Files
zeromesh/web/src/views/Nodes.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>