[easytier-uptime] support tag in node list (#1487)

This commit is contained in:
Sijie.Sun
2025-10-18 23:19:53 +08:00
committed by GitHub
parent cc8f35787e
commit f10b45a67c
25 changed files with 902 additions and 73 deletions
@@ -196,6 +196,17 @@
<el-table-column prop="description" label="描述" min-width="150" show-overflow-tooltip />
<el-table-column prop="tags" label="标签" min-width="160">
<template #default="{ row }">
<div class="tags-list">
<el-tag v-for="(tag, idx) in row.tags" :key="tag + idx" size="small" class="tag-chip" :style="getTagStyle(tag)">
{{ tag }}
</el-tag>
<span v-if="!row.tags || row.tags.length === 0" class="text-muted"></span>
</div>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
@@ -228,8 +239,8 @@
<!-- 编辑节点对话框 -->
<el-dialog v-model="editDialogVisible" title="编辑节点" width="800px" destroy-on-close>
<NodeForm v-if="editDialogVisible" v-model="editForm" :submitting="updating" submit-text="更新节点" submit-icon="Edit"
:show-connection-test="false" :show-agreement="false" :show-cancel="true" @submit="handleUpdateNode"
@cancel="editDialogVisible = false" @reset="resetEditForm" />
:show-connection-test="false" :show-agreement="false" :show-cancel="true" :show-tags="true"
@submit="handleUpdateNode" @cancel="editDialogVisible = false" @reset="resetEditForm" />
</el-dialog>
</div>
</template>
@@ -240,6 +251,7 @@ import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Clock, DataAnalysis, CircleCheck, Loading } from '@element-plus/icons-vue'
import NodeForm from '../components/NodeForm.vue'
import { getTagStyle } from '../utils/tagColor'
export default {
name: 'AdminDashboard',
@@ -270,7 +282,8 @@ export default {
protocol: 'tcp',
version: '',
max_connections: 100,
description: ''
description: '',
tags: []
},
editingNodeId: null,
updating: false
@@ -302,6 +315,7 @@ export default {
}
},
methods: {
getTagStyle,
async loadNodes() {
try {
this.loading = true
@@ -379,13 +393,47 @@ export default {
},
editNode(node) {
this.editingNodeId = node.id
this.editForm = node
// 只取需要的字段,并复制 tags 数组以避免引用问题
this.editForm = {
id: node.id,
name: node.name,
host: node.host,
port: node.port,
protocol: node.protocol,
version: node.version,
max_connections: node.max_connections,
description: node.description || '',
allow_relay: node.allow_relay,
network_name: node.network_name,
network_secret: node.network_secret,
wechat: node.wechat,
qq_number: node.qq_number,
mail: node.mail,
tags: Array.isArray(node.tags) ? [...node.tags] : []
}
this.editDialogVisible = true
},
async handleUpdateNode(formData) {
try {
this.updating = true
await adminApi.updateNode(this.editingNodeId, formData)
// 确保提交包含 tags 字段(为空数组也传)
const payload = {
name: formData.name,
host: formData.host,
port: formData.port,
protocol: formData.protocol,
version: formData.version,
max_connections: formData.max_connections,
description: formData.description,
allow_relay: formData.allow_relay,
network_name: formData.network_name,
network_secret: formData.network_secret,
wechat: formData.wechat,
qq_number: formData.qq_number,
mail: formData.mail,
tags: Array.isArray(formData.tags) ? formData.tags : []
}
await adminApi.updateNode(this.editingNodeId, payload)
ElMessage.success('节点更新成功')
this.editDialogVisible = false
await this.loadNodes()
@@ -576,4 +624,8 @@ export default {
.text-secondary {
color: #909399;
}
.tag-chip {
margin-right: 4px;
}
</style>
@@ -56,7 +56,7 @@
<!-- 搜索和筛选 -->
<el-card class="filter-card">
<el-row :gutter="20">
<el-row :gutter="26">
<el-col :span="8">
<el-input v-model="searchText" placeholder="搜索节点名称、主机地址或描述" prefix-icon="Search" clearable
@input="handleSearch" />
@@ -77,14 +77,16 @@
<el-option label="WSS" value="wss" />
</el-select>
</el-col>
<!-- 新增标签多选筛选 -->
<el-col :span="4">
<el-button type="primary" @click="refreshData" :loading="loading">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
<el-select v-model="selectedTags" multiple collapse-tags collapse-tags-tooltip filterable clearable
placeholder="按标签筛选(可多选)" @change="handleFilter">
<el-option v-for="tag in allTags" :key="tag" :label="tag" :value="tag">
<span class="tag-option" :style="getTagStyle(tag)">{{ tag }}</span>
</el-option>
</el-select>
</el-col>
<el-col :span="4">
<el-button type="success" @click="$router.push('/submit')">
<el-icon>
@@ -97,17 +99,24 @@
</el-card>
<!-- 节点列表 -->
<el-card class="nodes-card">
<el-card ref="nodesCardRef" class="nodes-card">
<template #header>
<div class="card-header">
<span>节点列表</span>
<span>
节点列表
<el-button type="text" :loading="loading" @click="refreshData" style="margin-left: 8px;">
<el-icon>
<Refresh />
</el-icon>
</el-button>
</span>
<el-tag :type="loading ? 'info' : 'success'">
{{ loading ? '加载中...' : `${pagination.total} 个节点` }}
</el-tag>
</div>
</template>
<el-table :data="nodes" v-loading="loading" stripe style="width: 100%" row-key="id">
<el-table ref="tableRef" :data="nodes" v-loading="loading" stripe style="width: 100%" row-key="id">
<!-- 展开列 -->
<el-table-column type="expand" width="50">
<template #default="{ row }">
@@ -151,7 +160,7 @@
<template #default="{ row }">
<div style="display: flex; flex-direction: column; gap: 1px; align-items: flex-start;">
<el-tag v-if="row.version" size="small" style="font-size: 11px; padding: 1px 4px;">{{ row.version
}}</el-tag>
}}</el-tag>
<span v-else class="text-muted" style="font-size: 11px;">未知</span>
<el-tag :type="row.allow_relay ? 'success' : 'info'" size="small"
style="font-size: 9px; padding: 1px 3px;">
@@ -176,6 +185,18 @@
<span class="description">{{ row.description || '暂无描述' }}</span>
</template>
</el-table-column>
<!-- 新增标签展示 -->
<el-table-column label="标签" min-width="160">
<template #default="{ row }">
<div class="tags-list">
<el-tag v-for="(tag, idx) in row.tags" :key="tag + idx" size="small" class="tag-chip"
:style="getTagStyle(tag)" style="margin: 2px 6px 2px 0;">
{{ tag }}
</el-tag>
<span v-if="!row.tags || row.tags.length === 0" class="text-muted"></span>
</div>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
@@ -223,6 +244,16 @@
<el-descriptions-item label="创建时间">{{ formatDate(selectedNode.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(selectedNode.updated_at) }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ selectedNode.description || '暂无描述' }}</el-descriptions-item>
<!-- 新增标签 -->
<el-descriptions-item label="标签" :span="2">
<div class="tags-list">
<el-tag v-for="(tag, idx) in selectedNode.tags" :key="tag + idx" size="small" class="tag-chip"
style="margin: 2px 6px 2px 0;">
{{ tag }}
</el-tag>
<span v-if="!selectedNode.tags || selectedNode.tags.length === 0" class="text-muted"></span>
</div>
</el-descriptions-item>
</el-descriptions>
<!-- 健康状态统计 -->
@@ -261,7 +292,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ref, reactive, onMounted, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { nodeApi } from '../api'
import dayjs from 'dayjs'
@@ -276,6 +307,7 @@ import {
Refresh,
Plus
} from '@element-plus/icons-vue'
import { getTagStyle } from '../utils/tagColor'
// 响应式数据
const loading = ref(false)
@@ -283,11 +315,18 @@ const nodes = ref([])
const searchText = ref('')
const statusFilter = ref('')
const protocolFilter = ref('')
const selectedTags = ref([])
const allTags = ref([])
const detailDialogVisible = ref(false)
const selectedNode = ref(null)
const healthStats = ref(null)
const expandedRows = ref([])
const apiUrl = ref(window.location.href)
const tableRef = ref(null)
const nodesCardRef = ref(null)
// 请求取消控制(避免重复请求覆盖)
let fetchController = null
// 分页数据
const pagination = reactive({
@@ -309,6 +348,17 @@ const averageUptime = computed(() => {
})
// 方法
const fetchTags = async () => {
try {
const resp = await nodeApi.getAllTags()
if (resp.success && Array.isArray(resp.data)) {
allTags.value = resp.data
}
} catch (error) {
console.error('获取标签列表失败:', error)
}
}
const fetchNodes = async (with_loading = true) => {
try {
if (with_loading) {
@@ -328,13 +378,26 @@ const fetchNodes = async (with_loading = true) => {
if (protocolFilter.value) {
params.protocol = protocolFilter.value
}
if (selectedTags.value && selectedTags.value.length > 0) {
params.tags = selectedTags.value
}
const response = await nodeApi.getNodes(params)
// 取消上一请求,创建新的请求控制器
if (fetchController) {
try { fetchController.abort() } catch (_) { }
}
fetchController = new AbortController()
const response = await nodeApi.getNodes(params, { signal: fetchController.signal })
if (response.success && response.data) {
nodes.value = response.data.items
pagination.total = response.data.total
}
} catch (error) {
if (error.name === 'CanceledError' || error.name === 'AbortError') {
// 被取消的旧请求,忽略
return
}
console.error('获取节点列表失败:', error)
ElMessage.error('获取节点列表失败')
} finally {
@@ -345,6 +408,7 @@ const fetchNodes = async (with_loading = true) => {
}
const refreshData = () => {
pagination.page = 1
fetchNodes()
}
@@ -408,12 +472,69 @@ const copyAddress = (address) => {
// 生命周期
onMounted(() => {
fetchTags()
fetchNodes()
// 设置定时刷新
setInterval(() => {
fetchNodes(false)
}, 3000) // 每30秒刷新一次
}, 30000) // 每30秒刷新一次
})
// 智能滚动处理:纵向滚动时页面整体滚动,横向滚动时表格内部滚动
let wheelHandler = null
let wheelTargets = []
const detachWheelHandlers = () => {
if (wheelTargets && wheelTargets.length) {
wheelTargets.forEach((el) => {
try { el.removeEventListener('wheel', wheelHandler, { capture: true }) } catch (_) { }
})
}
wheelTargets = []
}
const attachWheelHandler = () => {
const tableEl = tableRef.value?.$el
const body = tableEl ? tableEl.querySelector('.el-table__body-wrapper') : null
if (!body) return
detachWheelHandlers()
const wrap = body.querySelector('.el-scrollbar__wrap') || body
wheelHandler = (e) => {
const deltaX = e.deltaX
const deltaY = e.deltaY
// 如果是横向滚动(Shift + 滚轮 或 触摸板横向滑动)
if (Math.abs(deltaX) > Math.abs(deltaY) || e.shiftKey) {
// 允许表格内部横向滚动,不阻止默认行为
return
}
// 如果是纵向滚动,阻止表格内部滚动,让页面整体滚动
if (deltaY) {
e.preventDefault()
e.stopPropagation()
const scroller = document.scrollingElement || document.documentElement
scroller.scrollTop += deltaY
}
}
body.addEventListener('wheel', wheelHandler, { passive: false, capture: true })
wheelTargets.push(body)
}
onMounted(() => {
nextTick(attachWheelHandler)
})
watch(nodes, () => {
nextTick(attachWheelHandler)
})
onBeforeUnmount(() => {
detachWheelHandlers()
})
</script>
@@ -570,4 +691,28 @@ onMounted(() => {
background-color: #fafafa;
border-top: 1px solid #ebeef5;
}
.tag-option {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
:deep(.el-table__body-wrapper) {
overflow-x: auto !important;
overflow-y: hidden !important;
height: auto !important;
}
:deep(.el-card__body) {
overflow: visible !important;
}
:deep(.el-table__body-wrapper .el-scrollbar__wrap) {
overflow-x: auto !important;
overflow-y: hidden !important;
height: auto !important;
max-height: none !important;
}
</style>