initial: ZeroTier-like P2P mesh VPN server with multi-tenant Web UI

This commit is contained in:
xieyao
2026-06-17 03:50:23 +08:00
commit ee5f036325
74 changed files with 9517 additions and 0 deletions

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZeroMesh 管理控制台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1455
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
web/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "zeromesh-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"axios": "^1.7.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.4.0"
}
}

91
web/src/App.vue Normal file
View File

@@ -0,0 +1,91 @@
<template>
<router-view />
</template>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #6366f1;
--primary-light: #a5b4fc;
--primary-dark: #4f46e5;
--accent: #06b6d4;
--accent2: #d946ef;
--bg-deep: #020617;
--bg: #0b1120;
--bg-card: rgba(15, 23, 42, 0.8);
--bg-card-hover: rgba(30, 41, 59, 0.8);
--bg-input: rgba(30, 41, 59, 0.6);
--border: rgba(99, 102, 241, 0.15);
--border-glow: rgba(99, 102, 241, 0.3);
--text: #f1f5f9;
--text-muted: #64748b;
--text-dim: #475569;
--success: #22c55e;
--success-glow: rgba(34, 197, 94, 0.2);
--danger: #ef4444;
--warning: #eab308;
--glow-primary: 0 0 20px rgba(99, 102, 241, 0.15);
--glow-accent: 0 0 20px rgba(6, 182, 212, 0.15);
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.3);
}
@font-face {
font-family: 'System';
src: local(-apple-system), local(BlinkMacSystemFont), local('Segoe UI');
}
html {
font-size: 14px;
}
body {
font-family: 'System', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: var(--glow-primary); }
50% { box-shadow: 0 0 30px rgba(99, 102, 241, 0.25); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes grid-scroll {
0% { transform: translateY(0); }
100% { transform: translateY(40px); }
}
.fade-in { animation: fadeIn 0.4s ease-out; }
.slide-in { animation: slideIn 0.3s ease-out; }
</style>

53
web/src/api/index.js Normal file
View File

@@ -0,0 +1,53 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api/v1',
timeout: 10000,
})
api.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(err)
}
)
export default {
// Auth
checkAdmin: () => api.get('/admin/check'),
login: (username, password) => api.post('/auth/login', { username, password }),
register: (username, password) => api.post('/auth/register', { username, password }),
initAdmin: (username, password) => api.post('/auth/init', { username, password }),
// Dashboard (user-scoped)
dashboard: () => api.get('/dashboard'),
// Profile
profile: () => api.get('/user/profile'),
// Nodes
nodeRegister: data => api.post('/node/register', data),
nodeList: () => api.get('/node/list'),
nodeOnline: () => api.get('/node/online'),
// Networks
networkCreate: data => api.post('/network/create', data),
networkList: () => api.get('/network/list'),
networkGet: id => api.get(`/network/${id}`),
networkDelete: id => api.delete(`/network/${id}`),
networkMembers: id => api.get(`/network/${id}/members`),
networkAuthorize: (id, nodeId) => api.post(`/network/${id}/authorize`, { node_id: nodeId }),
networkDeauthorize: (id, nodeId) => api.post(`/network/${id}/deauthorize`, { node_id: nodeId }),
}

7
web/src/main.js Normal file
View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')

101
web/src/router/index.js Normal file
View File

@@ -0,0 +1,101 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
},
{
path: '/init',
name: 'Init',
component: () => import('../views/Init.vue'),
},
// Admin layout
{
path: '/',
component: () => import('../views/Layout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue'),
},
{
path: 'networks',
name: 'Networks',
component: () => import('../views/Networks.vue'),
},
{
path: 'network/create',
name: 'NetworkCreate',
component: () => import('../views/NetworkCreate.vue'),
},
{
path: 'nodes',
name: 'Nodes',
component: () => import('../views/Nodes.vue'),
},
{
path: 'docs',
name: 'Docs',
component: () => import('../views/Docs.vue'),
},
{
path: 'logs',
name: 'Logs',
component: () => import('../views/Logs.vue'),
},
],
},
// User layout
{
path: '/user',
component: () => import('../views/Layout.vue'),
redirect: '/user/dashboard',
children: [
{
path: 'dashboard',
name: 'UserDashboard',
component: () => import('../views/UserDashboard.vue'),
},
{
path: 'networks',
name: 'UserNetworks',
component: () => import('../views/UserNetworks.vue'),
},
{
path: 'network/create',
name: 'UserNetworkCreate',
component: () => import('../views/NetworkCreate.vue'),
},
{
path: 'networks/:id',
name: 'UserNetworkDetail',
component: () => import('../views/UserNetworkDetail.vue'),
},
{
path: 'nodes',
name: 'UserNodes',
component: () => import('../views/UserNodes.vue'),
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.path !== '/login' && to.path !== '/init' && !token) {
next('/login')
} else {
next()
}
})
export default router

230
web/src/views/Dashboard.vue Normal file
View File

@@ -0,0 +1,230 @@
<template>
<div class="dashboard">
<div class="page-header">
<div>
<h2 class="page-title">仪表盘</h2>
<p class="page-subtitle">系统概览与运行状态</p>
</div>
<div class="header-time">{{ timeStr }}</div>
</div>
<div v-if="loading" class="loading-state">
<div class="loading-ring"></div>
<span>加载系统状态...</span>
</div>
<template v-else>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card stat-users">
<div class="stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="stat-body">
<div class="stat-value">{{ stats.authorized_members || 0 }}</div>
<div class="stat-label">授权成员</div>
</div>
<div class="stat-trend">所有网络中的授权设备</div>
</div>
<div class="stat-card stat-networks">
<div class="stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</div>
<div class="stat-body">
<div class="stat-value">{{ stats.networks_count || 0 }}</div>
<div class="stat-label">虚拟网络</div>
</div>
<div class="stat-trend">已创建网络</div>
</div>
<div class="stat-card stat-online">
<div class="stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<div class="stat-body">
<div class="stat-value">{{ stats.nodes_online || 0 }}</div>
<div class="stat-label">在线节点</div>
</div>
<div class="stat-trend">
<span class="live-dot"></span> 实时在线
</div>
</div>
<div class="stat-card stat-total">
<div class="stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="stat-body">
<div class="stat-value">{{ stats.nodes_total || 0 }}</div>
<div class="stat-label">总节点</div>
</div>
<div class="stat-trend">全部注册节点</div>
</div>
</div>
<!-- Quick Start -->
<div class="card quick-start fade-in">
<div class="card-header-line">
<h3>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
快速开始
</h3>
</div>
<div class="steps">
<div class="step">
<div class="step-num">01</div>
<div class="step-body">
<strong>创建虚拟网络</strong>
<p> <router-link to="/networks">虚拟网络</router-link> 中定义 IP 范围和网络名称</p>
</div>
</div>
<div class="step-arrow">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5"><polyline points="5 12 19 12"/><polyline points="12 5 19 12 12 19"/></svg>
</div>
<div class="step">
<div class="step-num">02</div>
<div class="step-body">
<strong>注册节点</strong>
<p> <router-link to="/nodes">节点管理</router-link> 中注册每个设备</p>
</div>
</div>
<div class="step-arrow">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5"><polyline points="5 12 19 12"/><polyline points="12 5 19 12 12 19"/></svg>
</div>
<div class="step">
<div class="step-num">03</div>
<div class="step-body">
<strong>授权组网</strong>
<p>将节点授权到网络启动 agent 即可 P2P 直连</p>
</div>
</div>
</div>
</div>
<!-- System info -->
<div class="card sys-info fade-in">
<div class="card-header-line">
<h3>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
系统信息
</h3>
</div>
<div class="info-grid">
<div class="info-item"><span class="info-label">运行状态</span><span class="info-value"><span class="live-dot"></span> 正常</span></div>
<div class="info-item"><span class="info-label">API 版本</span><span class="info-value">v1</span></div>
<div class="info-item"><span class="info-label">端口</span><span class="info-value">10001</span></div>
<div class="info-item"><span class="info-label">数据库</span><span class="info-value">SQLite</span></div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import api from '../api'
const loading = ref(true)
const stats = ref({})
const timeStr = ref('')
let timer
onMounted(async () => {
updateTime()
timer = setInterval(updateTime, 1000)
try {
const [dashRes, nodeRes, netRes] = await Promise.all([
api.dashboard(),
api.nodeList().catch(() => ({ data: { nodes: [] } })),
api.networkList().catch(() => ({ data: { networks: [] } })),
])
const nodes = nodeRes.data.nodes || []
const networks = netRes.data.networks || []
stats.value = {
...dashRes.data,
nodes_total: nodes.length,
nodes_online: nodes.filter(n => n.online).length,
networks_count: networks.length,
}
} catch { stats.value = { authorized_members: 0, networks_count: 0, nodes_online: 0, nodes_total: 0 } }
finally { loading.value = false }
})
onUnmounted(() => clearInterval(timer))
function updateTime() {
const now = new Date()
timeStr.value = now.toLocaleString('zh-CN', { hour12: false })
}
</script>
<style scoped>
.dashboard { animation: fadeIn 0.4s ease-out; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
.page-title { font-size: 22px; font-weight: 700; }
.page-subtitle { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
.header-time { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; padding: 6px 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-card); }
.loading-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px; color: var(--text-muted); }
.loading-ring { width: 32px; height: 32px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Stats */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card {
background: var(--bg-card); border-radius: 14px; padding: 20px;
border: 1px solid var(--border);
backdrop-filter: blur(12px);
display: flex; flex-direction: column; gap: 12px;
transition: all 0.3s;
position: relative; overflow: hidden;
}
.stat-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
}
.stat-card:hover { transform: translateY(-2px); border-color: var(--border-glow); box-shadow: var(--glow-primary); }
.stat-users::before { background: linear-gradient(90deg, transparent, #6366f1, transparent); }
.stat-networks::before { background: linear-gradient(90deg, transparent, #06b6d4, transparent); }
.stat-online::before { background: linear-gradient(90deg, transparent, #22c55e, transparent); }
.stat-total::before { background: linear-gradient(90deg, transparent, #d946ef, transparent); }
.stat-card { display: flex; flex-direction: column; }
.stat-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.stat-users .stat-icon { background: rgba(99, 102, 241, 0.1); color: #a5b4fc; }
.stat-networks .stat-icon { background: rgba(6, 182, 212, 0.1); color: #67e8f9; }
.stat-online .stat-icon { background: rgba(34, 197, 94, 0.1); color: #86efac; }
.stat-total .stat-icon { background: rgba(217, 70, 239, 0.1); color: #f0abfc; }
.stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.5px; }
.stat-label { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
.stat-trend { font-size: 11px; color: var(--text-dim); }
/* Cards */
.card {
background: var(--bg-card); border-radius: 14px; padding: 24px;
border: 1px solid var(--border);
backdrop-filter: blur(12px);
margin-bottom: 16px;
}
.card-header-line { margin-bottom: 20px; }
.card-header-line h3 { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; }
/* Quick start steps */
.steps { display: flex; align-items: center; gap: 16px; }
.step { display: flex; align-items: flex-start; gap: 14px; flex: 1; }
.step-num { font-size: 24px; font-weight: 800; color: var(--text-dim); line-height: 1; }
.step-body strong { display: block; font-size: 14px; margin-bottom: 2px; }
.step-body p { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
.step-body a { color: var(--primary-light); text-decoration: none; }
.step-body a:hover { text-decoration: underline; }
.step-arrow { flex-shrink: 0; }
/* System info */
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.info-item { padding: 12px 16px; border-radius: 10px; background: rgba(0,0,0,0.2); border: 1px solid var(--border); }
.info-label { display: block; font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.info-value { font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 6px; }
.live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); box-shadow: 0 0 6px var(--success-glow); display: inline-block; }
</style>

715
web/src/views/Docs.vue Normal file
View File

@@ -0,0 +1,715 @@
<template>
<div class="docs fade-in">
<div class="page-header">
<div>
<h2 class="page-title">开发者文档</h2>
<p class="page-subtitle">了解 ZeroMesh 架构API 和客户端接入协议</p>
</div>
<button class="btn-copy-all" @click="copyAllContent" :title="copied ? '已复制!' : '复制全部文档内容AI 友好格式)'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
{{ copied ? '已复制' : '复制全文' }}
</button>
</div>
<!-- Tabs -->
<div class="tabs">
<button v-for="t in tabs" :key="t.id" :class="['tab', { active: activeTab === t.id }]" @click="activeTab = t.id">
<span class="tab-indicator"></span>{{ t.label }}
</button>
</div>
<!-- Architecture -->
<div v-if="activeTab === 'arch'" class="doc-section fade-in">
<div class="section-card">
<div class="card-shine"></div>
<h3>系统架构</h3>
<p class="lead-text">ZeroMesh 采用<strong>双平面架构</strong>控制面基于 HTTP REST API数据面基于 UDP P2P 加密传输</p>
<div class="arch-grid">
<div class="arch-card">
<div class="arch-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
</div>
<h4>控制面 (Control Plane)</h4>
<p class="arch-port">HTTP :10001</p>
<ul>
<li>管理员登录 / 注册</li>
<li>网络和节点管理</li>
<li>节点授权 / 撤销</li>
<li>监控仪表盘</li>
</ul>
</div>
<div class="arch-connector">
<div class="connector-line"></div>
<div class="connector-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</div>
</div>
<div class="arch-card">
<div class="arch-icon accent">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
</div>
<h4>数据面 (Data Plane)</h4>
<p class="arch-port">UDP :19993</p>
<ul>
<li>P2P 加密隧道 (VL1)</li>
<li>Noise Protocol + ChaCha20-Poly1305</li>
<li>虚拟二层交换 (VL2)</li>
<li>NAT 穿透 (STUN/ICE)</li>
</ul>
</div>
</div>
<div class="flow-diagram">
<div class="flow-title">客户端接入流程</div>
<div class="flow-steps">
<div class="flow-step">
<div class="step-num">1</div>
<div>生成 Ed25519 身份</div>
</div>
<div class="flow-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></div>
<div class="flow-step">
<div class="step-num">2</div>
<div>API 注册节点</div>
</div>
<div class="flow-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></div>
<div class="flow-step">
<div class="step-num">3</div>
<div>获取授权 &amp; 节点列表</div>
</div>
<div class="flow-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></div>
<div class="flow-step">
<div class="step-num">4</div>
<div>UDP 握手 + P2P 加密通信</div>
</div>
</div>
</div>
</div>
</div>
<!-- API Reference -->
<div v-if="activeTab === 'api'" class="doc-section fade-in">
<div class="section-card">
<div class="card-shine"></div>
<h3>HTTP API 参考</h3>
<p class="lead-text">所有 API 接口位于 <code>/api/v1</code> 前缀下认证通过 <code>Authorization: Bearer &lt;token&gt;</code> 头传递</p>
<div class="api-group">
<h4 class="group-title">认证</h4>
<div v-for="ep in authEndpoints" :key="ep.method + ep.path" class="api-row">
<span :class="'http-method ' + ep.method.toLowerCase()">{{ ep.method }}</span>
<code class="api-path">{{ ep.path }}</code>
<span class="api-desc">{{ ep.desc }}</span>
</div>
</div>
<div class="api-group">
<h4 class="group-title">节点管理</h4>
<div v-for="ep in nodeEndpoints" :key="ep.method + ep.path" class="api-row">
<span :class="'http-method ' + ep.method.toLowerCase()">{{ ep.method }}</span>
<code class="api-path">{{ ep.path }}</code>
<span class="api-desc">{{ ep.desc }}</span>
</div>
</div>
<div class="api-group">
<h4 class="group-title">网络管理</h4>
<div v-for="ep in networkEndpoints" :key="ep.method + ep.path" class="api-row">
<span :class="'http-method ' + ep.method.toLowerCase()">{{ ep.method }}</span>
<code class="api-path">{{ ep.path }}</code>
<span class="api-desc">{{ ep.desc }}</span>
</div>
</div>
<div class="api-group">
<h4 class="group-title">仪表盘</h4>
<div v-for="ep in dashEndpoints" :key="ep.method + ep.path" class="api-row">
<span :class="'http-method ' + ep.method.toLowerCase()">{{ ep.method }}</span>
<code class="api-path">{{ ep.path }}</code>
<span class="api-desc">{{ ep.desc }}</span>
</div>
</div>
</div>
<!-- Register Example -->
<div class="section-card">
<div class="card-shine"></div>
<h3>注册节点示例</h3>
<div class="code-block">
<div class="code-header">
<span class="code-lang">Shell / cURL</span>
<button class="code-copy" @click="copyCode(registerExample)">复制</button>
</div>
<pre>{{ registerExample }}</pre>
</div>
</div>
</div>
<!-- VL1 Protocol -->
<div v-if="activeTab === 'vl1'" class="doc-section fade-in">
<div class="section-card">
<div class="card-shine"></div>
<h3>VL1 传输协议</h3>
<p class="lead-text">VL1 ZeroMesh P2P 加密传输层基于 UDP 实现节点间的安全通信</p>
<h4>协议概览</h4>
<table class="spec-table">
<tr><td>传输层</td><td>UDP (IPv4/IPv6)</td></tr>
<tr><td>加密算法</td><td>ChaCha20-Poly1305 (AEAD)</td></tr>
<tr><td>密钥派生</td><td>SHA3-256</td></tr>
<tr><td>Nonce</td><td>96-bit (12 bytes) 4 zero + 8 byte big-endian 计数器</td></tr>
<tr><td>身份</td><td>Ed25519 签名密钥对</td></tr>
<tr><td>地址</td><td>5 bytes (10 hex 字符)从公钥哈希派生</td></tr>
<tr><td>最大包长</td><td>65535 bytes</td></tr>
<tr><td>保活间隔</td><td>25 </td></tr>
<tr><td>超时断开</td><td>90 秒无数据</td></tr>
</table>
<h4>包格式 (8 字节头 + 负载)</h4>
<table class="spec-table mono">
<thead><tr><th>偏移</th><th>大小</th><th>字段</th><th>编码</th></tr></thead>
<tbody>
<tr><td>0</td><td>1</td><td>版本</td><td><code>0x01</code></td></tr>
<tr><td>1</td><td>1</td><td>类型</td><td><code>1</code>=Handshake, <code>2</code>=Data, <code>3</code>=Keepalive</td></tr>
<tr><td>2</td><td>4</td><td>网络 ID</td><td>uint32 big-endian</td></tr>
<tr><td>6</td><td>2</td><td>负载长度</td><td>uint16 big-endian</td></tr>
<tr><td>8</td><td>N</td><td>负载</td><td>原始字节 (Data 类型为加密密文)</td></tr>
</tbody>
</table>
<h4>连接流程</h4>
<div class="steps-list">
<div class="step-item">
<div class="step-bullet"></div>
<div><strong>握手</strong> 发送方发送 Handshake 包含身份公钥 + 临时会话密钥接收方回复 Handshake 确认</div>
</div>
<div class="step-item">
<div class="step-bullet"></div>
<div><strong>加密通信</strong> 双方通过 Noise 协议派生对称密钥后续 Data 包使用 ChaCha20-Poly1305 加密</div>
</div>
<div class="step-item">
<div class="step-bullet"></div>
<div><strong>保活</strong> 25 秒发送空负载 Keepalive 超过 90 秒未收到数据则断开</div>
</div>
</div>
<h4>密钥派生</h4>
<div class="code-block">
<div class="code-header">
<span class="code-lang">Pseudo-code</span>
<button class="code-copy" @click="copyCode(keyDerivationExample)">复制</button>
</div>
<pre>{{ keyDerivationExample }}</pre>
</div>
</div>
</div>
<!-- Client Dev -->
<div v-if="activeTab === 'client'" class="doc-section fade-in">
<div class="section-card">
<div class="card-shine"></div>
<h3>客户端开发指南</h3>
<p class="lead-text">如何从零开始实现一个 ZeroMesh 客户端接入 P2P 虚拟网络</p>
<h4>第一步准备身份</h4>
<p>每个节点需要一个唯一的 Ed25519 密钥对40-bit 地址由公钥哈希的前 5 字节决定</p>
<div class="code-block">
<div class="code-header"><span class="code-lang">Go</span></div>
<pre>id := identity.Generate()
// id.Address → "b412579ffd" (10 hex chars)
// id.PublicKey → [32]byte
// id.PrivateKey → [64]byte</pre>
</div>
<h4>第二步通过 API 注册</h4>
<p>向控制面注册节点获取授权后才能加入网络</p>
<div class="code-block">
<div class="code-header"><span class="code-lang">cURL</span></div>
<pre>curl -X POST http://&lt;controller&gt;:10001/api/v1/node/register \
-H "Authorization: Bearer &lt;token&gt;" \
-H "Content-Type: application/json" \
-d '{
"node_id": "b412579ffd",
"public_key": "&lt;64 hex chars&gt;",
"name": "my-device",
"port": 0
}'</pre>
</div>
<h4>第三步建立 UDP 连接</h4>
<p>客户端启动 UDP socket连接到控制器获取在线节点列表然后向每个节点发起 VL1 握手</p>
<div class="steps-list">
<div class="step-item">
<div class="step-bullet"></div>
<div>绑定 UDP 端口可指定或由 OS 分配</div>
</div>
<div class="step-item">
<div class="step-bullet"></div>
<div>向控制器发送本机地址 + 端口通过 API update</div>
</div>
<div class="step-item">
<div class="step-bullet"></div>
<div><code>GET /api/v1/node/online</code> 获取在线节点列表</div>
</div>
<div class="step-item">
<div class="step-bullet"></div>
<div>向每个在线节点发送 VL1 Handshake </div>
</div>
<div class="step-item">
<div class="step-bullet"></div>
<div>接收 Handshake 回复建立 Noise 加密会话</div>
</div>
<div class="step-item">
<div class="step-bullet"></div>
<div>开始发送/接收加密 Data </div>
</div>
</div>
<h4>第四步处理 VL2 虚拟交换</h4>
<p>VL2 层在加密隧道之上模拟以太网交换MAC 地址学习广播泛洪ARP 代答客户端收到的 Data 包负载即为 VL2 </p>
<h4>加密注意事项</h4>
<ul class="note-list">
<li>Nonce 计数器从 0 开始每次加密/解密后递增</li>
<li>发送和接收使用独立的密钥和计数器</li>
<li>预共享密钥 (PSK) 通过控制器下发用于派生会话密钥</li>
<li><code>sendKey = SHA3-256(PSK || localPub || remotePub)</code></li>
<li><code>recvKey = SHA3-256(sendKey || "reverse")</code></li>
</ul>
</div>
</div>
<!-- VL2 -->
<div v-if="activeTab === 'vl2'" class="doc-section fade-in">
<div class="section-card">
<div class="card-shine"></div>
<h3>VL2 虚拟以太网交换</h3>
<p class="lead-text">VL2 VL1 加密隧道之上模拟二层网络交换使得所有节点如同连接在同一台虚拟交换机上</p>
<h4>核心特性</h4>
<table class="spec-table">
<tr><td>MAC 地址</td><td>从节点 40-bit 地址确定性生成</td></tr>
<tr><td>MAC 学习表</td><td>自动学习端口-MAC 映射超时清理</td></tr>
<tr><td>广播泛洪</td><td>未知目标 MAC 的数据包向所有节点广播</td></tr>
<tr><td>ARP 代答</td><td>交换机代理 ARP 请求减少广播</td></tr>
<tr><td>MTU</td><td>2800 bytes (可配置)</td></tr>
</table>
<h4>MAC 地址生成</h4>
<div class="code-block">
<div class="code-header"><span class="code-lang">Go</span></div>
<pre>// 节点地址: b412579ffd (5 bytes)
// MAC: 02:XX:XX:XX:XX:XX (前 2 字节固定为 0x02, 0xXX)
// 后 4 字节来自地址哈希
func GenerateMAC(addr identity.Address) net.HardwareAddr {
mac := make(net.HardwareAddr, 6)
mac[0] = 0x02 // locally administered
copy(mac[2:], addr[:4])
return mac
}</pre>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const activeTab = ref('arch')
const copied = ref(false)
function buildAllContent() {
const lines = []
const L = (s) => lines.push(s)
const sep = () => L('---')
L('# ZeroMesh 开发者文档')
L('')
L('## 系统架构')
L('')
L('ZeroMesh 采用双平面架构:控制面基于 HTTP REST API数据面基于 UDP P2P 加密传输。')
L('')
L('### 控制面 (Control Plane)')
L('- 端口: HTTP :10001')
L('- 管理员登录 / 注册')
L('- 网络和节点管理')
L('- 节点授权 / 撤销')
L('- 监控仪表盘')
L('')
L('### 数据面 (Data Plane)')
L('- 端口: UDP :19993')
L('- P2P 加密隧道 (VL1)')
L('- Noise Protocol + ChaCha20-Poly1305')
L('- 虚拟二层交换 (VL2)')
L('- NAT 穿透 (STUN/ICE)')
L('')
L('### 客户端接入流程')
L('1. 生成 Ed25519 身份')
L('2. API 注册节点')
L('3. 获取授权 & 节点列表')
L('4. UDP 握手 + P2P 加密通信')
L('')
sep()
L('')
L('## HTTP API 参考')
L('')
L('基础路径: /api/v1')
L('认证: Authorization: Bearer <token>')
L('')
L('### 认证')
L('| 方法 | 路径 | 说明 |')
L('|------|------|------|')
L('| GET | /api/v1/admin/check | 检查管理员身份 |')
L('| POST | /api/v1/auth/login | 管理员登录 { username, password } |')
L('| POST | /api/v1/auth/register | 管理员注册 |')
L('')
L('### 节点管理')
L('| 方法 | 路径 | 说明 |')
L('|------|------|------|')
L('| POST | /api/v1/node/register | 注册节点 { node_id, public_key, name?, port? } |')
L('| GET | /api/v1/node/list | 获取所有节点列表 |')
L('| GET | /api/v1/node/online | 获取在线节点列表 |')
L('')
L('### 网络管理')
L('| 方法 | 路径 | 说明 |')
L('|------|------|------|')
L('| POST | /api/v1/network/create | 创建虚拟网络 { name, ip_range } |')
L('| GET | /api/v1/network/list | 获取所有网络 |')
L('| GET | /api/v1/network/:id | 获取网络详情 |')
L('| DELETE | /api/v1/network/:id | 删除网络 |')
L('| GET | /api/v1/network/:id/members | 获取网络成员 |')
L('| POST | /api/v1/network/:id/authorize | 授权节点加入网络 { node_id } |')
L('| POST | /api/v1/network/:id/deauthorize | 移除节点授权 { node_id } |')
L('')
L('### 仪表盘')
L('| 方法 | 路径 | 说明 |')
L('|------|------|------|')
L('| GET | /api/v1/admin/dashboard | 仪表盘统计数据 |')
L('')
L('### 注册节点示例 (cURL)')
L('')
L('```bash')
L('# 登录获取 token')
L('TOKEN=$(curl -s http://127.0.0.1:10001/api/v1/auth/login \\')
L(' -H "Content-Type: application/json" \\')
L(" -d '{\"username\":\"admin\",\"password\":\"zeromesh123\"}' | jq -r '.token')")
L('')
L('# 注册节点')
L('curl -X POST http://127.0.0.1:10001/api/v1/node/register \\')
L(' -H "Authorization: Bearer $TOKEN" \\')
L(' -H "Content-Type: application/json" \\')
L(" -d '{")
L(' \"node_id\": \"b412579ffd\",')
L(' \"public_key\": \"abc123...\",')
L(' \"name\": \"server-01\",')
L(' \"ip_address\": \"10.147.1.2\",')
L(' \"port\": 9993,')
L(' \"version\": \"1.0.0\"')
L(" }'")
L('')
L('# 获取节点列表')
L('curl http://127.0.0.1:10001/api/v1/node/list \\')
L(' -H "Authorization: Bearer $TOKEN"')
L('```')
L('')
sep()
L('')
L('## VL1 传输协议')
L('')
L('### 协议概览')
L('- 传输层: UDP (IPv4/IPv6)')
L('- 加密算法: ChaCha20-Poly1305 (AEAD)')
L('- 密钥派生: SHA3-256')
L('- Nonce: 96-bit (12 bytes) --- 4 zero + 8 byte big-endian 计数器')
L('- 身份: Ed25519 签名密钥对')
L('- 地址: 5 bytes (10 hex 字符),从公钥哈希派生')
L('- 最大包长: 65535 bytes')
L('- 保活间隔: 25 秒')
L('- 超时断开: 90 秒无数据')
L('')
L('### 包格式 (8 字节头 + 负载)')
L('| 偏移 | 大小 | 字段 | 编码 |')
L('|------|------|------|------|')
L('| 0 | 1 | 版本 | 0x01 |')
L('| 1 | 1 | 类型 | 1=Handshake, 2=Data, 3=Keepalive |')
L('| 2 | 4 | 网络 ID | uint32 big-endian |')
L('| 6 | 2 | 负载长度 | uint16 big-endian |')
L('| 8 | N | 负载 | 原始字节 (Data 类型为加密密文) |')
L('')
L('### 连接流程')
L('1. 握手 --- 发送 Handshake 包(含身份公钥 + 临时会话密钥),接收 Handshake 确认')
L('2. 加密通信 --- Noise 协议派生对称密钥Data 包使用 ChaCha20-Poly1305 加密')
L('3. 保活 --- 每 25 秒发送 Keepalive 包;超过 90 秒无数据则断开')
L('')
L('### 密钥派生')
L('sendKey = SHA3-256(PSK || localPub || remotePub)[:32]')
L('recvKey = SHA3-256(sendKey || "reverse")[:32]')
L('')
L('Nonce = [0,0,0,0, counter big-endian], counter 每次加密/解密后递增')
L('')
sep()
L('')
L('## VL2 虚拟以太网交换')
L('')
L('### 核心特性')
L('- MAC 地址: 从节点 40-bit 地址确定性生成')
L('- MAC 学习表: 自动学习端口-MAC 映射,超时清理')
L('- 广播泛洪: 未知目标 MAC 的数据包向所有节点广播')
L('- ARP 代答: 交换机代理 ARP 请求,减少广播')
L('- MTU: 2800 bytes (可配置)')
L('')
L('### MAC 地址生成')
L('Go 代码:')
L('func GenerateMAC(addr identity.Address) net.HardwareAddr {')
L(' mac := make(net.HardwareAddr, 6)')
L(' mac[0] = 0x02 // locally administered')
L(' copy(mac[2:], addr[:4])')
L(' return mac')
L('}')
L('')
sep()
L('')
L('## 客户端开发指南')
L('')
L('### 第一步:准备身份')
L('每个节点需要唯一的 Ed25519 密钥对。40-bit 地址由公钥哈希的前 5 字节决定。')
L('Go: id := identity.Generate()')
L('- id.Address -> "b412579ffd" (10 hex chars)')
L('- id.PublicKey -> [32]byte')
L('- id.PrivateKey -> [64]byte')
L('')
L('### 第二步:通过 API 注册')
L('POST /api/v1/node/register')
L('Body: { node_id, public_key, name, port }')
L('')
L('### 第三步:建立 UDP 连接')
L('1. 绑定 UDP 端口')
L('2. 向控制器发送本机地址 + 端口')
L('3. GET /api/v1/node/online 获取在线节点列表')
L('4. 向每个在线节点发送 VL1 Handshake 包')
L('5. 接收 Handshake 回复,建立 Noise 加密会话')
L('6. 开始发送/接收加密 Data 包')
L('')
L('### 第四步:处理 VL2 虚拟交换')
L('在加密隧道之上模拟以太网交换MAC 地址学习、广播泛洪、ARP 代答。')
L('')
L('### 加密注意事项')
L('- Nonce 计数器从 0 开始,每次加密/解密后递增')
L('- 发送和接收使用独立的密钥和计数器')
L('- PSK 通过控制器下发,用于派生会话密钥')
L('- sendKey = SHA3-256(PSK || localPub || remotePub)[:32]')
L('- recvKey = SHA3-256(sendKey || "reverse")[:32]')
return lines.join('\n')
}
async function copyAllContent() {
try {
await navigator.clipboard.writeText(buildAllContent())
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch { /* fallback */ }
}
const tabs = [
{ id: 'arch', label: '架构' },
{ id: 'api', label: 'API 参考' },
{ id: 'vl1', label: 'VL1 协议' },
{ id: 'vl2', label: 'VL2 交换' },
{ id: 'client', label: '客户端开发' },
]
const authEndpoints = [
{ method: 'GET', path: '/api/v1/admin/check', desc: '检查管理员身份' },
{ method: 'POST', path: '/api/v1/auth/login', desc: '管理员登录 { username, password }' },
{ method: 'POST', path: '/api/v1/auth/register', desc: '管理员注册' },
]
const nodeEndpoints = [
{ method: 'POST', path: '/api/v1/node/register', desc: '注册节点 { node_id, public_key, name?, port? }' },
{ method: 'GET', path: '/api/v1/node/list', desc: '获取所有节点列表' },
{ method: 'GET', path: '/api/v1/node/online', desc: '获取在线节点列表' },
]
const networkEndpoints = [
{ method: 'POST', path: '/api/v1/network/create', desc: '创建虚拟网络 { name, ip_range }' },
{ method: 'GET', path: '/api/v1/network/list', desc: '获取所有网络' },
{ method: 'GET', path: '/api/v1/network/:id', desc: '获取网络详情' },
{ method: 'DELETE', path: '/api/v1/network/:id', desc: '删除网络' },
{ method: 'GET', path: '/api/v1/network/:id/members', desc: '获取网络成员' },
{ method: 'POST', path: '/api/v1/network/:id/authorize', desc: '授权节点加入网络 { node_id }' },
{ method: 'POST', path: '/api/v1/network/:id/deauthorize', desc: '移除节点授权 { node_id }' },
]
const dashEndpoints = [
{ method: 'GET', path: '/api/v1/admin/dashboard', desc: '仪表盘统计数据' },
]
const registerExample = `# 登录获取 token
TOKEN=$(curl -s http://127.0.0.1:10001/api/v1/auth/login \\
-H "Content-Type: application/json" \\
-d '{"username":"admin","password":"zeromesh123"}' | jq -r '.token')
# 注册节点
curl -X POST http://127.0.0.1:10001/api/v1/node/register \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
"node_id": "b412579ffd",
"public_key": "abc123...",
"name": "server-01",
"ip_address": "10.147.1.2",
"port": 9993,
"version": "1.0.0"
}'
# 获取节点列表
curl http://127.0.0.1:10001/api/v1/node/list \\
-H "Authorization: Bearer $TOKEN"`
const keyDerivationExample = `sendKey = SHA3-256(PSK || localPub || remotePub)[:32]
recvKey = SHA3-256(sendKey || "reverse")[:32]
// Encrypt(nonce=0) → 密文 + Poly1305 tag
nonce = [0,0,0,0, counter big-endian]
ciphertext = ChaCha20-Poly1305_Encrypt(sendKey, nonce, plaintext)
counter++
// Decrypt(nonce=0) → 明文
plaintext = ChaCha20-Poly1305_Decrypt(recvKey, nonce, ciphertext)
counter++`
async function copyCode(text) {
try {
await navigator.clipboard.writeText(text)
} catch { /* fallback */ }
}
</script>
<style scoped>
.docs { animation: fadeIn 0.4s ease-out; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
.page-title { font-size: 22px; font-weight: 700; }
.page-subtitle { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
/* Tabs */
.tabs { display: flex; gap: 0; background: var(--bg-card); border-radius: 12px; border: 1px solid var(--border); padding: 4px; margin-bottom: 24px; backdrop-filter: blur(12px); overflow-x: auto; }
.tab {
display: flex; align-items: center; gap: 7px;
padding: 9px 20px; border: none; border-radius: 9px;
background: transparent; color: var(--text-muted); font-size: 13px; font-weight: 500; cursor: pointer;
transition: all 0.2s; white-space: nowrap;
}
.tab:hover { color: var(--text); background: rgba(99, 102, 241, 0.04); }
.tab.active { color: var(--primary-light); background: rgba(99, 102, 241, 0.1); }
.tab-indicator { width: 5px; height: 5px; border-radius: 50%; background: currentColor; opacity: 0; transition: opacity 0.2s; }
.tab.active .tab-indicator { opacity: 1; }
/* Cards */
.btn-copy-all {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border: 1px solid var(--border); border-radius: 10px;
background: var(--bg-card); color: var(--text-muted); font-size: 13px; font-weight: 500; cursor: pointer;
transition: all 0.25s; white-space: nowrap;
}
.btn-copy-all:hover { border-color: var(--primary); color: var(--primary-light); background: rgba(99, 102, 241, 0.06); box-shadow: 0 0 12px rgba(99, 102, 241, 0.15); }
.doc-section { animation: fadeIn 0.3s ease-out; }
.section-card {
position: relative; background: var(--bg-card); border-radius: 14px;
border: 1px solid var(--border); padding: 28px; margin-bottom: 20px;
backdrop-filter: blur(12px); overflow: hidden;
}
.card-shine { position: absolute; top: 0; left: 0; right: 0; height: 2px; background: linear-gradient(90deg, transparent, var(--primary-light), var(--accent), transparent); }
.section-card h3 { font-size: 18px; font-weight: 700; margin-bottom: 12px; }
.section-card h4 { font-size: 14px; font-weight: 600; color: var(--primary-light); margin: 20px 0 10px; }
.section-card h4:first-of-type { margin-top: 0; }
.lead-text { font-size: 14px; color: var(--text-muted); line-height: 1.6; margin-bottom: 20px; }
.lead-text code { background: var(--bg-input); padding: 1px 6px; border-radius: 4px; font-size: 13px; color: var(--accent); }
p { font-size: 13px; color: var(--text-muted); line-height: 1.7; margin-bottom: 12px; }
/* Architecture Grid */
.arch-grid { display: flex; align-items: stretch; gap: 0; margin: 24px 0; }
.arch-card {
flex: 1; padding: 24px; border: 1px solid var(--border);
border-radius: 12px; background: rgba(0,0,0,0.15);
transition: all 0.3s;
}
.arch-card:hover { border-color: var(--border-glow); box-shadow: var(--glow-primary); }
.arch-card h4 { margin: 12px 0 4px; font-size: 14px; font-weight: 600; }
.arch-card ul { list-style: none; padding: 0; margin: 8px 0 0; }
.arch-card li { padding: 3px 0; font-size: 12px; color: var(--text-muted); }
.arch-card li::before { content: ''; margin-right: 6px; color: var(--primary); font-weight: bold; }
.arch-port { font-family: monospace; font-size: 12px; color: var(--accent); background: rgba(6, 182, 212, 0.08); padding: 2px 8px; border-radius: 5px; display: inline-block; margin-top: 4px; }
.arch-icon { width: 40px; height: 40px; border-radius: 10px; background: rgba(99, 102, 241, 0.1); display: flex; align-items: center; justify-content: center; color: var(--primary-light); }
.arch-icon.accent { background: rgba(6, 182, 212, 0.1); color: var(--accent); }
.arch-icon svg { width: 22px; height: 22px; }
.arch-connector { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 0 12px; color: var(--text-dim); }
.connector-line { width: 1px; height: 20px; background: var(--border); }
.connector-arrow svg { width: 20px; height: 20px; }
/* Flow diagram */
.flow-diagram { margin: 24px 0 0; padding: 20px; border: 1px solid var(--border); border-radius: 12px; background: rgba(0,0,0,0.1); }
.flow-title { font-size: 13px; font-weight: 600; color: var(--text-muted); margin-bottom: 16px; text-transform: uppercase; letter-spacing: 0.5px; }
.flow-steps { display: flex; align-items: center; gap: 0; flex-wrap: wrap; }
.flow-step { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border: 1px solid var(--border); border-radius: 10px; background: rgba(99, 102, 241, 0.04); font-size: 13px; color: var(--text); transition: all 0.2s; }
.flow-step:hover { border-color: var(--primary); background: rgba(99, 102, 241, 0.08); }
.step-num { width: 24px; height: 24px; border-radius: 50%; background: linear-gradient(135deg, var(--primary), var(--accent)); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0; }
.flow-chevron { padding: 0 8px; color: var(--text-dim); }
.flow-chevron svg { width: 18px; height: 18px; }
/* API Reference */
.api-group { margin-bottom: 16px; }
.group-title { font-size: 12px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
.api-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; }
.http-method { display: inline-block; padding: 2px 8px; border-radius: 5px; font-size: 11px; font-weight: 700; font-family: monospace; text-transform: uppercase; min-width: 50px; text-align: center; }
.http-method.get { background: rgba(34, 197, 94, 0.08); color: var(--success); }
.http-method.post { background: rgba(99, 102, 241, 0.08); color: var(--primary-light); }
.http-method.delete { background: rgba(239, 68, 68, 0.08); color: var(--danger); }
.api-path { font-family: monospace; font-size: 13px; color: var(--text); }
.api-desc { font-size: 12px; color: var(--text-muted); }
/* Code blocks */
.code-block {
margin: 12px 0; border: 1px solid var(--border); border-radius: 10px;
overflow: hidden; background: rgba(2, 6, 23, 0.6);
}
.code-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 14px; background: rgba(99, 102, 241, 0.04); border-bottom: 1px solid var(--border); }
.code-lang { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; }
.code-copy { background: none; border: 1px solid var(--border); color: var(--text-muted); padding: 3px 10px; border-radius: 6px; font-size: 11px; cursor: pointer; transition: all 0.15s; }
.code-copy:hover { background: var(--bg-input); color: var(--text); }
.code-block pre { padding: 14px; font-size: 12px; line-height: 1.6; color: var(--text); overflow-x: auto; white-space: pre; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
/* Spec table */
.spec-table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 13px; }
.spec-table td, .spec-table th { padding: 8px 12px; border: 1px solid var(--border); text-align: left; }
.spec-table th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; background: rgba(0,0,0,0.15); }
.spec-table td { color: var(--text-muted); }
.spec-table.mono td { font-family: monospace; font-size: 12px; }
.spec-table.mono td:first-child, .spec-table.mono td:nth-child(2) { color: var(--accent); }
.spec-table.mono td:last-child { color: var(--text); }
/* Steps list */
.steps-list { margin: 12px 0; }
.step-item { display: flex; align-items: flex-start; gap: 10px; padding: 6px 0; font-size: 13px; color: var(--text-muted); line-height: 1.6; }
.step-bullet { width: 6px; height: 6px; border-radius: 50%; background: var(--primary); flex-shrink: 0; margin-top: 7px; box-shadow: 0 0 6px rgba(99, 102, 241, 0.3); }
.note-list { list-style: none; padding: 0; margin: 8px 0; }
.note-list li { padding: 5px 0; font-size: 13px; color: var(--text-muted); }
.note-list li::before { content: ''; margin-right: 8px; color: var(--accent); font-weight: bold; }
@media (max-width: 768px) {
.arch-grid { flex-direction: column; }
.arch-connector { flex-direction: row; padding: 8px 0; }
.connector-line { width: 20px; height: 1px; }
.connector-arrow svg { transform: rotate(90deg); }
.flow-steps { flex-direction: column; align-items: flex-start; }
.flow-chevron { transform: rotate(90deg); padding: 4px 0; }
}
</style>

190
web/src/views/Init.vue Normal file
View File

@@ -0,0 +1,190 @@
<template>
<div class="init-page">
<div class="bg-grid"></div>
<div class="bg-orb"></div>
<div class="init-card">
<div class="card-shine"></div>
<div class="card-badge">首次部署</div>
<svg class="init-icon" viewBox="0 0 48 48" width="48" height="48">
<defs>
<linearGradient id="ig" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1" />
<stop offset="100%" stop-color="#06b6d4" />
</linearGradient>
</defs>
<path d="M24 4L4 14v20l20 10 20-10V14L24 4z" fill="none" stroke="url(#ig)" stroke-width="2" />
<path d="M24 24L8 16v12l16 8 16-8V16L24 24z" fill="none" stroke="url(#ig)" stroke-width="1.5" opacity="0.5" />
<circle cx="24" cy="16" r="4" fill="url(#ig)" opacity="0.6" />
</svg>
<h1>初始化管理员</h1>
<p class="desc">创建系统首个管理员账号用于管理虚拟网络和节点</p>
<div v-if="error" class="error-bar">{{ error }}</div>
<div v-if="success" class="success-bar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<div>
<strong>管理员创建成功</strong><br/>
<router-link to="/login">立即登录控制台 </router-link>
</div>
</div>
<form v-else @submit.prevent="handleInit">
<div class="field">
<label>管理员用户名</label>
<div class="input-wrap">
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<input v-model="username" type="text" placeholder="admin" required />
</div>
</div>
<div class="field">
<label>密码</label>
<div class="input-wrap">
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input v-model="password" type="password" placeholder="设置密码" required />
</div>
</div>
<button type="submit" class="btn-submit" :disabled="loading">
<span v-if="loading" class="spinner"></span>
<span v-else>创建管理员</span>
</button>
</form>
<div class="footer">
已有账号<router-link to="/login">去登录</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import api from '../api'
const router = useRouter()
const username = ref('admin')
const password = ref('')
const error = ref('')
const loading = ref(false)
const success = ref(false)
onMounted(async () => {
try {
const res = await api.checkAdmin()
if (res.data.admin_exists) router.push('/login')
} catch { /* allow */ }
})
async function handleInit() {
error.value = ''
loading.value = true
try {
const res = await api.initAdmin(username.value, password.value)
if (res.data.token) {
localStorage.setItem('token', res.data.token)
localStorage.setItem('username', res.data.user.username)
localStorage.setItem('role', 'admin')
router.push('/dashboard')
return
}
success.value = true
} catch (e) {
error.value = e.response?.data?.error || '创建失败'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.init-page {
display: flex; align-items: center; justify-content: center;
min-height: 100vh; position: relative; overflow: hidden;
background: var(--bg-deep);
}
.bg-grid {
position: fixed; inset: 0; pointer-events: none;
background-image:
linear-gradient(rgba(99, 102, 241, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.04) 1px, transparent 1px);
background-size: 48px 48px;
}
.bg-orb {
position: fixed; width: 400px; height: 400px;
top: 50%; left: 50%; transform: translate(-50%, -50%);
border-radius: 50%; pointer-events: none;
background: rgba(99, 102, 241, 0.1);
filter: blur(120px);
}
.init-card {
position: relative; z-index: 1;
width: 400px; padding: 40px;
border-radius: 20px;
border: 1px solid var(--border);
background: rgba(11, 17, 32, 0.7);
backdrop-filter: blur(24px);
box-shadow: var(--shadow-card), 0 0 60px rgba(99, 102, 241, 0.03);
overflow: hidden;
}
.card-shine {
position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg, transparent, var(--primary-light), var(--accent), transparent);
}
.card-badge {
display: inline-block; padding: 3px 10px; border-radius: 20px;
font-size: 10px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase;
background: rgba(234, 179, 8, 0.1); color: var(--warning);
border: 1px solid rgba(234, 179, 8, 0.2); margin-bottom: 16px;
}
.init-icon { margin-bottom: 16px; }
h1 { font-size: 22px; font-weight: 700; margin-bottom: 6px; }
.desc { font-size: 13px; color: var(--text-muted); margin-bottom: 28px; line-height: 1.6; }
.error-bar {
padding: 10px 14px; border-radius: 10px;
background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.2);
color: #fca5a5; font-size: 13px; margin-bottom: 20px;
}
.success-bar {
display: flex; align-items: center; gap: 12px;
padding: 16px; border-radius: 10px;
background: rgba(34, 197, 94, 0.06); border: 1px solid rgba(34, 197, 94, 0.15);
color: #86efac; font-size: 14px; line-height: 1.6;
}
.success-bar a { color: var(--primary-light); text-decoration: none; font-weight: 500; }
.success-bar a:hover { text-decoration: underline; }
.field { margin-bottom: 18px; }
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; }
.input-wrap { position: relative; display: flex; align-items: center; }
.input-icon { position: absolute; left: 12px; color: var(--text-dim); pointer-events: none; }
.input-wrap input {
width: 100%; padding: 10px 12px 10px 38px;
border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-input); color: var(--text); font-size: 14px; transition: all 0.2s;
}
.input-wrap input:hover { border-color: var(--text-dim); }
.input-wrap input:focus {
outline: none; border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.btn-submit {
width: 100%; padding: 11px; margin-top: 4px;
border: none; border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--accent));
color: #fff; font-size: 14px; font-weight: 600;
cursor: pointer; transition: all 0.3s; display: flex; align-items: center; justify-content: center; gap: 8px;
}
.btn-submit:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3); }
.btn-submit:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
.footer { text-align: center; margin-top: 20px; font-size: 13px; color: var(--text-muted); }
.footer a { color: var(--primary-light); text-decoration: none; font-weight: 500; }
.footer a:hover { text-decoration: underline; }
.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>

276
web/src/views/Layout.vue Normal file
View File

@@ -0,0 +1,276 @@
<template>
<div class="layout">
<!-- Background Grid -->
<div class="bg-grid"></div>
<!-- Header -->
<header class="header">
<div class="header-left">
<div class="logo">
<svg class="logo-icon" viewBox="0 0 32 32" width="28" height="28">
<defs>
<linearGradient id="lg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1" />
<stop offset="100%" stop-color="#06b6d4" />
</linearGradient>
</defs>
<circle cx="16" cy="16" r="14" fill="none" stroke="url(#lg)" stroke-width="2" opacity="0.4" />
<circle cx="16" cy="16" r="8" fill="none" stroke="url(#lg)" stroke-width="1.5" opacity="0.6" />
<circle cx="16" cy="16" r="3" fill="url(#lg)" />
<line x1="16" y1="2" x2="16" y2="8" stroke="#6366f1" stroke-width="1.5" opacity="0.6" />
<line x1="16" y1="24" x2="16" y2="30" stroke="#06b6d4" stroke-width="1.5" opacity="0.6" />
<line x1="2" y1="16" x2="8" y2="16" stroke="#6366f1" stroke-width="1.5" opacity="0.6" />
<line x1="24" y1="16" x2="30" y2="16" stroke="#06b6d4" stroke-width="1.5" opacity="0.6" />
</svg>
<div class="logo-text">
<span class="logo-name">ZeroMesh</span>
<span class="logo-sub">v1.0.0</span>
</div>
</div>
</div>
<div class="header-right">
<div class="status-dot"></div>
<span class="user-badge">{{ username }}</span>
<button class="btn-logout" @click="logout" title="退出登录">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
</div>
</header>
<div class="body">
<!-- Sidebar -->
<nav class="sidebar">
<!-- Admin nav -->
<template v-if="isAdmin">
<div class="nav-section-label">概览</div>
<router-link to="/dashboard" class="nav-item" active-class="active">
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
<span>仪表盘</span>
</router-link>
<div class="nav-section-label">网络</div>
<router-link to="/networks" class="nav-item" active-class="active">
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>虚拟网络</span>
</router-link>
<div class="nav-section-label">节点</div>
<router-link to="/nodes" class="nav-item" active-class="active">
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" /><rect x="2" y="14" width="20" height="8" rx="2" ry="2" /><line x1="6" y1="6" x2="6.01" y2="6" /><line x1="6" y1="18" x2="6.01" y2="18" />
</svg>
<span>节点管理</span>
</router-link>
<div class="nav-section-label">文档</div>
<router-link to="/docs" class="nav-item" active-class="active">
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" />
</svg>
<span>开发者文档</span>
</router-link>
<div class="nav-section-label">运维</div>
<router-link to="/logs" class="nav-item" active-class="active">
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" /><path d="M16 4v16" /><path d="M8 4v16" /><path d="M4 4v16" /><line x1="4" y1="12" x2="8" y2="12" /><line x1="12" y1="12" x2="16" y2="12" />
</svg>
<span>请求日志</span>
</router-link>
</template>
<!-- User nav -->
<template v-else>
<div class="nav-section-label">概览</div>
<router-link to="/user/dashboard" class="nav-item" active-class="active">
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
<span>我的控制台</span>
</router-link>
<div class="nav-section-label">网络</div>
<router-link to="/user/networks" class="nav-item" active-class="active">
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>我的网络</span>
</router-link>
<div class="nav-section-label">设备</div>
<router-link to="/user/nodes" class="nav-item" active-class="active">
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" /><rect x="2" y="14" width="20" height="8" rx="2" ry="2" /><line x1="6" y1="6" x2="6.01" y2="6" /><line x1="6" y1="18" x2="6.01" y2="18" />
</svg>
<span>我的设备</span>
</router-link>
</template>
<div class="nav-spacer"></div>
<div class="nav-footer">
<div class="nav-footer-dot"></div>
<div class="nav-footer-text">System Online</div>
</div>
</nav>
<!-- Main Content -->
<main class="content">
<router-view v-slot="{ Component }">
<transition name="page" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const username = ref(localStorage.getItem('username') || 'Admin')
const isAdmin = computed(() => {
return localStorage.getItem('role') === 'admin'
})
function logout() {
localStorage.removeItem('token')
localStorage.removeItem('username')
router.push('/login')
}
</script>
<style scoped>
.layout { display: flex; flex-direction: column; min-height: 100vh; position: relative; }
/* Background grid */
.bg-grid {
position: fixed; inset: 0; pointer-events: none; z-index: 0;
background-image:
linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
animation: grid-scroll 8s linear infinite;
}
/* Header */
.header {
position: relative; z-index: 10;
background: rgba(2, 6, 23, 0.9);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header::after {
content: ''; position: absolute; bottom: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--border-glow), transparent);
}
.header-left { display: flex; align-items: center; gap: 32px; }
.logo { display: flex; align-items: center; gap: 10px; }
.logo-icon { flex-shrink: 0; }
.logo-text { display: flex; flex-direction: column; }
.logo-name { font-size: 16px; font-weight: 700; background: linear-gradient(135deg, var(--primary-light), var(--accent)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.logo-sub { font-size: 10px; color: var(--text-dim); letter-spacing: 1px; text-transform: uppercase; margin-top: -1px; }
.header-right { display: flex; align-items: center; gap: 16px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 8px var(--success-glow); animation: pulse-glow 2s ease-in-out infinite; }
.user-badge { font-size: 13px; color: var(--text-muted); padding: 4px 12px; border-radius: 20px; border: 1px solid var(--border); background: rgba(99, 102, 241, 0.05); }
.btn-logout { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 6px; border-radius: 8px; display: flex; align-items: center; transition: all 0.2s; }
.btn-logout:hover { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
/* Body */
.body { display: flex; flex: 1; position: relative; z-index: 1; }
/* Sidebar */
.sidebar {
width: 220px;
background: rgba(2, 6, 23, 0.6);
backdrop-filter: blur(12px);
border-right: 1px solid var(--border);
padding: 20px 0;
display: flex;
flex-direction: column;
}
.nav-section-label {
padding: 8px 20px 6px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
font-weight: 600;
}
.nav-section-label:not(:first-child) { margin-top: 12px; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
text-decoration: none;
position: relative;
transition: all 0.2s;
margin: 1px 8px;
border-radius: 8px;
}
.nav-item:hover {
color: var(--text);
background: rgba(99, 102, 241, 0.06);
text-decoration: none;
}
.nav-item.active {
color: var(--primary-light);
background: rgba(99, 102, 241, 0.1);
}
.nav-item.active::before {
content: '';
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
border-radius: 0 3px 3px 0;
background: linear-gradient(180deg, var(--primary), var(--accent));
box-shadow: 0 0 10px rgba(99, 102, 241, 0.4);
}
.nav-icon { flex-shrink: 0; opacity: 0.7; }
.nav-item:hover .nav-icon { opacity: 1; }
.nav-item.active .nav-icon { opacity: 1; color: var(--primary-light); }
.nav-spacer { flex: 1; }
.nav-footer { padding: 12px 20px; display: flex; align-items: center; gap: 8px; border-top: 1px solid var(--border); margin: 0 8px; }
.nav-footer-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); box-shadow: 0 0 6px var(--success-glow); }
.nav-footer-text { font-size: 11px; color: var(--text-dim); letter-spacing: 0.5px; }
/* Content */
.content {
flex: 1;
padding: 28px 32px;
overflow-y: auto;
max-height: calc(100vh - 60px);
}
/* Page transitions */
.page-enter-active, .page-leave-active { transition: all 0.2s ease; }
.page-enter-from { opacity: 0; transform: translateY(8px); }
.page-leave-to { opacity: 0; transform: translateY(-8px); }
</style>

262
web/src/views/Login.vue Normal file
View File

@@ -0,0 +1,262 @@
<template>
<div class="login-page">
<div class="bg-grid"></div>
<div class="bg-orb bg-orb-1"></div>
<div class="bg-orb bg-orb-2"></div>
<div class="login-container">
<div class="login-left">
<div class="hero">
<svg class="hero-icon" viewBox="0 0 80 80" width="80" height="80">
<defs>
<linearGradient id="hg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1" />
<stop offset="100%" stop-color="#06b6d4" />
</linearGradient>
</defs>
<circle cx="40" cy="40" r="36" fill="none" stroke="url(#hg)" stroke-width="2" opacity="0.15" />
<circle cx="40" cy="40" r="26" fill="none" stroke="url(#hg)" stroke-width="1.5" opacity="0.3" />
<circle cx="40" cy="40" r="10" fill="url(#hg)" opacity="0.8" />
<circle cx="40" cy="40" r="4" fill="#fff" opacity="0.6" />
<!-- Connection lines -->
<g stroke="url(#hg)" stroke-width="1" opacity="0.4">
<line x1="40" y1="4" x2="40" y2="14" />
<line x1="40" y1="66" x2="40" y2="76" />
<line x1="4" y1="40" x2="14" y2="40" />
<line x1="66" y1="40" x2="76" y2="40" />
<line x1="14.6" y1="14.6" x2="21.7" y2="21.7" />
<line x1="58.3" y1="58.3" x2="65.4" y2="65.4" />
<line x1="65.4" y1="14.6" x2="58.3" y2="21.7" />
<line x1="21.7" y1="58.3" x2="14.6" y2="65.4" />
</g>
<circle cx="40" cy="4" r="2.5" fill="#6366f1" opacity="0.6" />
<circle cx="40" cy="76" r="2.5" fill="#06b6d4" opacity="0.6" />
<circle cx="4" cy="40" r="2.5" fill="#6366f1" opacity="0.6" />
<circle cx="76" cy="40" r="2.5" fill="#06b6d4" opacity="0.6" />
</svg>
<h1 class="hero-title">ZeroMesh</h1>
<p class="hero-desc">P2P 网状网络控制器<br/>安全 · 高速 · 零配置</p>
<div class="hero-features">
<div class="hero-feature">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
端到端加密
</div>
<div class="hero-feature">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
NAT 穿透
</div>
<div class="hero-feature">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
虚拟交换机
</div>
</div>
</div>
</div>
<div class="login-right">
<div class="login-card">
<div class="card-header">
<h2>登录</h2>
<p>管理控制台</p>
</div>
<div v-if="error" class="error-bar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
{{ error }}
</div>
<form @submit.prevent="handleLogin">
<div class="field">
<label>用户名</label>
<div class="input-wrap">
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<input v-model="username" type="text" placeholder="admin" required />
</div>
</div>
<div class="field">
<label>密码</label>
<div class="input-wrap">
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input v-model="password" type="password" placeholder="········" required />
</div>
</div>
<button type="submit" class="btn-submit" :disabled="loading">
<span v-if="loading" class="spinner"></span>
<span v-else>进入控制台</span>
</button>
</form>
<div class="card-footer">
首次使用<router-link to="/init">初始化管理员</router-link>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import api from '../api'
const router = useRouter()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
onMounted(async () => {
if (localStorage.getItem('token')) {
router.push('/dashboard')
return
}
try {
const res = await api.checkAdmin()
if (!res.data.admin_exists) {
router.push('/init')
}
} catch { /* allow */ }
})
async function handleLogin() {
error.value = ''
loading.value = true
try {
const res = await api.login(username.value, password.value)
localStorage.setItem('token', res.data.token)
localStorage.setItem('username', res.data.user.username)
localStorage.setItem('role', res.data.user.role || 'user')
router.push(res.data.user.role === 'admin' ? '/dashboard' : '/user/dashboard')
} catch (e) {
error.value = e.response?.data?.error || '认证失败,请检查凭证'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
display: flex; align-items: center; justify-content: center;
min-height: 100vh; position: relative; overflow: hidden;
background: var(--bg-deep);
}
.bg-grid {
position: fixed; inset: 0; pointer-events: none;
background-image:
linear-gradient(rgba(99, 102, 241, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.04) 1px, transparent 1px);
background-size: 48px 48px;
}
.bg-orb {
position: fixed; border-radius: 50%; pointer-events: none;
filter: blur(100px); opacity: 0.3;
}
.bg-orb-1 { width: 500px; height: 500px; top: -150px; right: -100px; background: rgba(99, 102, 241, 0.4); }
.bg-orb-2 { width: 400px; height: 400px; bottom: -100px; left: -100px; background: rgba(6, 182, 212, 0.3); }
.login-container {
display: flex; position: relative; z-index: 1;
width: 860px; max-width: 95vw; min-height: 520px;
border-radius: 20px;
border: 1px solid var(--border);
background: rgba(11, 17, 32, 0.7);
backdrop-filter: blur(24px);
box-shadow: var(--shadow-card), 0 0 80px rgba(99, 102, 241, 0.05);
overflow: hidden;
}
/* Left hero */
.login-left {
width: 380px; padding: 48px 36px;
background: rgba(99, 102, 241, 0.03);
border-right: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
}
.hero { text-align: center; }
.hero-icon { margin-bottom: 20px; }
.hero-title {
font-size: 28px; font-weight: 800;
background: linear-gradient(135deg, var(--primary-light), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 6px;
}
.hero-desc { font-size: 13px; color: var(--text-muted); line-height: 1.7; margin-bottom: 20px; }
.hero-features { display: flex; flex-direction: column; gap: 8px; align-items: center; }
.hero-feature { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); }
/* Right form */
.login-right {
flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px;
}
.login-card { width: 100%; max-width: 340px; }
.card-header { margin-bottom: 24px; }
.card-header h2 { font-size: 20px; font-weight: 700; }
.card-header p { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
.error-bar {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px; border-radius: 10px;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #fca5a5; font-size: 13px; margin-bottom: 20px;
}
.field { margin-bottom: 18px; }
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; letter-spacing: 0.3px; }
.input-wrap {
position: relative;
display: flex; align-items: center;
}
.input-icon {
position: absolute; left: 12px; color: var(--text-dim); pointer-events: none;
}
.input-wrap input {
width: 100%; padding: 10px 12px 10px 38px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text);
font-size: 14px;
transition: all 0.2s;
}
.input-wrap input:hover { border-color: var(--text-dim); }
.input-wrap input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.input-wrap input::placeholder { color: var(--text-dim); }
.btn-submit {
width: 100%; padding: 11px; margin-top: 4px;
border: none; border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--accent));
color: #fff; font-size: 14px; font-weight: 600;
cursor: pointer; transition: all 0.3s; display: flex; align-items: center; justify-content: center; gap: 8px;
}
.btn-submit:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3);
}
.btn-submit:active { transform: translateY(0); }
.btn-submit:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
.card-footer { text-align: center; margin-top: 20px; font-size: 13px; color: var(--text-muted); }
.card-footer a { color: var(--primary-light); text-decoration: none; font-weight: 500; }
.card-footer a:hover { text-decoration: underline; }
.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 700px) {
.login-left { display: none; }
.login-container { width: 400px; }
}
</style>

408
web/src/views/Logs.vue Normal file
View File

@@ -0,0 +1,408 @@
<template>
<div class="logs fade-in">
<div class="page-header">
<div>
<h2 class="page-title">请求日志</h2>
<p class="page-subtitle">API 调用记录与错误日志用于线上排障</p>
</div>
<div class="header-actions">
<button class="btn-secondary" @click="copyPage">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
复制本页
</button>
<button class="btn-danger" @click="clearLogs">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
清空日志
</button>
</div>
</div>
<!-- Error Banner -->
<div v-if="errorMsg" class="error-banner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<span>{{ errorMsg }}</span>
<button class="error-dismiss" @click="errorMsg = ''">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Tabs -->
<div class="tabs">
<button :class="['tab-btn', { active: tab === 'api' }]" @click="tab = 'api'">API 日志</button>
<button :class="['tab-btn', { active: tab === 'error' }]" @click="tab = 'error'">错误日志 (400)</button>
<button :class="['tab-btn', { active: tab === 'grouped' }]" @click="tab = 'grouped'">聚合统计</button>
</div>
<!-- Filters -->
<div v-if="tab !== 'grouped'" class="filters">
<div class="filter-group">
<label>IP</label>
<input v-model="filter.ip" placeholder="来源 IP" @input="debounceSearch" />
</div>
<div class="filter-group">
<label>路径</label>
<input v-model="filter.path" placeholder="API 路径" @input="debounceSearch" />
</div>
<div class="filter-group">
<label>方法</label>
<select v-model="filter.method" @change="search">
<option value="">全部</option>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div class="filter-group">
<label>状态码</label>
<input v-model.number="filter.statusCode" placeholder="如 404" @input="debounceSearch" />
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="loading-state">
<div class="loading-ring"></div>
<span>加载日志...</span>
</div>
<!-- Grouped view -->
<div v-else-if="tab === 'grouped'" class="table-container">
<table>
<thead>
<tr>
<th>IP 地址</th>
<th>方法</th>
<th>路径</th>
<th>调用次数</th>
<th>总延迟 (ms)</th>
</tr>
</thead>
<tbody>
<tr v-for="row in groupedData" :key="row.ip + row.method + row.path">
<td><code>{{ row.ip }}</code></td>
<td><span class="method-badge" :class="row.method">{{ row.method }}</span></td>
<td><code>{{ row.path }}</code></td>
<td>{{ row.count }}</td>
<td>{{ row.total_latency }}</td>
</tr>
<tr v-if="!groupedData.length">
<td colspan="5" class="empty-cell">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<!-- Table: API / Error logs -->
<template v-else>
<div v-if="!currentData.length && !loading" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1" opacity="0.4"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
<h3>暂无日志</h3>
<p>发送 API 请求后会在这里显示记录</p>
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>时间</th>
<th>IP</th>
<th>方法</th>
<th>路径</th>
<th>状态</th>
<th>延迟</th>
<th v-if="tab === 'error'">请求体</th>
<th v-if="tab === 'error'">响应体</th>
<th>UA</th>
</tr>
</thead>
<tbody>
<tr v-for="row in currentData" :key="row.id">
<td class="cell-nowrap">{{ formatTime(row.created_at) }}</td>
<td><code>{{ row.ip }}</code></td>
<td><span class="method-badge" :class="row.method">{{ row.method }}</span></td>
<td><code class="cell-path">{{ row.path }}</code></td>
<td>
<span :class="['status-badge', row.status_code >= 400 ? 'error' : 'ok']">{{ row.status_code }}</span>
</td>
<td class="cell-num">{{ row.latency_ms }}ms</td>
<td v-if="tab === 'error'" class="cell-preview">{{ truncate(row.request_body, 60) }}</td>
<td v-if="tab === 'error'" class="cell-preview">{{ truncate(row.response_body, 60) }}</td>
<td class="cell-nowrap cell-ua">{{ row.user_agent || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="total > 0" class="pagination">
<span class="page-info"> {{ total }} {{ page }} / {{ pageTotal }} </span>
<div class="page-actions">
<button :disabled="page <= 1" @click="goPage(1)">首页</button>
<button :disabled="page <= 1" @click="goPage(page - 1)">上一页</button>
<span class="page-num">{{ page }}</span>
<button :disabled="page >= pageTotal" @click="goPage(page + 1)">下一页</button>
<button :disabled="page >= pageTotal" @click="goPage(pageTotal)">末页</button>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const API = '/api/v1/log'
const token = () => localStorage.getItem('token')
const headers = { 'Authorization': `Bearer ${token()}` }
const tab = ref('api')
const loading = ref(false)
const errorMsg = ref('')
const page = ref(1)
const pageSize = 20
const total = ref(0)
const filter = ref({ ip: '', path: '', method: '', statusCode: '' })
let debounceTimer = null
const apiLogs = ref([])
const errorLogs = ref([])
const groupedData = ref([])
const pageTotal = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const currentData = computed(() => tab.value === 'api' ? apiLogs.value : errorLogs.value)
watch(tab, () => { page.value = 1; search() })
function debounceSearch() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(search, 300)
}
async function search() {
loading.value = true
errorMsg.value = ''
try {
if (tab.value === 'grouped') {
const r = await fetch(`${API}/grouped`, { headers })
const d = await r.json()
if (!r.ok) throw new Error(d.error || '加载失败')
groupedData.value = d.data || []
} else {
const ep = tab.value === 'api' ? 'api' : 'error'
const params = new URLSearchParams({ page: page.value, page_size: pageSize })
if (filter.value.ip) params.set('ip', filter.value.ip)
if (filter.value.path) params.set('path', filter.value.path)
if (filter.value.method) params.set('method', filter.value.method)
if (filter.value.statusCode) params.set('status_code', filter.value.statusCode)
const r = await fetch(`${API}/${ep}?${params}`, { headers })
const d = await r.json()
if (!r.ok) throw new Error(d.error || '加载失败')
if (tab.value === 'api') apiLogs.value = d.data || []
else errorLogs.value = d.data || []
total.value = d.total || 0
}
} catch (e) {
errorMsg.value = e.message
} finally {
loading.value = false
}
}
function goPage(n) {
page.value = n
search()
}
async function clearLogs() {
if (!confirm('确认清空所有日志?此操作不可恢复。')) return
loading.value = true
errorMsg.value = ''
try {
const ep = tab.value === 'api' || tab.value === 'grouped' ? 'api' : 'error'
const r = await fetch(`${API}/${ep}`, { method: 'DELETE', headers })
if (!r.ok) {
const d = await r.json()
throw new Error(d.error || '清空失败')
}
apiLogs.value = []
errorLogs.value = []
groupedData.value = []
total.value = 0
page.value = 1
} catch (e) {
errorMsg.value = e.message
} finally {
loading.value = false
}
}
async function copyPage() {
const rows = tab.value === 'grouped' ? groupedData.value : currentData.value
if (!rows.length) return
const lines = []
if (tab.value === 'grouped') {
lines.push('IP\t方法\t路径\t调用次数\t总延迟(ms)')
for (const r of rows) {
lines.push(`${r.ip}\t${r.method}\t${r.path}\t${r.count}\t${r.total_latency}`)
}
} else {
const isErr = tab.value === 'error'
const head = isErr
? '时间\tIP\t方法\t路径\t状态\t延迟\t请求体\t响应体\tUA'
: '时间\tIP\t方法\t路径\t状态\t延迟\tUA'
lines.push(head)
for (const r of rows) {
const t = formatTime(r.created_at)
const ua = (r.user_agent || '-')
if (isErr) {
lines.push(`${t}\t${r.ip}\t${r.method}\t${r.path}\t${r.status_code}\t${r.latency_ms}ms\t${truncate(r.request_body, 60)}\t${truncate(r.response_body, 60)}\t${ua}`)
} else {
lines.push(`${t}\t${r.ip}\t${r.method}\t${r.path}\t${r.status_code}\t${r.latency_ms}ms\t${ua}`)
}
}
}
try {
await navigator.clipboard.writeText(lines.join('\n'))
} catch {
const ta = document.createElement('textarea')
ta.value = lines.join('\n')
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
}
function formatTime(t) {
if (!t) return '-'
const d = new Date(t)
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function truncate(s, n) {
if (!s) return ''
return s.length > n ? s.slice(0, n) + '...' : s
}
search()
</script>
<style scoped>
.logs { padding-bottom: 40px; }
.page-header {
display: flex; justify-content: space-between; align-items: flex-start;
margin-bottom: 28px; gap: 16px;
}
.header-actions { display: flex; gap: 10px; flex-shrink: 0; }
.tabs {
display: flex; gap: 4px; margin-bottom: 20px;
background: rgba(255,255,255,0.03); border-radius: 8px; padding: 3px;
border: 1px solid rgba(255,255,255,0.06);
}
.tab-btn {
flex: 1; padding: 8px 16px; border: none; border-radius: 6px;
background: transparent; color: var(--text-dim); cursor: pointer;
font-size: 0.85rem; font-weight: 500; transition: all 0.2s;
}
.tab-btn.active {
background: linear-gradient(135deg, rgba(99,102,241,0.2), rgba(6,182,212,0.2));
color: #e2e8f0; box-shadow: 0 0 12px rgba(99,102,241,0.15);
}
.tab-btn:hover:not(.active) { color: var(--text); }
.filters {
display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap;
}
.filter-group {
display: flex; flex-direction: column; gap: 4px;
min-width: 120px;
}
.filter-group label {
font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-group input, .filter-group select {
padding: 6px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08);
background: rgba(0,0,0,0.3); color: var(--text); font-size: 0.8rem;
outline: none; transition: border-color 0.2s;
}
.filter-group input:focus, .filter-group select:focus {
border-color: rgba(99,102,241,0.5);
}
.table-container { overflow-x: auto; background: var(--card-bg); border-radius: 10px; border: 1px solid var(--card-border); }
table { width: 100%; border-collapse: collapse; white-space: nowrap; }
th {
text-align: left; padding: 10px 14px; font-size: 0.72rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em;
color: var(--text-dim); background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--card-border);
}
td { padding: 8px 14px; font-size: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.03); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(99,102,241,0.03); }
code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.78rem; color: #a5b4fc; }
.cell-path { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
.cell-num { font-family: 'JetBrains Mono', monospace; text-align: right; color: var(--text-dim); }
.cell-nowrap { overflow: hidden; text-overflow: ellipsis; max-width: 160px; }
.cell-ua { max-width: 140px; color: var(--text-dim); }
.cell-preview { max-width: 200px; overflow: hidden; text-overflow: ellipsis; color: var(--text-dim); font-size: 0.75rem; }
.empty-cell { text-align: center; padding: 32px; color: var(--text-dim); }
.method-badge {
display: inline-block; padding: 1px 8px; border-radius: 4px;
font-size: 0.72rem; font-weight: 600; letter-spacing: 0.03em;
}
.method-badge.GET { background: rgba(34,197,94,0.15); color: #22c55e; }
.method-badge.POST { background: rgba(59,130,246,0.15); color: #3b82f6; }
.method-badge.PUT { background: rgba(234,179,8,0.15); color: #eab308; }
.method-badge.DELETE { background: rgba(239,68,68,0.15); color: #ef4444; }
.status-badge {
display: inline-block; padding: 1px 8px; border-radius: 4px;
font-size: 0.72rem; font-weight: 600; font-family: 'JetBrains Mono', monospace;
}
.status-badge.ok { background: rgba(34,197,94,0.15); color: #22c55e; }
.status-badge.error { background: rgba(239,68,68,0.15); color: #ef4444; }
.pagination {
display: flex; justify-content: space-between; align-items: center;
margin-top: 16px; padding: 0 4px;
}
.page-info { font-size: 0.78rem; color: var(--text-dim); }
.page-actions { display: flex; align-items: center; gap: 4px; }
.page-actions button {
padding: 5px 12px; border: 1px solid rgba(255,255,255,0.08); border-radius: 6px;
background: rgba(0,0,0,0.3); color: var(--text); cursor: pointer;
font-size: 0.78rem; transition: all 0.2s;
}
.page-actions button:hover:not(:disabled) { border-color: rgba(99,102,241,0.4); }
.page-actions button:disabled { opacity: 0.3; cursor: not-allowed; }
.page-num {
display: inline-flex; align-items: center; justify-content: center;
min-width: 28px; height: 28px; padding: 0 6px;
background: linear-gradient(135deg, rgba(99,102,241,0.2), rgba(6,182,212,0.2));
border-radius: 6px; font-size: 0.78rem; font-weight: 600; color: #a5b4fc;
}
.btn-secondary {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 14px; border: 1px solid rgba(255,255,255,0.1); border-radius: 8px;
background: rgba(255,255,255,0.04); color: var(--text);
cursor: pointer; font-size: 0.8rem; font-weight: 500; transition: all 0.2s;
}
.btn-secondary:hover { border-color: rgba(99,102,241,0.3); background: rgba(99,102,241,0.08); }
.btn-danger {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 14px; border: 1px solid rgba(239,68,68,0.3); border-radius: 8px;
background: rgba(239,68,68,0.1); color: #ef4444;
cursor: pointer; font-size: 0.8rem; font-weight: 500; transition: all 0.2s;
}
.btn-danger:hover { background: rgba(239,68,68,0.2); }
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="create-net fade-in">
<div class="page-header">
<div>
<h2 class="page-title">创建虚拟网络</h2>
<p class="page-subtitle">配置一个新的 P2P 私有网络</p>
</div>
</div>
<div v-if="errorMsg" class="error-banner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<span>{{ errorMsg }}</span>
<button class="error-dismiss" @click="errorMsg = ''">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="form-card">
<div class="form-section">
<label class="form-label">网络名称</label>
<input v-model="name" placeholder="例如office-vpn" class="form-input" />
<span class="form-hint">给你的网络起个名字</span>
</div>
<div class="form-section">
<label class="form-label">网段 (CIDR)</label>
<div class="cidr-row">
<input v-model="ipRange" placeholder="留空自动分配" class="form-input mono" />
<button class="btn-ghost" @click="randomize">随机</button>
</div>
<span class="form-hint">私有网段如 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16留空则自动随机生成</span>
</div>
<div class="form-actions">
<router-link :to="backLink" class="btn-ghost">取消</router-link>
<button class="btn-primary" @click="create" :disabled="submitting">
{{ submitting ? '创建中...' : '创建网络' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import api from '../api'
const router = useRouter()
const route = useRoute()
const name = ref('')
const ipRange = ref('')
const errorMsg = ref('')
const submitting = ref(false)
const isUser = computed(() => route.path.startsWith('/user'))
const backLink = computed(() => isUser.value ? '/user/networks' : '/networks')
function randomize() {
const classes = [
() => `10.${rand(256)}.${rand(256)}.0/24`,
() => `172.${16 + rand(16)}.${rand(256)}.0/24`,
() => `192.168.${rand(256)}.0/24`,
]
ipRange.value = classes[rand(3)]()
}
function rand(n) { return Math.floor(Math.random() * n) }
async function create() {
if (!name.value) { errorMsg.value = '请输入网络名称'; return }
submitting.value = true
errorMsg.value = ''
try {
const res = await api.networkCreate({ name: name.value, ip_range: ipRange.value || '' })
if (res.data.network) {
router.push(isUser.value ? `/user/networks/${res.data.network.network_id}` : `/networks`)
}
} catch (e) {
errorMsg.value = e.response?.data?.error || '创建失败'
}
submitting.value = false
}
</script>
<style scoped>
.create-net { animation: fadeIn 0.4s ease-out; max-width: 600px; margin: 0 auto; }
.page-header { margin-bottom: 28px; }
.page-title { font-size: 22px; font-weight: 700; }
.page-subtitle { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
.form-card {
background: var(--bg-card); border-radius: 14px; border: 1px solid var(--border);
padding: 28px; backdrop-filter: blur(12px);
}
.form-section { margin-bottom: 24px; }
.form-label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; }
.form-input {
width: 100%; padding: 10px 14px; border-radius: 9px;
border: 1px solid var(--border); background: var(--bg-input);
color: var(--text); font-size: 14px; transition: all 0.2s; box-sizing: border-box;
}
.form-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); }
.form-input.mono { font-family: 'JetBrains Mono', monospace; font-size: 13px; }
.form-hint { display: block; font-size: 11px; color: var(--text-dim); margin-top: 4px; }
.cidr-row { display: flex; gap: 8px; }
.cidr-row .form-input { flex: 1; }
.form-actions { display: flex; gap: 10px; justify-content: flex-end; padding-top: 8px; border-top: 1px solid var(--border); }
.btn-primary {
display: inline-flex; align-items: center; gap: 6px;
padding: 10px 22px; border: none; border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--accent));
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
transition: all 0.25s;
}
.btn-primary:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost {
display: inline-flex; align-items: center;
padding: 9px 18px; border-radius: 10px; text-decoration: none;
background: transparent; border: 1px solid var(--border);
color: var(--text-muted); font-size: 13px; cursor: pointer; transition: all 0.15s;
}
.btn-ghost:hover { background: var(--bg-input); color: var(--text); }
.error-banner {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px; margin-bottom: 16px;
border-radius: 10px; border: 1px solid rgba(239, 68, 68, 0.2);
background: rgba(239, 68, 68, 0.06); color: var(--danger);
font-size: 13px; animation: fadeIn 0.2s ease-out;
}
.error-dismiss { margin-left: auto; background: none; border: none; color: var(--danger); cursor: pointer; padding: 2px; border-radius: 4px; opacity: 0.6; }
.error-dismiss:hover { opacity: 1; }
</style>

306
web/src/views/Networks.vue Normal file
View File

@@ -0,0 +1,306 @@
<template>
<div class="networks fade-in">
<div class="page-header">
<div>
<h2 class="page-title">虚拟网络</h2>
<p class="page-subtitle">管理 P2P 网络和成员授权</p>
</div>
<router-link to="/network/create" class="btn-primary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
创建网络
</router-link>
</div>
<!-- Error Banner -->
<div v-if="errorMsg" class="error-banner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<span>{{ errorMsg }}</span>
<button class="error-dismiss" @click="errorMsg = ''">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="loading-state">
<div class="loading-ring"></div>
<span>加载中...</span>
</div>
<!-- Empty -->
<div v-else-if="networks.length === 0" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1" opacity="0.4"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<h3>暂无虚拟网络</h3>
<p>点击右上角创建你的第一个 P2P 网络</p>
</div>
<!-- Table -->
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>网络 ID</th>
<th>名称</th>
<th>IP 范围</th>
<th>成员</th>
<th>MTU</th>
<th>状态</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="net in networks" :key="net.id" @click="viewNetwork(net)" class="table-row">
<td><code class="code-id">{{ net.network_id }}</code></td>
<td><span class="net-name">{{ net.name }}</span></td>
<td><span class="ip-badge">{{ net.ip_range || '-' }}</span></td>
<td><span class="count-badge">{{ (net.members || []).length }}</span></td>
<td>{{ net.mtu || 2800 }}</td>
<td><span class="status-pill status-active">运行中</span></td>
<td>
<button class="btn-icon" @click.stop="confirmDelete(net)" title="删除">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Detail Modal -->
<div v-if="selected" class="modal-overlay" @click.self="selected = null">
<div class="modal modal-wide" @click.stop>
<div class="modal-shine"></div>
<div class="modal-header">
<h2>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--primary-light)" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
{{ selected.name }}
</h2>
<div class="modal-meta">
ID: <code>{{ selected.network_id }}</code> &middot; IP: {{ selected.ip_range || '-' }}
</div>
</div>
<div class="modal-body">
<h3 class="section-title">成员列表 ({{ (members || []).length }})</h3>
<div v-if="(members || []).length === 0" class="empty-mini">暂无成员</div>
<table v-else class="mini-table">
<thead>
<tr><th>节点 ID</th><th>IP 地址</th><th>授权</th><th></th></tr>
</thead>
<tbody>
<tr v-for="m in members" :key="m.id">
<td><code class="code-id">{{ m.node_id }}</code></td>
<td>{{ m.ip_address || '-' }}</td>
<td>
<span :class="m.authorized ? 'badge badge-auth' : 'badge badge-pending'">
{{ m.authorized ? '已授权' : '待授权' }}
</span>
</td>
<td>
<button v-if="!m.authorized" class="btn-sm btn-primary" @click="authorizeMember(m)">授权</button>
<button v-else class="btn-sm btn-ghost" @click="deauthorizeMember(m)">移除</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button class="btn-ghost" @click="selected = null">关闭</button>
</div>
</div>
</div>
<!-- Delete Confirm -->
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
<div class="modal" @click.stop>
<div class="modal-shine"></div>
<div class="modal-header">
<h2 style="color: var(--danger);">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
确认删除
</h2>
</div>
<div class="modal-body">
<p>确定要删除网络 <strong>{{ deleteTarget.name }}</strong> </p>
<p class="warning-text">此操作不可恢复所有成员将被移出网络</p>
</div>
<div class="modal-footer">
<button class="btn-ghost" @click="deleteTarget = null">取消</button>
<button class="btn-danger" @click="deleteNetwork">确认删除</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../api'
const loading = ref(true)
const errorMsg = ref('')
const networks = ref([])
function showError(msg) {
errorMsg.value = msg
setTimeout(() => { if (errorMsg.value === msg) errorMsg.value = '' }, 5000)
}
const selected = ref(null)
const members = ref([])
const deleteTarget = ref(null)
onMounted(fetchNetworks)
async function fetchNetworks() {
loading.value = true
try {
const res = await api.networkList()
networks.value = res.data.networks || []
} catch { networks.value = [] }
finally { loading.value = false }
}
async function viewNetwork(net) {
selected.value = net
try {
const res = await api.networkMembers(net.network_id)
members.value = res.data.members || []
} catch { members.value = [] }
}
async function authorizeMember(m) {
try {
await api.networkAuthorize(selected.value.network_id, m.node_id)
await viewNetwork(selected.value)
} catch (e) { showError(e.response?.data?.error || '授权失败') }
}
async function deauthorizeMember(m) {
try {
await api.networkDeauthorize(selected.value.network_id, m.node_id)
await viewNetwork(selected.value)
} catch (e) { showError(e.response?.data?.error || '移除失败') }
}
function confirmDelete(net) { deleteTarget.value = net }
async function deleteNetwork() {
try {
await api.networkDelete(deleteTarget.value.network_id)
deleteTarget.value = null
await fetchNetworks()
} catch (e) { showError(e.response?.data?.error || '删除失败') }
}
</script>
<style scoped>
.networks { animation: fadeIn 0.4s ease-out; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
.page-title { font-size: 22px; font-weight: 700; }
.page-subtitle { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
.loading-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px; color: var(--text-muted); }
.loading-ring { width: 28px; height: 28px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px; text-align: center; }
.empty-state h3 { font-size: 16px; font-weight: 600; margin-top: 8px; }
.empty-state p { font-size: 13px; color: var(--text-muted); }
/* Table */
.table-container { background: var(--bg-card); border-radius: 14px; border: 1px solid var(--border); overflow: hidden; backdrop-filter: blur(12px); }
table { width: 100%; border-collapse: collapse; }
th { padding: 12px 16px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; text-align: left; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.15); }
td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
tr:last-child td { border-bottom: none; }
.table-row { cursor: pointer; transition: background 0.15s; }
.table-row:hover { background: rgba(99, 102, 241, 0.04); }
.code-id { background: var(--bg-input); padding: 2px 8px; border-radius: 5px; font-size: 12px; color: var(--primary-light); }
.net-name { font-weight: 500; }
.ip-badge { font-family: monospace; font-size: 12px; color: var(--text-muted); background: rgba(6, 182, 212, 0.08); padding: 2px 8px; border-radius: 5px; }
.count-badge { font-family: monospace; font-size: 13px; font-weight: 600; color: var(--accent); }
.status-pill { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
.status-active { background: rgba(34, 197, 94, 0.08); color: var(--success); border: 1px solid rgba(34, 197, 94, 0.15); }
.btn-icon { background: none; border: none; color: var(--text-dim); cursor: pointer; padding: 4px; border-radius: 6px; transition: all 0.15s; }
.btn-icon:hover { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
/* Buttons */
.btn-primary {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 18px; border: none; border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--accent));
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
transition: all 0.25s;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35); }
.btn-ghost {
padding: 8px 18px; border-radius: 10px;
background: transparent; border: 1px solid var(--border);
color: var(--text-muted); font-size: 13px; cursor: pointer; transition: all 0.15s;
}
.btn-ghost:hover { background: var(--bg-input); color: var(--text); }
.btn-danger {
padding: 8px 18px; border-radius: 10px; border: none;
background: var(--danger); color: #fff; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.15s;
}
.btn-danger:hover { opacity: 0.85; }
.btn-sm { padding: 5px 12px; border-radius: 7px; font-size: 12px; border: none; cursor: pointer; transition: all 0.15s; }
/* Modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(2, 6, 23, 0.7);
backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center; z-index: 100;
animation: fadeIn 0.15s ease-out;
}
.modal {
background: var(--bg); border-radius: 16px; padding: 0;
border: 1px solid var(--border); width: 480px; max-width: 90vw;
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
overflow: hidden; position: relative;
}
.modal-shine { position: absolute; top: 0; left: 0; right: 0; height: 2px; background: linear-gradient(90deg, transparent, var(--primary-light), var(--accent), transparent); }
.modal-wide { width: 640px; }
.modal-header { padding: 24px 24px 0; }
.modal-header h2 { display: flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 700; }
.modal-meta { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
.modal-meta code { background: var(--bg-input); padding: 1px 6px; border-radius: 4px; }
.modal-body { padding: 20px 24px; }
.modal-footer { display: flex; gap: 8px; justify-content: flex-end; padding: 16px 24px; border-top: 1px solid var(--border); background: rgba(0,0,0,0.15); }
.section-title { font-size: 13px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; }
.field input {
width: 100%; padding: 9px 12px; border-radius: 9px;
border: 1px solid var(--border); background: var(--bg-input);
color: var(--text); font-size: 14px; transition: all 0.2s;
}
.field input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); }
.field-hint { display: block; font-size: 11px; color: var(--text-dim); margin-top: 4px; }
.empty-mini { padding: 20px; text-align: center; color: var(--text-dim); font-size: 13px; }
.mini-table { width: 100%; border-collapse: collapse; }
.mini-table th { padding: 8px 12px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; text-align: left; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.1); }
.mini-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
.badge-auth { background: rgba(34, 197, 94, 0.08); color: var(--success); border: 1px solid rgba(34, 197, 94, 0.15); }
.badge-pending { background: rgba(234, 179, 8, 0.08); color: var(--warning); border: 1px solid rgba(234, 179, 8, 0.15); }
.warning-text { font-size: 12px; color: var(--text-dim); margin-top: 8px; }
.error-banner {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px; margin-bottom: 16px;
border-radius: 10px; border: 1px solid rgba(239, 68, 68, 0.2);
background: rgba(239, 68, 68, 0.06); color: var(--danger);
font-size: 13px; animation: fadeIn 0.2s ease-out;
}
.error-dismiss { margin-left: auto; background: none; border: none; color: var(--danger); cursor: pointer; padding: 2px; border-radius: 4px; opacity: 0.6; }
.error-dismiss:hover { opacity: 1; }
</style>

294
web/src/views/Nodes.vue Normal file
View File

@@ -0,0 +1,294 @@
<template>
<div class="nodes fade-in">
<div class="page-header">
<div>
<h2 class="page-title">节点管理</h2>
<p class="page-subtitle">注册监控和管理网络节点</p>
</div>
<button class="btn-primary" @click="showRegister = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
注册节点
</button>
</div>
<!-- Search -->
<div class="search-bar">
<svg class="search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input v-model="search" placeholder="搜索节点 ID 或名称..." />
<span v-if="search" class="search-clear" @click="search = ''">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</span>
<span class="search-count">{{ filtered.length }} / {{ nodes.length }}</span>
</div>
<!-- Error Banner -->
<div v-if="errorMsg" class="error-banner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<span>{{ errorMsg }}</span>
<button class="error-dismiss" @click="errorMsg = ''">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="loading-state">
<div class="loading-ring"></div>
<span>加载节点列表...</span>
</div>
<!-- Empty -->
<div v-else-if="filtered.length === 0" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1" opacity="0.4"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
<h3 v-if="search">未找到匹配节点</h3>
<h3 v-else>暂无注册节点</h3>
<p>{{ search ? '尝试其他关键词' : '点击右上角注册第一个节点' }}</p>
</div>
<!-- Table -->
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>节点 ID</th>
<th>名称</th>
<th>IP 地址</th>
<th>公钥</th>
<th>状态</th>
<th>最后在线</th>
</tr>
</thead>
<tbody>
<tr v-for="node in filtered" :key="node.id" class="table-row">
<td><code class="code-id">{{ node.node_id }}</code></td>
<td><span class="node-name">{{ node.name || '-' }}</span></td>
<td><span class="ip-badge">{{ node.ip_address || '-' }}</span></td>
<td><code class="key-trunc">{{ node.public_key ? node.public_key.slice(0,16)+'...' : '-' }}</code></td>
<td>
<span :class="node.online ? 'status-pill online' : 'status-pill offline'">
<span class="dot"></span>
{{ node.online ? '在线' : '离线' }}
</span>
</td>
<td class="time-cell">{{ node.last_seen ? formatTime(node.last_seen) : '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Register Modal -->
<div v-if="showRegister" class="modal-overlay" @click.self="showRegister = false">
<div class="modal" @click.stop>
<div class="modal-shine"></div>
<div class="modal-header">
<h2>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--primary-light)" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
注册节点
</h2>
</div>
<div class="modal-body">
<div class="field">
<label>节点 ID</label>
<input v-model="form.nodeId" placeholder="zeromesh-cli 生成的 40-bit 地址" />
</div>
<div class="field">
<label>公钥 (Hex)</label>
<input v-model="form.publicKey" placeholder="64 位十六进制公钥" />
</div>
<div class="field">
<label>名称</label>
<input v-model="form.name" placeholder="例如: office-server-01" />
</div>
<div class="field-row">
<div class="field">
<label>IP 地址</label>
<input v-model="form.ipAddress" placeholder="可选" />
</div>
<div class="field" style="width: 100px;">
<label>端口</label>
<input v-model.number="form.port" type="number" placeholder="9993" />
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-ghost" @click="showRegister = false">取消</button>
<button class="btn-primary" @click="registerNode">注册节点</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import api from '../api'
const loading = ref(true)
const errorMsg = ref('')
const nodes = ref([])
function showError(msg) {
errorMsg.value = msg
setTimeout(() => { if (errorMsg.value === msg) errorMsg.value = '' }, 5000)
}
const search = ref('')
const showRegister = ref(false)
const form = ref({ nodeId: '', publicKey: '', name: '', ipAddress: '', port: 9993 })
const filtered = computed(() => {
if (!search.value) return nodes.value
const q = search.value.toLowerCase()
return nodes.value.filter(n =>
(n.node_id && n.node_id.toLowerCase().includes(q)) ||
(n.name && n.name.toLowerCase().includes(q))
)
})
onMounted(fetchNodes)
async function fetchNodes() {
loading.value = true
try {
const res = await api.nodeList()
nodes.value = res.data.nodes || []
} catch { nodes.value = [] }
finally { loading.value = false }
}
async function registerNode() {
try {
await api.nodeRegister({
node_id: form.value.nodeId,
public_key: form.value.publicKey,
name: form.value.name,
ip_address: form.value.ipAddress,
port: form.value.port,
version: '1.0.0',
})
showRegister.value = false
form.value = { nodeId: '', publicKey: '', name: '', ipAddress: '', port: 9993 }
await fetchNodes()
} catch (e) { showError(e.response?.data?.error || '注册失败') }
}
function formatTime(ts) {
const d = new Date(ts)
const now = new Date()
const diff = now - d
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff/60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff/3600000)} 小时前`
return d.toLocaleDateString('zh-CN')
}
</script>
<style scoped>
.nodes { animation: fadeIn 0.4s ease-out; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
.page-title { font-size: 22px; font-weight: 700; }
.page-subtitle { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
/* Search */
.search-bar {
display: flex; align-items: center; gap: 8px;
padding: 8px 14px; border-radius: 12px;
border: 1px solid var(--border); background: var(--bg-card);
margin-bottom: 20px; position: relative;
transition: border-color 0.2s;
}
.search-bar:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.05); }
.search-icon { flex-shrink: 0; color: var(--text-dim); }
.search-bar input { flex: 1; border: none; background: transparent; color: var(--text); font-size: 13px; outline: none; }
.search-bar input::placeholder { color: var(--text-dim); }
.search-clear { cursor: pointer; color: var(--text-dim); padding: 2px; border-radius: 4px; }
.search-clear:hover { color: var(--text); background: var(--bg-input); }
.search-count { font-size: 11px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.loading-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px; color: var(--text-muted); }
.loading-ring { width: 28px; height: 28px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px; text-align: center; }
.empty-state h3 { font-size: 16px; font-weight: 600; margin-top: 8px; }
.empty-state p { font-size: 13px; color: var(--text-muted); }
/* Table */
.table-container { background: var(--bg-card); border-radius: 14px; border: 1px solid var(--border); overflow: hidden; backdrop-filter: blur(12px); }
table { width: 100%; border-collapse: collapse; }
th { padding: 12px 16px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; text-align: left; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.15); }
td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
tr:last-child td { border-bottom: none; }
.table-row { transition: background 0.15s; }
.table-row:hover { background: rgba(99, 102, 241, 0.04); }
.code-id { background: var(--bg-input); padding: 2px 8px; border-radius: 5px; font-size: 12px; color: var(--primary-light); font-family: monospace; }
.node-name { font-weight: 500; }
.ip-badge { font-family: monospace; font-size: 12px; color: var(--text-muted); background: rgba(6, 182, 212, 0.08); padding: 2px 8px; border-radius: 5px; }
.key-trunc { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-size: 11px; color: var(--text-dim); max-width: 100px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
.status-pill { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
.status-pill.online { background: rgba(34, 197, 94, 0.08); color: var(--success); border: 1px solid rgba(34, 197, 94, 0.15); }
.status-pill.offline { background: rgba(100, 116, 139, 0.08); color: var(--text-muted); border: 1px solid rgba(100, 116, 139, 0.15); }
.status-pill .dot { width: 5px; height: 5px; border-radius: 50%; }
.status-pill.online .dot { background: var(--success); box-shadow: 0 0 5px var(--success-glow); }
.status-pill.offline .dot { background: var(--text-dim); }
.time-cell { font-size: 12px; color: var(--text-muted); }
/* Buttons */
.btn-primary {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 18px; border: none; border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--accent));
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
transition: all 0.25s;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35); }
.btn-ghost {
padding: 8px 18px; border-radius: 10px;
background: transparent; border: 1px solid var(--border);
color: var(--text-muted); font-size: 13px; cursor: pointer; transition: all 0.15s;
}
.btn-ghost:hover { background: var(--bg-input); color: var(--text); }
/* Modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(2, 6, 23, 0.7);
backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center; z-index: 100;
animation: fadeIn 0.15s ease-out;
}
.modal {
background: var(--bg); border-radius: 16px; padding: 0;
border: 1px solid var(--border); width: 480px; max-width: 90vw;
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
overflow: hidden; position: relative;
}
.modal-shine { position: absolute; top: 0; left: 0; right: 0; height: 2px; background: linear-gradient(90deg, transparent, var(--primary-light), var(--accent), transparent); }
.modal-header { padding: 24px 24px 0; }
.modal-header h2 { display: flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 700; }
.modal-body { padding: 20px 24px; }
.modal-footer { display: flex; gap: 8px; justify-content: flex-end; padding: 16px 24px; border-top: 1px solid var(--border); background: rgba(0,0,0,0.15); }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; }
.field input {
width: 100%; padding: 9px 12px; border-radius: 9px;
border: 1px solid var(--border); background: var(--bg-input);
color: var(--text); font-size: 14px; transition: all 0.2s;
}
.field input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); }
.field-row { display: flex; gap: 12px; }
.field-row .field { flex: 1; }
.error-banner {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px; margin-bottom: 16px;
border-radius: 10px; border: 1px solid rgba(239, 68, 68, 0.2);
background: rgba(239, 68, 68, 0.06); color: var(--danger);
font-size: 13px; animation: fadeIn 0.2s ease-out;
}
.error-dismiss { margin-left: auto; background: none; border: none; color: var(--danger); cursor: pointer; padding: 2px; border-radius: 4px; opacity: 0.6; }
.error-dismiss:hover { opacity: 1; }
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div class="user-dash fade-in">
<div class="page-header">
<div>
<h2 class="page-title">我的控制台</h2>
<p class="page-subtitle">管理你的虚拟网络和设备</p>
</div>
</div>
<div v-if="loading" class="loading-state">
<div class="loading-ring"></div>
<span>加载中...</span>
</div>
<template v-else>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</div>
<div class="stat-body">
<div class="stat-value">{{ stats.networks_count }}</div>
<div class="stat-label">虚拟网络</div>
</div>
<div class="stat-trend">已创建 / {{ profile.quota_networks === -1 ? '∞' : profile.quota_networks }}</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<div class="stat-body">
<div class="stat-value">{{ stats.nodes_total }}</div>
<div class="stat-label">设备节点</div>
</div>
<div class="stat-trend">已注册 / {{ profile.quota_nodes === -1 ? '∞' : profile.quota_nodes }}</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="stat-body">
<div class="stat-value">{{ stats.authorized_members }}</div>
<div class="stat-label">授权成员</div>
</div>
<div class="stat-trend">已加入网络的设备</div>
</div>
</div>
<div class="action-cards">
<router-link to="/user/networks" class="action-card">
<div class="action-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</div>
<div class="action-body">
<strong>创建虚拟网络</strong>
<p>设置网段 CIDR创建工作网络</p>
</div>
</router-link>
<router-link to="/user/nodes" class="action-card">
<div class="action-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/></svg>
</div>
<div class="action-body">
<strong>注册设备</strong>
<p>添加节点到你的账号</p>
</div>
</router-link>
</div>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../api'
const loading = ref(true)
const stats = ref({ networks_count: 0, nodes_total: 0, authorized_members: 0 })
const profile = ref({ quota_networks: 3, quota_nodes: 20 })
onMounted(async () => {
try {
const [dash, prof] = await Promise.all([api.dashboard(), api.profile()])
stats.value = { ...stats.value, ...dash.data }
profile.value = prof.data.user || profile.value
} catch {}
loading.value = false
})
</script>
<style scoped>
.user-dash { animation: fadeIn 0.4s ease-out; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card {
background: var(--card-bg); border-radius: 14px; padding: 20px;
border: 1px solid var(--card-border); display: flex; flex-direction: column; gap: 12px;
transition: all 0.3s; position: relative; overflow: hidden;
}
.stat-card:hover { transform: translateY(-2px); border-color: var(--border-glow); }
.stat-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: rgba(99,102,241,0.1); color: #a5b4fc; }
.stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.5px; }
.stat-label { font-size: 13px; color: var(--text-dim); margin-top: 2px; }
.stat-trend { font-size: 11px; color: var(--text-muted); }
.action-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
.action-card {
display: flex; align-items: center; gap: 16px;
background: var(--card-bg); border-radius: 14px; padding: 20px;
border: 1px solid var(--card-border); text-decoration: none; color: var(--text);
transition: all 0.3s;
}
.action-card:hover { transform: translateY(-2px); border-color: var(--border-glow); }
.action-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; background: rgba(6,182,212,0.1); color: #67e8f9; flex-shrink: 0; }
.action-body strong { display: block; font-size: 14px; margin-bottom: 4px; }
.action-body p { font-size: 12px; color: var(--text-dim); margin: 0; }
.loading-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px; color: var(--text-dim); }
.loading-ring { width: 32px; height: 32px; border: 2px solid rgba(255,255,255,0.06); border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div class="net-detail fade-in">
<div class="page-header">
<div>
<router-link to="/user/networks" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
返回
</router-link>
<h2 class="page-title">{{ net.name || '网络详情' }}</h2>
<p class="page-subtitle">网段 {{ net.ip_range }} · ID #{{ net.network_id }}</p>
</div>
</div>
<div v-if="loading" class="loading-state"><div class="loading-ring"></div><span>加载中...</span></div>
<template v-else>
<div class="info-bar">
<div class="info-item"><span class="info-label">MTU</span><span>{{ net.mtu }}</span></div>
<div class="info-item"><span class="info-label">组播</span><span>{{ net.multicast ? '开启' : '关闭' }}</span></div>
<div class="info-item"><span class="info-label">私有</span><span>{{ net.private ? '是' : '否' }}</span></div>
<div class="info-item"><span class="info-label">成员</span><span>{{ members.length }}</span></div>
</div>
<h3 style="margin: 24px 0 12px; font-size: 14px;">已授权设备</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>Node ID</th>
<th>标签</th>
<th>IP 地址</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="m in members" :key="m.id">
<td><code>{{ m.node_id }}</code></td>
<td>{{ m.label || '-' }}</td>
<td><code>{{ m.ip_address || '待分配' }}</code></td>
<td>
<button class="btn-sm btn-danger-sm" @click="removeMember(m)">移除</button>
</td>
</tr>
<tr v-if="!members.length">
<td colspan="4" class="empty-cell">还没有设备加入此网络</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import api from '../api'
const route = useRoute()
const net = ref({})
const members = ref([])
const loading = ref(true)
onMounted(async () => {
try {
const [netRes, memRes] = await Promise.all([
api.networkGet(route.params.id),
api.networkMembers(route.params.id),
])
net.value = netRes.data.network || {}
members.value = memRes.data.members || []
} catch {}
loading.value = false
})
async function removeMember(m) {
if (!confirm(`确认移除设备 ${m.node_id}`)) return
try {
await api.networkDeauthorize(route.params.id, m.node_id)
members.value = members.value.filter(x => x.id !== m.id)
} catch {}
}
</script>
<style scoped>
.page-header { margin-bottom: 24px; }
.back-link { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-dim); text-decoration: none; margin-bottom: 8px; }
.back-link:hover { color: var(--text); }
.info-bar { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-bottom: 20px; }
.info-item { padding: 12px 16px; border-radius: 10px; background: var(--card-bg); border: 1px solid var(--card-border); }
.info-label { display: block; font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.table-container { background: var(--card-bg); border-radius: 10px; border: 1px solid var(--card-border); overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 10px 14px; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--card-border); }
td { padding: 8px 14px; font-size: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.03); }
tr:last-child td { border-bottom: none; }
code { font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; color: #a5b4fc; }
.empty-cell { text-align: center; padding: 32px; color: var(--text-dim); }
.btn-sm { padding: 4px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.04); color: var(--text); font-size: 12px; cursor: pointer; }
.btn-danger-sm { border-color: rgba(239,68,68,0.2); color: #ef4444; }
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div class="user-nets fade-in">
<div class="page-header">
<div>
<h2 class="page-title">我的虚拟网络</h2>
<p class="page-subtitle">创建和管理你的私有网络</p>
</div>
<router-link to="/user/network/create" class="btn-primary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
创建网络
</router-link>
</div>
<div v-if="errorMsg" class="error-banner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<span>{{ errorMsg }}</span>
<button class="error-dismiss" @click="errorMsg = ''">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div v-if="loading" class="loading-state">
<div class="loading-ring"></div>
<span>加载网络列表...</span>
</div>
<div v-else-if="!networks.length" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1" opacity="0.4"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<h3>还没有虚拟网络</h3>
<p>点击右上角创建网络开始</p>
</div>
<div v-else class="network-list">
<div v-for="net in networks" :key="net.network_id" class="network-card">
<div class="net-header">
<div class="net-name">{{ net.name }}</div>
<div class="net-id">#{{ net.network_id }}</div>
</div>
<div class="net-detail"><span class="net-label">网段</span><code>{{ net.ip_range }}</code></div>
<div class="net-detail"><span class="net-label">MTU</span><span>{{ net.mtu }}</span></div>
<div class="net-detail"><span class="net-label">成员</span><span>{{ (net.members || []).length }} 个设备</span></div>
<div class="net-actions">
<router-link :to="`/user/networks/${net.network_id}`" class="btn-sm">管理</router-link>
<button class="btn-sm btn-danger-sm" @click="deleteNet(net)">删除</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../api'
const loading = ref(true)
const errorMsg = ref('')
const networks = ref([])
onMounted(load)
async function load() {
loading.value = true
try {
const res = await api.networkList()
networks.value = res.data.networks || []
} catch (e) {
errorMsg.value = e.response?.data?.error || '加载失败'
}
loading.value = false
}
async function deleteNet(net) {
if (!confirm(`确认删除网络「${net.name}」?此操作不可恢复。`)) return
try {
await api.networkDelete(net.network_id)
await load()
} catch (e) {
errorMsg.value = e.response?.data?.error || '删除失败'
}
}
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 28px; }
.network-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.network-card {
background: var(--card-bg); border-radius: 12px; padding: 20px;
border: 1px solid var(--card-border); transition: all 0.3s;
}
.network-card:hover { transform: translateY(-2px); border-color: var(--border-glow); }
.net-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.net-name { font-size: 15px; font-weight: 600; }
.net-id { font-size: 11px; color: var(--text-dim); background: rgba(255,255,255,0.04); padding: 2px 8px; border-radius: 4px; }
.net-detail { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; font-size: 13px; border-bottom: 1px solid rgba(255,255,255,0.03); }
.net-detail:last-of-type { border-bottom: none; margin-bottom: 12px; }
.net-label { color: var(--text-dim); font-size: 12px; }
code { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: #a5b4fc; }
.net-actions { display: flex; gap: 8px; }
.btn-sm {
flex: 1; text-align: center; padding: 6px; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.04);
color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer; transition: all 0.2s;
}
.btn-sm:hover { border-color: rgba(99,102,241,0.3); }
.btn-danger-sm { border-color: rgba(239,68,68,0.2); color: #ef4444; }
.btn-danger-sm:hover { background: rgba(239,68,68,0.1); }
</style>

215
web/src/views/UserNodes.vue Normal file
View File

@@ -0,0 +1,215 @@
<template>
<div class="user-nodes fade-in">
<div class="page-header">
<div>
<h2 class="page-title">我的设备</h2>
<p class="page-subtitle">注册和管理你的节点设备</p>
</div>
<button class="btn-primary" @click="showRegister = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
注册设备
</button>
</div>
<div v-if="errorMsg" class="error-banner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<span>{{ errorMsg }}</span>
<button class="error-dismiss" @click="errorMsg = ''">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Register Modal -->
<div v-if="showRegister" class="modal-overlay" @click.self="showRegister = false">
<div class="modal">
<h3>注册设备</h3>
<p class="modal-desc">在设备上运行 agent 把生成的 Node ID 和公钥填入下方</p>
<div class="form-group">
<label>Node ID</label>
<input v-model="form.nodeId" placeholder="10 位十六进制地址" />
</div>
<div class="form-group">
<label>公钥 (ed25519)</label>
<input v-model="form.publicKey" placeholder="Base64 编码的公钥" />
</div>
<div class="form-group">
<label>设备名称</label>
<input v-model="form.name" placeholder="例如:办公笔记本" />
</div>
<div class="form-group">
<label>版本</label>
<input v-model="form.version" placeholder="1.0.0" />
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="showRegister = false">取消</button>
<button class="btn-primary" @click="registerNode" :disabled="registering">{{ registering ? '注册中...' : '注册' }}</button>
</div>
</div>
</div>
<div v-if="loading" class="loading-state">
<div class="loading-ring"></div>
<span>加载设备列表...</span>
</div>
<div v-else-if="!nodes.length" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1" opacity="0.4"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
<h3>还没有注册设备</h3>
<p>点击右上角注册设备添加你的第一个节点</p>
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Node ID</th>
<th>名称</th>
<th>公钥</th>
<th>状态</th>
<th>最后在线</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="node in nodes" :key="node.node_id">
<td><code>{{ node.node_id }}</code></td>
<td>{{ node.name || '-' }}</td>
<td class="cell-pk"><code>{{ truncate(node.public_key, 24) }}</code></td>
<td>
<span :class="['status-dot', node.online ? 'online' : 'offline']"></span>
{{ node.online ? '在线' : '离线' }}
</td>
<td class="cell-time">{{ node.last_seen ? formatTime(node.last_seen) : '-' }}</td>
<td>
<button class="btn-sm" @click="joinNetwork(node)">加入网络</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Join Network Modal -->
<div v-if="showJoin" class="modal-overlay" @click.self="showJoin = false">
<div class="modal">
<h3>加入网络</h3>
<p class="modal-desc">将设备 <code>{{ joinNode?.node_id }}</code> 加入以下网络</p>
<div class="form-group">
<label>选择网络</label>
<select v-model="joinNetworkId">
<option v-for="net in networks" :key="net.network_id" :value="net.network_id">{{ net.name }} ({{ net.ip_range }})</option>
</select>
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="showJoin = false">取消</button>
<button class="btn-primary" @click="confirmJoin" :disabled="joining">{{ joining ? '加入中...' : '加入' }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../api'
const loading = ref(true)
const errorMsg = ref('')
const nodes = ref([])
const networks = ref([])
const showRegister = ref(false)
const registering = ref(false)
const form = ref({ nodeId: '', publicKey: '', name: '', version: '' })
const showJoin = ref(false)
const joinNode = ref(null)
const joinNetworkId = ref('')
const joining = ref(false)
onMounted(load)
async function load() {
loading.value = true
try {
const [nodeRes, netRes] = await Promise.all([api.nodeList(), api.networkList()])
nodes.value = nodeRes.data.nodes || []
networks.value = netRes.data.networks || []
} catch (e) {
errorMsg.value = e.response?.data?.error || '加载失败'
}
loading.value = false
}
async function registerNode() {
if (!form.value.nodeId || !form.value.publicKey) return
registering.value = true
errorMsg.value = ''
try {
await api.nodeRegister({
node_id: form.value.nodeId,
public_key: form.value.publicKey,
name: form.value.name,
version: form.value.version,
})
showRegister.value = false
form.value = { nodeId: '', publicKey: '', name: '', version: '' }
await load()
} catch (e) {
errorMsg.value = e.response?.data?.error || '注册失败'
}
registering.value = false
}
function joinNetwork(node) {
joinNode.value = node
joinNetworkId.value = networks.value[0]?.network_id || ''
showJoin.value = true
}
async function confirmJoin() {
if (!joinNetworkId.value) return
joining.value = true
errorMsg.value = ''
try {
await api.networkAuthorize(joinNetworkId.value, joinNode.value.node_id)
showJoin.value = false
} catch (e) {
errorMsg.value = e.response?.data?.error || '加入失败'
}
joining.value = false
}
function formatTime(t) {
if (!t) return '-'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
function truncate(s, n) {
if (!s) return ''
return s.length > n ? s.slice(0, n) + '...' : s
}
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 28px; }
.table-container { overflow-x: auto; background: var(--card-bg); border-radius: 10px; border: 1px solid var(--card-border); }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 10px 14px; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--card-border); }
td { padding: 8px 14px; font-size: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.03); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(99,102,241,0.03); }
code { font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; color: #a5b4fc; }
.cell-pk { max-width: 180px; overflow: hidden; text-overflow: ellipsis; }
.cell-time { font-size: 0.75rem; color: var(--text-dim); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin-right: 6px; vertical-align: middle; }
.status-dot.online { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
.status-dot.offline { background: #6b7280; }
.btn-sm { padding: 4px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.04); color: var(--text); font-size: 12px; cursor: pointer; transition: all 0.2s; }
.btn-sm:hover { border-color: rgba(99,102,241,0.3); }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; backdrop-filter: blur(4px); }
.modal { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 14px; padding: 28px; width: 100%; max-width: 440px; }
.modal h3 { margin-bottom: 6px; }
.modal-desc { font-size: 13px; color: var(--text-dim); margin-bottom: 20px; }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.03em; }
.form-group input, .form-group select { width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); background: rgba(0,0,0,0.3); color: var(--text); font-size: 13px; outline: none; box-sizing: border-box; }
.form-group input:focus, .form-group select:focus { border-color: rgba(99,102,241,0.5); }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
</style>

17
web/vite.config.js Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: '/',
build: {
outDir: '../web/dist',
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:10001',
},
},
})