295 lines
13 KiB
Vue
295 lines
13 KiB
Vue
<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>
|