mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-04 01:55:41 +00:00
719 lines
21 KiB
Vue
719 lines
21 KiB
Vue
<template>
|
||
<div class="node-dashboard">
|
||
<!-- 页面头部 -->
|
||
<div class="dashboard-header">
|
||
<h1>EasyTier 节点状态监控</h1>
|
||
<p class="subtitle">实时监控所有共享节点的健康状态和连接信息</p>
|
||
</div>
|
||
|
||
<!-- 统计卡片 -->
|
||
<el-row :gutter="20" class="stats-row">
|
||
<el-col :span="6">
|
||
<el-card class="stat-card">
|
||
<div class="stat-content">
|
||
<div class="stat-number">{{ totalNodes }}</div>
|
||
<div class="stat-label">总节点数</div>
|
||
</div>
|
||
<el-icon class="stat-icon" color="#409EFF">
|
||
<Monitor />
|
||
</el-icon>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-card class="stat-card">
|
||
<div class="stat-content">
|
||
<div class="stat-number">{{ activeNodes }}</div>
|
||
<div class="stat-label">在线节点</div>
|
||
</div>
|
||
<el-icon class="stat-icon" color="#67C23A">
|
||
<CircleCheck />
|
||
</el-icon>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-card class="stat-card">
|
||
<div class="stat-content">
|
||
<div class="stat-number">{{ averageLoad }} %</div>
|
||
<div class="stat-label">平均负载</div>
|
||
</div>
|
||
<el-icon class="stat-icon" color="#E6A23C">
|
||
<Link />
|
||
</el-icon>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-card class="stat-card">
|
||
<div class="stat-content">
|
||
<div class="stat-number">{{ averageUptime }}%</div>
|
||
<div class="stat-label">平均在线率</div>
|
||
</div>
|
||
<el-icon class="stat-icon" color="#F56C6C">
|
||
<TrendCharts />
|
||
</el-icon>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<!-- 搜索和筛选 -->
|
||
<el-card class="filter-card">
|
||
<el-row :gutter="26">
|
||
<el-col :span="8">
|
||
<el-input v-model="searchText" placeholder="搜索节点名称、主机地址或描述" prefix-icon="Search" clearable
|
||
@input="handleSearch" />
|
||
</el-col>
|
||
<el-col :span="4">
|
||
<el-select v-model="statusFilter" placeholder="状态筛选" clearable @change="handleFilter">
|
||
<el-option label="全部" value="" />
|
||
<el-option label="在线" value="true" />
|
||
<el-option label="离线" value="false" />
|
||
</el-select>
|
||
</el-col>
|
||
<el-col :span="4">
|
||
<el-select v-model="protocolFilter" placeholder="协议筛选" clearable @change="handleFilter">
|
||
<el-option label="全部" value="" />
|
||
<el-option label="TCP" value="tcp" />
|
||
<el-option label="UDP" value="udp" />
|
||
<el-option label="WS" value="ws" />
|
||
<el-option label="WSS" value="wss" />
|
||
</el-select>
|
||
</el-col>
|
||
<!-- 新增:标签多选筛选 -->
|
||
<el-col :span="4">
|
||
<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>
|
||
<Plus />
|
||
</el-icon>
|
||
提交节点
|
||
</el-button>
|
||
</el-col>
|
||
</el-row>
|
||
</el-card>
|
||
|
||
<!-- 节点列表 -->
|
||
<el-card ref="nodesCardRef" class="nodes-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<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 ref="tableRef" :data="nodes" v-loading="loading" stripe style="width: 100%" row-key="id">
|
||
<!-- 展开列 -->
|
||
<el-table-column type="expand" width="50">
|
||
<template #default="{ row }">
|
||
<div class="expanded-content">
|
||
<HealthTimeline :node-info="row" :compact="true" />
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column prop="name" label="节点名称" width="150">
|
||
<template #default="{ row }">
|
||
<div class="node-name">
|
||
<el-icon :color="row.is_active ? '#67C23A' : '#F56C6C'">
|
||
<CircleCheck v-if="row.is_active" />
|
||
<CircleClose v-else />
|
||
</el-icon>
|
||
<span>{{ row.name }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column prop="address" label="节点连接地址" width="250">
|
||
<template #header>
|
||
<span>节点连接地址</span>
|
||
<el-tooltip content="可以将节点链接填入命令行的 -p 参数,或者图形界面的节点地址字段(公共服务器或手动皆可)" placement="top" effect="light">
|
||
<el-icon class="help-icon">
|
||
<QuestionFilled />
|
||
</el-icon>
|
||
</el-tooltip>
|
||
</template>
|
||
<template #default="{ row }">
|
||
<el-tag type="primary" size="" style="margin-bottom: 0.2rem;"
|
||
@click="copyAddress(apiUrl + 'node/' + row.id)"> {{
|
||
apiUrl
|
||
}}node/{{ row.id }}</el-tag>
|
||
<el-tag type="info" size="" @click="copyAddress(row.address)">{{ row.address }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="版本" width="90">
|
||
<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>
|
||
<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;">
|
||
{{ row.allow_relay ? '可中转' : '禁中转' }}
|
||
</el-tag>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="连接状态" width="150">
|
||
<template #default="{ row }">
|
||
<div class="connection-info">
|
||
<span>{{ row.current_connections }}/{{ row.max_connections }}</span>
|
||
<el-progress :percentage="row.usage_percentage" :color="getProgressColor(row.usage_percentage)"
|
||
:stroke-width="6" :show-text="false" />
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column prop="description" label="描述" min-width="200">
|
||
<template #default="{ row }">
|
||
<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 }">
|
||
{{ formatDate(row.created_at) }}
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="操作" width="120" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button type="primary" size="small" @click.stop="viewNodeDetails(row)">
|
||
详情
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- 分页 -->
|
||
<div class="pagination-wrapper">
|
||
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page"
|
||
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
|
||
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 节点详情对话框 -->
|
||
<el-dialog v-model="detailDialogVisible" :title="selectedNode?.name + ' - 详细信息'" width="800px" destroy-on-close>
|
||
<div v-if="selectedNode" class="node-details">
|
||
<el-descriptions :column="2" border>
|
||
<el-descriptions-item label="节点名称">{{ selectedNode.name }}</el-descriptions-item>
|
||
<el-descriptions-item label="状态">
|
||
<el-tag :type="selectedNode.is_active ? 'success' : 'danger'">
|
||
{{ selectedNode.is_active ? '在线' : '离线' }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="主机地址">{{ selectedNode.host }}</el-descriptions-item>
|
||
<el-descriptions-item label="端口">{{ selectedNode.port }}</el-descriptions-item>
|
||
<el-descriptions-item label="协议">{{ selectedNode.protocol.toUpperCase() }}</el-descriptions-item>
|
||
<el-descriptions-item label="版本">{{ selectedNode.version || '未知' }}</el-descriptions-item>
|
||
<el-descriptions-item label="允许中转">
|
||
<el-tag :type="selectedNode.allow_relay ? 'success' : 'info'" size="small">
|
||
{{ selectedNode.allow_relay ? '是' : '否' }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="使用率">{{ selectedNode.usage_percentage.toFixed(1) }}%</el-descriptions-item>
|
||
<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>
|
||
|
||
<!-- 健康状态统计 -->
|
||
<div class="health-stats" v-if="healthStats">
|
||
<h3>健康状态统计 (最近24小时)</h3>
|
||
<el-row :gutter="20">
|
||
<el-col :span="6">
|
||
<div class="health-stat-item">
|
||
<div class="stat-value">{{ healthStats.uptime_percentage?.toFixed(1) || 0 }}%</div>
|
||
<div class="stat-label">在线率</div>
|
||
</div>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<div class="health-stat-item">
|
||
<div class="stat-value">{{ (selectedNode.last_response_time / 1000) || 0 }}ms</div>
|
||
<div class="stat-label">平均响应时间</div>
|
||
</div>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<div class="health-stat-item">
|
||
<div class="stat-value">{{ healthStats.total_checks || 0 }}</div>
|
||
<div class="stat-label">检查次数</div>
|
||
</div>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<div class="health-stat-item">
|
||
<div class="stat-value">{{ healthStats.failed_checks || 0 }}</div>
|
||
<div class="stat-label">失败次数</div>
|
||
</div>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
</div>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { nodeApi } from '../api'
|
||
import dayjs from 'dayjs'
|
||
import HealthTimeline from '../components/HealthTimeline.vue'
|
||
import {
|
||
Monitor,
|
||
CircleCheck,
|
||
CircleClose,
|
||
Link,
|
||
TrendCharts,
|
||
Search,
|
||
Refresh,
|
||
Plus
|
||
} from '@element-plus/icons-vue'
|
||
import { getTagStyle } from '../utils/tagColor'
|
||
|
||
// 响应式数据
|
||
const loading = ref(false)
|
||
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({
|
||
page: 1,
|
||
per_page: 50,
|
||
total: 0
|
||
})
|
||
|
||
// 计算属性
|
||
const totalNodes = computed(() => nodes.value.length)
|
||
const activeNodes = computed(() => nodes.value.filter(node => node.is_active).length)
|
||
const averageLoad = computed(() =>
|
||
(nodes.value.reduce((sum, node) => sum + node.current_connections, 0) / (nodes.value.length)).toFixed(2)
|
||
)
|
||
const averageUptime = computed(() => {
|
||
if (nodes.value.length === 0) return 0
|
||
const activeCount = nodes.value.filter(node => node.is_active).length
|
||
return ((activeCount / nodes.value.length) * 100).toFixed(1)
|
||
})
|
||
|
||
// 方法
|
||
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) {
|
||
loading.value = true
|
||
}
|
||
const params = {
|
||
page: pagination.page,
|
||
per_page: pagination.per_page
|
||
}
|
||
|
||
if (searchText.value) {
|
||
params.search = searchText.value
|
||
}
|
||
if (statusFilter.value !== '') {
|
||
params.is_active = statusFilter.value === 'true'
|
||
}
|
||
if (protocolFilter.value) {
|
||
params.protocol = protocolFilter.value
|
||
}
|
||
if (selectedTags.value && selectedTags.value.length > 0) {
|
||
params.tags = selectedTags.value
|
||
}
|
||
|
||
// 取消上一请求,创建新的请求控制器
|
||
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 {
|
||
if (with_loading) {
|
||
loading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const refreshData = () => {
|
||
pagination.page = 1
|
||
fetchNodes()
|
||
}
|
||
|
||
const handleSearch = () => {
|
||
pagination.page = 1
|
||
fetchNodes()
|
||
}
|
||
|
||
const handleFilter = () => {
|
||
pagination.page = 1
|
||
fetchNodes()
|
||
}
|
||
|
||
const handleSizeChange = (size) => {
|
||
pagination.per_page = size
|
||
pagination.page = 1
|
||
fetchNodes()
|
||
}
|
||
|
||
const handleCurrentChange = (page) => {
|
||
pagination.page = page
|
||
fetchNodes()
|
||
}
|
||
|
||
const viewNodeDetails = async (node) => {
|
||
selectedNode.value = node
|
||
detailDialogVisible.value = true
|
||
|
||
// 获取健康状态统计
|
||
try {
|
||
const response = await nodeApi.getNodeHealthStats(node.id, { hours: 24 })
|
||
if (response.success && response.data) {
|
||
healthStats.value = response.data
|
||
}
|
||
} catch (error) {
|
||
console.error('获取健康状态统计失败:', error)
|
||
}
|
||
}
|
||
|
||
const formatDate = (dateString) => {
|
||
return dayjs(dateString).format('YYYY-MM-DD HH:mm:ss')
|
||
}
|
||
|
||
const getProgressColor = (percentage) => {
|
||
if (percentage < 50) return '#67C23A'
|
||
if (percentage < 80) return '#E6A23C'
|
||
return '#F56C6C'
|
||
}
|
||
|
||
const copyAddress = (address) => {
|
||
try {
|
||
navigator.clipboard.writeText(address).then(() => {
|
||
ElMessage.success(`地址已复制, ${address}`)
|
||
}).catch(() => {
|
||
ElMessage.error(`复制失败, ${address}`)
|
||
})
|
||
} catch (error) {
|
||
ElMessage.error(`复制失败, ${address}`)
|
||
}
|
||
}
|
||
|
||
// 生命周期
|
||
onMounted(() => {
|
||
fetchTags()
|
||
fetchNodes()
|
||
|
||
// 设置定时刷新
|
||
setInterval(() => {
|
||
fetchNodes(false)
|
||
}, 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>
|
||
|
||
<style scoped>
|
||
.node-dashboard {
|
||
padding: 20px;
|
||
background-color: #f5f7fa;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.dashboard-header {
|
||
text-align: center;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.dashboard-header h1 {
|
||
color: #303133;
|
||
margin-bottom: 10px;
|
||
font-size: 32px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.subtitle {
|
||
color: #606266;
|
||
font-size: 16px;
|
||
margin: 0;
|
||
}
|
||
|
||
.stats-row {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.stat-card {
|
||
position: relative;
|
||
overflow: hidden;
|
||
height: 100px;
|
||
}
|
||
|
||
.stat-content {
|
||
padding: 0 16px;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.stat-number {
|
||
font-size: 24px;
|
||
font-weight: bold;
|
||
color: #303133;
|
||
line-height: 1;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin: 0;
|
||
}
|
||
|
||
.stat-icon {
|
||
position: absolute;
|
||
right: 12px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
font-size: 28px;
|
||
opacity: 0.3;
|
||
}
|
||
|
||
.filter-card {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.nodes-card {
|
||
background: white;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.node-name {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.address {
|
||
margin-left: 8px;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.connection-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.description {
|
||
color: #606266;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.text-muted {
|
||
color: #C0C4CC;
|
||
}
|
||
|
||
.pagination-wrapper {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.node-details {
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.health-stats {
|
||
margin-top: 30px;
|
||
padding-top: 20px;
|
||
border-top: 1px solid #EBEEF5;
|
||
}
|
||
|
||
.health-stats h3 {
|
||
margin-bottom: 20px;
|
||
color: #303133;
|
||
}
|
||
|
||
.health-stat-item {
|
||
text-align: center;
|
||
padding: 15px;
|
||
background: #f8f9fa;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.health-stat-item .stat-value {
|
||
font-size: 24px;
|
||
font-weight: bold;
|
||
color: #409EFF;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.health-stat-item .stat-label {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
.expanded-content {
|
||
padding: 16px 24px;
|
||
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>
|