mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-07 18:24:36 +00:00
[easytier-uptime] support tag in node list (#1487)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user