mirror of
https://mirror.suhoan.cn/https://github.com/EasyTier/EasyTier.git
synced 2025-12-14 13:47:24 +08:00
[easytier-uptime] support tag in node list (#1487)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { healthApi } from './api'
|
||||
import {
|
||||
@@ -70,6 +70,20 @@ const menuItems = [
|
||||
}
|
||||
]
|
||||
|
||||
// 根据当前路由计算默认激活的菜单项
|
||||
const activeMenuIndex = computed(() => {
|
||||
const p = route.path
|
||||
if (p.startsWith('/submit')) return 'submit'
|
||||
return 'dashboard'
|
||||
})
|
||||
|
||||
// 处理菜单选择,避免返回 Promise 导致异步补丁问题
|
||||
const handleMenuSelect = (key) => {
|
||||
const item = menuItems.find((i) => i.name === key)
|
||||
if (item && item.path) {
|
||||
router.push(item.path)
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
checkHealth()
|
||||
// 定期检查健康状态
|
||||
@@ -89,8 +103,8 @@ onMounted(() => {
|
||||
<h1 class="app-title">EasyTier Uptime</h1>
|
||||
</div>
|
||||
|
||||
<el-menu :default-active="route.name" mode="horizontal" class="nav-menu"
|
||||
@select="(key) => router.push(menuItems.find(item => item.name === key)?.path || '/')">
|
||||
<el-menu :default-active="activeMenuIndex" mode="horizontal" class="nav-menu"
|
||||
@select="handleMenuSelect">
|
||||
<el-menu-item v-for="item in menuItems" :key="item.name" :index="item.name">
|
||||
<el-icon>
|
||||
<component :is="item.icon" />
|
||||
|
||||
@@ -6,6 +6,18 @@ const api = axios.create({
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
// 保证数组参数使用 repeated keys 风格序列化:tags=a&tags=b
|
||||
paramsSerializer: params => {
|
||||
const usp = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => usp.append(key, v))
|
||||
} else if (value !== undefined && value !== null && value !== '') {
|
||||
usp.append(key, value)
|
||||
}
|
||||
})
|
||||
return usp.toString()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -50,9 +62,15 @@ api.interceptors.response.use(
|
||||
|
||||
// 节点相关API
|
||||
export const nodeApi = {
|
||||
// 获取节点列表
|
||||
async getNodes(params = {}) {
|
||||
const response = await api.get('/api/nodes', { params })
|
||||
// 获取节点列表(支持传入 AbortController.signal 用于取消)
|
||||
async getNodes(params = {}, options = {}) {
|
||||
const response = await api.get('/api/nodes', { params, signal: options.signal })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取所有标签
|
||||
async getAllTags() {
|
||||
const response = await api.get('/api/tags')
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -149,6 +167,28 @@ export const adminApi = {
|
||||
async updateNode(id, data) {
|
||||
const response = await api.put(`/api/admin/nodes/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 兼容方法:获取所有节点(参数转换)
|
||||
async getAllNodes(params = {}) {
|
||||
const mapped = {
|
||||
page: params.page,
|
||||
per_page: params.page_size ?? params.per_page,
|
||||
is_approved: params.approved ?? params.is_approved,
|
||||
is_active: params.online ?? params.is_active,
|
||||
protocol: params.protocol,
|
||||
search: params.search,
|
||||
tag: params.tag
|
||||
}
|
||||
// 移除未定义的字段
|
||||
Object.keys(mapped).forEach(k => {
|
||||
if (mapped[k] === undefined || mapped[k] === null || mapped[k] === '') {
|
||||
delete mapped[k]
|
||||
}
|
||||
})
|
||||
// 直接复用现有接口
|
||||
const response = await api.get('/api/admin/nodes', { params: mapped })
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,15 @@
|
||||
<div class="form-tip">详细描述有助于用户选择合适的节点</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 新增:标签管理(仅在管理员编辑时显示) -->
|
||||
<el-form-item v-if="props.showTags" label="标签" prop="tags">
|
||||
<el-select v-model="form.tags" multiple filterable allow-create default-first-option :multiple-limit="10"
|
||||
placeholder="输入后按回车添加,如:北京、联通、IPv6、高带宽">
|
||||
<el-option v-for="opt in (form.tags || [])" :key="opt" :label="opt" :value="opt" />
|
||||
</el-select>
|
||||
<div class="form-tip">用于分类与检索,建议 1-6 个标签,每个不超过 32 字符</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<el-form-item label="联系方式" prop="contact_info">
|
||||
<div class="contact-section">
|
||||
@@ -238,6 +247,7 @@ const props = defineProps({
|
||||
wechat: '',
|
||||
qq_number: '',
|
||||
mail: '',
|
||||
tags: [],
|
||||
agreed: false
|
||||
})
|
||||
},
|
||||
@@ -264,6 +274,11 @@ const props = defineProps({
|
||||
showCancel: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 新增:是否显示标签管理
|
||||
showTags: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -353,6 +368,38 @@ const rules = {
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
// 新增:标签规则(仅在显示标签管理时生效)
|
||||
tags: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (!props.showTags) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!Array.isArray(form.tags)) {
|
||||
callback(new Error('标签格式错误'))
|
||||
return
|
||||
}
|
||||
if (form.tags.length > 10) {
|
||||
callback(new Error('最多添加 10 个标签'))
|
||||
return
|
||||
}
|
||||
for (const t of form.tags) {
|
||||
const s = (t || '').trim()
|
||||
if (s.length === 0) {
|
||||
callback(new Error('标签不能为空'))
|
||||
return
|
||||
}
|
||||
if (s.length > 32) {
|
||||
callback(new Error('每个标签不超过 32 字符'))
|
||||
return
|
||||
}
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -362,7 +409,7 @@ const canTest = computed(() => {
|
||||
})
|
||||
|
||||
const buildDataFromForm = () => {
|
||||
return {
|
||||
const data = {
|
||||
name: form.name || 'Test Node',
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
@@ -376,6 +423,11 @@ const buildDataFromForm = () => {
|
||||
qq_number: form.qq_number || null,
|
||||
mail: form.mail || null
|
||||
}
|
||||
// 仅在管理员编辑时附带标签
|
||||
if (props.showTags) {
|
||||
data.tags = Array.isArray(form.tags) ? form.tags : []
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
@@ -441,6 +493,10 @@ const resetFields = () => {
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
// 重置标签
|
||||
if (props.showTags) {
|
||||
form.tags = []
|
||||
}
|
||||
testResult.value = null
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Deterministic tag color generator (pure frontend)
|
||||
// Same tag => same color; different tags => different colors
|
||||
|
||||
function stringHash(str) {
|
||||
const s = String(str)
|
||||
let hash = 5381
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
hash = (hash * 33) ^ s.charCodeAt(i)
|
||||
}
|
||||
return hash >>> 0 // ensure positive
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
// h,s,l in [0,1]
|
||||
let r, g, b
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l // achromatic
|
||||
} else {
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t
|
||||
if (t < 1 / 2) return q
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
|
||||
return p
|
||||
}
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1 / 3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1 / 3)
|
||||
}
|
||||
|
||||
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
const toHex = (v) => v.toString(16).padStart(2, '0')
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
export function getTagStyle(tag) {
|
||||
const hash = stringHash(tag)
|
||||
const hue = hash % 360 // 0-359
|
||||
const saturation = 65 // percentage
|
||||
const lightness = 47 // percentage
|
||||
|
||||
const rgb = hslToRgb(hue / 360, saturation / 100, lightness / 100)
|
||||
const hex = rgbToHex(rgb[0], rgb[1], rgb[2])
|
||||
|
||||
// Perceived brightness for text color selection
|
||||
const brightness = rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114
|
||||
const textColor = brightness > 160 ? '#1f1f1f' : '#ffffff'
|
||||
|
||||
return {
|
||||
backgroundColor: hex,
|
||||
borderColor: hex,
|
||||
color: textColor
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -18,11 +18,11 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
target: 'http://localhost:11030',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8080',
|
||||
target: 'http://localhost:11030',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user