Files
ZDXX/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue

580 lines
26 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="dashboard-container">
<el-card shadow="never" class="main-card">
<template #header>
<div class="header-row">
<div class="left-panel">
<h2 class="sys-title">📡 光谱设备监控</h2>
<div class="sys-status">
<el-tag type="info" effect="plain" round size="small">
<el-icon><Clock /></el-icon> 更新: {{ lastCheckTime || '...' }}
</el-tag>
</div>
</div>
<div class="header-actions">
<el-button type="primary" plain icon="Plus" @click="showAddDialog = true">新增</el-button>
<el-button type="primary" plain icon="Link" @click="openIoTBinder">卡绑定</el-button>
<el-button type="info" plain icon="Document" @click="openLogCenter(null)">日志</el-button>
<el-button type="warning" plain icon="RefreshRight" :loading="runningTask" @click="runManualMonitor">检测</el-button>
<el-button circle icon="Refresh" :loading="loading" @click="fetchData" />
<div class="divider-mobile"></div>
<el-button type="danger" plain icon="SwitchButton" @click="handleLogout">退出</el-button>
</div>
</div>
</template>
<div class="status-summary">
<el-tag color="#409EFF" effect="dark" class="legend-tag"></el-tag>
<el-tag color="#F56C6C" effect="dark" class="legend-tag">离线 / 严重滞后</el-tag>
<el-tag color="#E6A23C" effect="dark" class="legend-tag">滞后 / 流量超标</el-tag>
<el-tag color="#FAC858" effect="dark" class="legend-tag" style="color: #333">数据异常 / 昨日</el-tag>
<el-tag color="#67C23A" effect="dark" class="legend-tag">正常</el-tag>
</div>
<div class="toolbar">
<div class="filter-section">
<el-radio-group v-model="filters.status" size="default">
<el-radio-button label="all">全部({{ summary.totalCount }})</el-radio-button>
<el-radio-button label="abnormal" class="red-radio">
状态异常({{ summary.errorCount + summary.warningCount }})
</el-radio-button>
<el-radio-button label="data_error" class="yellow-radio">
数据异常({{ summary.dataErrorCount }})
</el-radio-button>
<el-radio-button label="hidden" class="gray-radio">
回收({{ summary.hiddenCount }})
</el-radio-button>
</el-radio-group>
<el-input
v-model="filters.keyword"
placeholder="搜索设备名称..."
class="search-input"
prefix-icon="Search"
clearable
/>
<div class="total-usage-tag">
<el-icon><Odometer /></el-icon>
<span class="label">卡池总用量:</span>
<span class="value">{{ totalUsageSum.toFixed(2) }} M</span>
</div>
</div>
</div>
<el-table
:data="filteredData"
border
v-loading="loading"
style="width: 100%; min-width: 1250px;"
:row-class-name="tableRowClassName"
:height="tableHeight"
:default-sort="{ prop: 'sortWeight', order: 'descending' }"
>
<el-table-column label="状态" width="100" align="center" fixed="left">
<template #default="{ row }">
<el-tag v-if="row.is_hidden" color="#909399" effect="dark" style="border:none; color:#fff;">隐藏</el-tag>
<el-tag
v-else
:color="row.statusColor"
effect="dark"
style="border:none; width: 80px;"
:style="{ color: row.statusLabelColor || '#fff' }"
>
{{ row.statusLabel }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="设备名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<div
class="device-name-wrapper"
:class="{ 'clickable-row': !row.is_hidden }"
@click="handleDeviceClick(row)"
>
<span class="device-name" :class="{ 'text-deleted': row.is_hidden }">
{{ formatDisplayName(row.name) }}
</span>
<el-icon v-if="!row.is_hidden" class="link-icon"><DataLine /></el-icon>
<el-tag v-if="row.isBound" size="small" type="info" effect="plain" style="margin-left:5px; height: 18px; line-height: 16px; padding:0 4px;"></el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="安装地点" min-width="140">
<template #default="{ row }">
<div v-if="row.isEditingSite" class="editing-cell">
<el-input
v-model="row.tempSite"
size="small"
@blur="saveSite(row)"
@keyup.enter="saveSite(row)"
class="site-input-inner"
placeholder="输入后回车"
/>
</div>
<div v-else class="display-cell" @click="handleEditSite(row)">
<span>{{ row.install_site || '点击填写' }}</span>
<el-icon class="edit-icon"><EditPen /></el-icon>
</div>
</template>
</el-table-column>
<el-table-column label="本月流量" width="130" prop="trafficNum" sortable>
<template #default="{ row }">
<div v-if="row.isBound">
<span :style="{ fontWeight: '600', color: row.trafficWarning ? '#E6A23C' : '#606266' }">
{{ row.trafficNum }} M
</span>
<el-tooltip v-if="row.trafficWarning" content="流量超标 (>=500M)" placement="top">
<el-icon color="#E6A23C" style="margin-left: 4px; cursor: help;"><Warning /></el-icon>
</el-tooltip>
</div>
<span v-else style="color: #ccc;">--</span>
</template>
</el-table-column>
<el-table-column label="卡状态" width="110" align="center">
<template #default="{ row }">
<div v-if="row.isBound">
<el-tag
:type="getCardStatusType(row.statusDesc)"
effect="light"
size="small"
style="font-weight: bold;"
>
{{ row.statusDesc || '未知' }}
</el-tag>
</div>
<span v-else style="color: #ccc;">--</span>
</template>
</el-table-column>
<el-table-column label="服务截止" width="140">
<template #default="{ row }">
<div v-if="row.isBound && row.stopDate">
<span :style="{ color: row.expireWarning ? '#E6A23C' : '#606266', fontWeight: row.expireWarning ? 'bold' : 'normal' }">
{{ row.stopDate }}
</span>
<el-tooltip v-if="row.expireWarning" content="即将过期 (<30天)" placement="top">
<el-icon color="#E6A23C" style="margin-left: 4px; cursor: help;"><Warning /></el-icon>
</el-tooltip>
</div>
<span v-else style="color: #ccc;">--</span>
</template>
</el-table-column>
<el-table-column label="数据时效与质量" width="260" prop="sortWeight" sortable>
<template #default="{ row }">
<div style="font-size: 13px; display:flex; align-items:center; gap:5px; color: #606266; margin-bottom: 4px;">
<el-icon><Clock /></el-icon> {{ row.latest_time || '尚未同步' }}
</div>
<div v-if="!row.is_maintaining && !row.is_hidden">
<div v-if="row.statusType === 'error'" class="status-text error-text">
{{ row.statusReason }}
</div>
<div v-else-if="row.statusType === 'warning'" class="status-text warning-text">
{{ row.statusReason }}
</div>
<div v-else-if="row.statusType === 'slight-warning'" class="status-text slight-warning-text">
{{ row.statusReason }}
</div>
<div v-else class="status-text success-text">
状态正常
</div>
<div v-if="row.statusType !== 'error'" style="margin-top: 4px;">
<el-tag v-if="row.data_quality === 'error'" type="danger" size="small" effect="dark">
<el-icon><Warning /></el-icon> 数据严重异常
</el-tag>
<el-tag v-else-if="row.data_quality === 'warning'" type="warning" size="small" effect="dark">
<el-icon><WarningFilled /></el-icon> 数值警告
</el-tag>
<el-tag v-else-if="row.statusType !== 'warning' && row.statusType !== 'slight-warning'" type="success" size="small" effect="plain">
数值正常
</el-tag>
</div>
</div>
<div v-else-if="row.is_maintaining" class="status-text maintenance-text">🛠 维护中</div>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<div class="action-group">
<template v-if="row.is_hidden">
<el-button type="success" plain size="small" icon="RefreshLeft" @click="toggleHidden(row, false)">恢复</el-button>
</template>
<template v-else>
<el-switch
v-model="row.is_maintaining"
inline-prompt
active-text=""
inactive-text=""
style="--el-switch-on-color: #409EFF;"
:before-change="() => handleMaintenanceBeforeChange(row)"
/>
<el-button type="primary" link icon="Edit" @click="openLogCenter(row)">日志</el-button>
<el-popconfirm title="确定隐藏?" @confirm="toggleHidden(row, true)">
<template #reference>
<el-button type="danger" link icon="Delete">隐藏</el-button>
</template>
</el-popconfirm>
</template>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<DataMonitor ref="dataMonitorRef" />
<MaintenanceLogs ref="maintenanceLogsRef" />
<IoTDeviceBinder ref="iotBinderRef" @update-success="fetchData" />
<el-dialog v-model="showAddDialog" title="手动添加设备" width="400px" align-center>
<el-form :model="newDeviceForm" label-width="80px">
<el-form-item label="设备名称">
<el-input v-model="newDeviceForm.name" placeholder="请输入唯一设备名" />
</el-form-item>
<el-form-item label="安装地点">
<el-input v-model="newDeviceForm.site" placeholder="可选填" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" :loading="isAdding" @click="handleAddDeviceSubmit">
确认添加
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Warning, WarningFilled, Plus, Odometer, Link } from '@element-plus/icons-vue'
import DataMonitor from './DataMonitor.vue'
import MaintenanceLogs from './MaintenanceLogs.vue'
import IoTDeviceBinder from './IoTDeviceBinder.vue'
const router = useRouter()
const loading = ref(false)
const runningTask = ref(false)
const rawData = ref([])
const lastCheckTime = ref('')
const windowHeight = ref(window.innerHeight)
const windowWidth = ref(window.innerWidth)
const tableHeight = computed(() => {
const isMobile = windowWidth.value < 768
const offset = isMobile ? 380 : 250
return windowHeight.value - offset
})
const filters = reactive({ status: 'all', keyword: '' })
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
const dataMonitorRef = ref(null)
const maintenanceLogsRef = ref(null)
const iotBinderRef = ref(null)
const showAddDialog = ref(false)
const isAdding = ref(false)
const newDeviceForm = reactive({ name: '', site: '' })
// === 辅助函数:根据中文状态返回 Tag 颜色 ===
const getCardStatusType = (status) => {
if (status === '在使用') return 'success' // 绿色
if (status === '停机' || status === '销户') return 'danger' // 红色
if (status === '停机保号' || status === '沉默期') return 'warning' // 黄色
if (status === '测试期') return 'info' // 灰色
return 'info' // 默认
}
// === 核心数据处理逻辑 ===
const fetchData = async () => {
loading.value = true
try {
const res = await axios.get(`${API_BASE}/api/devices_overview`)
const backendList = res.data.data || res.data
const now = new Date()
rawData.value = backendList.map(item => {
const isHidden = item.is_hidden === true || item.is_hidden === 1
const isBound = !!item.isBound
const isOrphanIoT = (item.source === 'iot_card')
const isWhitelist = !!item.is_whitelist
// === 1. 智能时间解析与格式化 (增强版) ===
let diffDays = 0, diffHours = 0, isToday = false, validTime = false
let timeStr = item.latest_time
// 默认显示原始值,稍后如果解析成功则覆盖它
let displayTime = timeStr
if (timeStr && timeStr !== 'N/A') {
let d = null;
const str = timeStr.toString().trim();
// A. 尝试匹配标准格式: YYYY-MM-DD HH:mm:ss
const matchStandard = str.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/);
if (matchStandard) {
d = new Date(
parseInt(matchStandard[1]),
parseInt(matchStandard[2]) - 1,
parseInt(matchStandard[3]),
parseInt(matchStandard[4]),
parseInt(matchStandard[5]),
parseInt(matchStandard[6])
);
} else {
// B. 兜底逻辑:处理下划线或其他格式 (如 2026_01_14)
// 先把下划线全换成横杠
let cleanStr = str.replace(/_/g, '-')
// 如果长度不够(只有日期),补全时间,防止 new Date 解析成 UTC 0点导致时差
if (cleanStr.length <= 10) {
cleanStr += ' 00:00:00'
}
// 处理 T 分隔符 (ISO格式)
cleanStr = cleanStr.replace(' ', 'T')
d = new Date(cleanStr);
}
// C. 如果解析成功,强制重新生成统一的显示字符串
if (d && !isNaN(d.getTime())) {
validTime = true
isToday = d.toDateString() === now.toDateString()
const diff = now - d
diffHours = (diff > 0 ? diff : 0) / (1000 * 3600)
diffDays = diffHours / 24
// 🌟 核心修改点:生成标准显示格式 YYYY-MM-DD HH:mm:ss 🌟
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
// 这行代码保证了无论后端发什么,前端都显示得很漂亮
displayTime = `${y}-${m}-${dd} ${hh}:${mm}:${ss}`
}
}
// 2. 解析监测数值 (保留旧逻辑)
let currentValueNum = 0
if (item.current_value) {
const match = String(item.current_value).match(/(\d+(\.\d+)?)/)
if (match) currentValueNum = parseFloat(match[0])
}
// 3. 流量与过期计算
let trafficNum = 0
let rawTraffic = item.usedTraffic
if ((rawTraffic === undefined || rawTraffic === null) && item.json_data) {
try { const j = JSON.parse(item.json_data); rawTraffic = j.usedTraffic } catch(e) {}
}
if (rawTraffic) {
trafficNum = parseFloat(rawTraffic)
if (isNaN(trafficNum)) trafficNum = 0
}
// === 修改处:恢复流量超标警告判断,用于标黄 ===
const trafficWarning = (trafficNum >= 500 && !isWhitelist)
let expireWarning = false
if (item.stopDate && item.stopDate !== 'N/A') {
const stopD = new Date(item.stopDate.replace(/_/g, '-'))
if (!isNaN(stopD.getTime())) {
const daysLeft = (stopD - now) / (1000 * 3600 * 24)
if (daysLeft < 30) expireWarning = true
}
}
// 4. 状态判定
let statusColor = '#67C23A', statusLabel = '正常', statusType = 'normal', statusLabelColor = '#fff'
let statusReason = ''
let sortWeight = diffHours
if (item.is_maintaining) {
statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance';
sortWeight = Number.MAX_SAFE_INTEGER;
} else if (!validTime || item.status === 'offline') {
statusLabel = '离线'; statusColor = '#F56C6C'; statusType = 'error';
statusReason = validTime ? '设备离线' : '暂无数据';
sortWeight = 80000000;
} else if (diffDays > 7) {
statusLabel = '严重滞后'; statusColor = '#F56C6C'; statusType = 'error';
statusReason = `滞后 ${Math.floor(diffDays)}`;
} else if (diffHours > 24) {
statusLabel = '滞后'; statusColor = '#E6A23C'; statusType = 'warning';
statusReason = `滞后 ${Math.floor(diffDays)}`;
// === 注意:这里没有把 trafficWarning 加入到 sortWeight 或 statusType 的改变逻辑中 ===
// 从而实现了“只标黄文字,不改变行状态,不置顶”
} else if (expireWarning) {
statusLabel = '即将过期'; statusColor = '#E6A23C'; statusType = 'warning';
statusReason = `即将过期`;
sortWeight = 400;
} else if (!isToday) {
statusLabel = '昨日数据'; statusColor = '#FAC858'; statusType = 'slight-warning'; statusLabelColor = '#333';
statusReason = '非今日数据';
} else {
sortWeight = 0;
}
return {
...item,
latest_time: displayTime,
is_hidden: isHidden,
isOrphanIoT,
isBound,
isWhitelist,
diffDays, diffHours, sortWeight, isToday,
statusColor, statusLabel, statusType, statusLabelColor, statusReason,
isEditingSite: false, tempSite: '',
data_quality: item.data_quality || 'ok',
currentValueNum,
trafficNum,
trafficWarning,
expireWarning
}
}).sort((a, b) => b.sortWeight - a.sortWeight)
lastCheckTime.value = new Date().toLocaleString()
} catch (e) {
ElMessage.error('获取数据失败')
} finally {
loading.value = false
}
}
// === 筛选逻辑 ===
const summary = computed(() => {
const active = rawData.value.filter(r => !r.is_hidden && !r.isOrphanIoT)
return {
totalCount: active.length,
errorCount: active.filter(r => r.statusType === 'error').length,
warningCount: active.filter(r => r.statusType === 'warning').length,
hiddenCount: rawData.value.filter(r => r.is_hidden).length,
dataErrorCount: active.filter(r => r.data_quality === 'error' || r.data_quality === 'warning').length
}
})
const filteredData = computed(() => {
return rawData.value.filter(item => {
// 隐藏孤儿卡
if (item.isOrphanIoT) return false
if (filters.status === 'hidden') return item.is_hidden
if (item.is_hidden) return false
if (filters.status === 'abnormal') return ['error', 'warning', 'slight-warning'].includes(item.statusType)
if (filters.status === 'data_error') return ['error', 'warning'].includes(item.data_quality)
return true
}).filter(item => !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase()))
})
// === 卡池总用量 ===
const totalUsageSum = computed(() => {
return rawData.value.reduce((sum, item) => {
if (item.source === 'iot_card') {
return sum + (item.trafficNum || 0)
}
return sum
}, 0)
})
// === 交互函数 ===
const handleAddDeviceSubmit = async () => { if (!newDeviceForm.name) return; await axios.post(`${API_BASE}/api/add_device`, newDeviceForm); showAddDialog.value=false; fetchData() }
const handleDeviceClick = (row) => { if (!row.is_hidden && dataMonitorRef.value) dataMonitorRef.value.open(row) }
const openLogCenter = (row) => { if (maintenanceLogsRef.value) maintenanceLogsRef.value.open(row ? { deviceName: row.name } : null) }
const openIoTBinder = () => { if (iotBinderRef.value) iotBinderRef.value.open() }
const runManualMonitor = async () => { runningTask.value=true; await axios.post(`${API_BASE}/api/run_monitor`); setTimeout(()=>fetchData(), 3000); setTimeout(()=>runningTask.value=false, 1000) }
const handleEditSite = (row) => { row.tempSite = row.install_site; row.isEditingSite = true; nextTick(() => document.querySelector('.site-input-inner input')?.focus()) }
const saveSite = async (row) => { if(!row.isEditingSite)return; row.isEditingSite=false; await axios.post(`${API_BASE}/api/update_site`, {name:row.name, site:row.tempSite}); row.install_site=row.tempSite }
const handleMaintenanceBeforeChange = (row) => { return new Promise(r => { axios.post(`${API_BASE}/api/toggle_maintenance`, {name:row.name, is_maintaining:!row.is_maintaining}).then(() => {row.is_maintaining=!row.is_maintaining; fetchData(); r(true)}).catch(()=>r(false)) }) }
const toggleHidden = async (row, val) => { await axios.post(`${API_BASE}/api/toggle_hidden`, {name:row.name, is_hidden:val}); row.is_hidden=val; fetchData() }
const handleLogout = () => { localStorage.removeItem('token'); router.push('/') }
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
// 行高亮逻辑 (融合了数据异常的高亮)
const tableRowClassName = ({ row }) => {
if (row.is_hidden) return 'hidden-row'
if (row.data_quality === 'error') return 'data-error-row' // 优先显示数值严重错误
if (row.statusType === 'error') return 'error-row'
if (row.data_quality === 'warning') return 'data-warning-row' // 数值警告
if (row.statusType === 'warning') return 'warning-row'
if (row.statusType === 'maintenance') return 'maintenance-row'
return ''
}
const updateDimensions = () => { windowHeight.value = window.innerHeight; windowWidth.value = window.innerWidth }
onMounted(() => { fetchData(); window.addEventListener('resize', updateDimensions) })
onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
</script>
<style scoped>
.dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; }
.main-card { border-radius: 8px; }
.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.sys-title { font-size: 20px; font-weight: 700; color: #303133; margin: 0; }
.left-panel { display: flex; align-items: center; gap: 10px; }
.header-actions { display: flex; gap: 8px; align-items: center; }
.status-summary { margin-bottom: 15px; display: flex; gap: 5px; flex-wrap: wrap; }
.legend-tag { font-weight: bold; border: none; font-size: 12px; }
.toolbar { background: #fff; padding: 10px; border-radius: 6px; margin-bottom: 10px; border: 1px solid #e4e7ed; }
.filter-section { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.search-input { width: 220px; }
:deep(.red-radio.is-active .el-radio-button__inner) { background-color: #F56C6C; border-color: #F56C6C; box-shadow: -1px 0 0 0 #F56C6C; }
:deep(.yellow-radio.is-active .el-radio-button__inner) { background-color: #E6A23C; border-color: #E6A23C; box-shadow: -1px 0 0 0 #E6A23C; }
:deep(.gray-radio.is-active .el-radio-button__inner) { background-color: #909399; border-color: #909399; box-shadow: -1px 0 0 0 #909399; }
:deep(.red-radio .el-radio-button__inner), :deep(.yellow-radio .el-radio-button__inner), :deep(.gray-radio .el-radio-button__inner) { color: #606266; }
.total-usage-tag { display: flex; align-items: center; gap: 5px; background: #f0f9ff; border: 1px solid #cce9ff; padding: 5px 12px; border-radius: 4px; color: #409EFF; margin-left: 5px; }
.total-usage-tag .label { font-size: 13px; font-weight: bold; }
.total-usage-tag .value { font-size: 14px; font-weight: 800; }
.device-name-wrapper { display: flex; align-items: center; gap: 5px; cursor: pointer; }
.device-name { font-weight: bold; font-size: 14px; color: #303133; }
.device-name-wrapper:hover .device-name { color: #409EFF; text-decoration: underline; }
.text-deleted { text-decoration: line-through; color: #999; }
.status-text { font-size: 12px; margin-top: 4px; font-weight: bold; }
.error-text { color: #F56C6C; }
.warning-text { color: #E6A23C; }
.success-text { color: #67C23A; }
.slight-warning-text { color: #E6A23C; }
.maintenance-text { color: #409EFF; }
.display-cell { cursor: pointer; padding: 4px 0; display: flex; justify-content: space-between; }
.edit-icon { color: #409EFF; }
:deep(.error-row) { background-color: #fef0f0 !important; }
:deep(.warning-row) { background-color: #fdf6ec !important; }
:deep(.maintenance-row) { background-color: #f0f9ff !important; }
:deep(.hidden-row) { background-color: #f4f4f5 !important; color: #909399; }
/* 增加原有代码的数据异常背景色 */
:deep(.data-error-row) { background-color: #ffe6e6 !important; }
:deep(.data-warning-row) { background-color: #fffbe6 !important; }
@media screen and (max-width: 768px) {
.dashboard-container { padding: 5px; }
.left-panel, .header-actions { width: 100%; justify-content: space-between; margin-bottom: 5px; }
.header-actions .el-button { flex: 1; margin: 0 2px; }
.filter-section { justify-content: space-between; }
.el-radio-group { width: 100%; display: flex; }
.el-radio-button { flex: 1; }
.search-input { width: 100%; margin-top: 5px; }
.total-usage-tag { width: 100%; justify-content: center; margin: 5px 0 0 0; }
}
</style>