fix: 标签栏图标修正;概览页展示数据切换;消息通知异常显示修复

This commit is contained in:
zxr
2026-07-15 12:40:38 +08:00
parent 8a6ec3ef96
commit e5df404aaa
11 changed files with 262 additions and 150 deletions

View File

@@ -9,9 +9,9 @@ VITE_APP_DESCRIPTION="default standard template"
VITE_USE_MOCK=false
# API 基础URL
# VITE_API_BASE_URL=https://ops-api.apinb.com
VITE_API_BASE_URL=https://ops-api.apinb.com
# 开发环境走 Vite 同源代理,避免依赖本机 80/443 nginx
VITE_API_BASE_URL=
# VITE_API_BASE_URL=
# Logs 本地调试地址(仅 logs 模块使用)
VITE_LOGS_API_BASE_URL=http://127.0.0.1:12440

24
dev/components.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import { GlobalComponents } from 'vue'
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}
// For TSX support
declare global {
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
}

View File

@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" type="image/x-icon" href="https://unpkg.byted-static.com/latest/byted/arco-config/assets/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智能运维管理系统</title>
</head>

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="33" height="33">
<path d="M0 240.941176a240.941176 240.941176 0 0 1 240.941176-240.941176h542.117648a240.941176 240.941176 0 0 1 240.941176 240.941176v542.117648a240.941176 240.941176 0 0 1-240.941176 240.941176H240.941176a240.941176 240.941176 0 0 1-240.941176-240.941176V240.941176z" fill="#287BF7"></path>
<path d="M340.931765 388.969412a120.621176 120.621176 0 1 0 115.260235 209.799529l70.776471 97.490824a241.091765 241.091765 0 1 1 82.522353-283.587765c15.751529 39.936 14.516706 83.546353 13.763764 110.290824-0.150588 4.879059-0.271059 9.216-0.271059 12.860235 0 22.648471 5.812706 48.519529 18.221177 66.831059 10.24 15.058824 26.654118 28.310588 61.108706 28.310588 31.804235 0 49.152-10.721882 59.331764-22.40753 11.264-12.950588 18.974118-33.370353 18.974118-59.452235 0-18.642824-9.396706-39.574588-27.166118-53.067294-16.022588-12.137412-43.550118-21.594353-86.768941-8.734118l-34.42447-115.440941c75.926588-22.618353 144.112941-9.637647 193.987764 28.190118 48.128 36.502588 74.842353 93.093647 74.842353 149.082353 0 48.489412-14.245647 99.087059-48.609882 138.541176-35.418353 40.719059-87.341176 63.728941-150.166588 63.728941-75.294118 0-128.662588-33.792-160.768-81.106823-29.936941-44.062118-39.032471-95.864471-39.032471-134.475294 0-9.396706 0.150588-17.588706 0.240941-24.847059 0.421647-25.057882 0.632471-38.972235-5.330823-54.091294a120.621176 120.621176 0 0 0-156.491294-67.915294z" fill="#FFFFFF"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -16,11 +16,6 @@
@view-history="openHistory"
/>
</a-tab-pane>
<template #extra>
<a-button type="text" @click="emptyList">
{{ $t('messageBox.tab.button') }}
</a-button>
</template>
</a-tabs>
<a-drawer v-model:visible="historyVisible" width="760px" title="通知投递历史" :footer="false" unmount-on-close @open="fetchHistory">
<a-table :columns="historyColumns" :data="historyList" :loading="historyLoading" :pagination="historyPagination" row-key="id" @page-change="onHistoryPageChange">
@@ -50,7 +45,7 @@ import {
import useLoading from '@/hooks/loading'
import useNotificationSocket from '@/hooks/useNotificationSocket'
import { Message } from '@arco-design/web-vue'
import { computed, reactive, ref, toRefs } from 'vue'
import { computed, reactive, ref, toRefs, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import List from './list.vue'
@@ -59,9 +54,13 @@ interface TabItem {
title: string
avatar?: string
}
const visibleMessageTypes = ['message']
const { loading, setLoading } = useLoading(true)
const messageType = ref('message')
const { t } = useI18n()
const emit = defineEmits<{
(event: 'unread-count-change', count: number): void
}>()
const messageData = reactive<{
renderList: MessageRecord[]
messageList: MessageRecord[]
@@ -92,14 +91,6 @@ const tabList: TabItem[] = [
key: 'message',
title: t('messageBox.tab.title.message'),
},
{
key: 'notice',
title: t('messageBox.tab.title.notice'),
},
{
key: 'todo',
title: t('messageBox.tab.title.todo'),
},
]
async function fetchSourceData() {
setLoading(true)
@@ -176,6 +167,9 @@ const getDeliveryColor = (status: string) => {
const renderList = computed(() => {
return messageData.messageList.filter((item) => messageType.value === item.type)
})
const totalUnreadCount = computed(() => {
return messageData.messageList.filter((item) => visibleMessageTypes.includes(item.type) && !item.status).length
})
const unreadCount = computed(() => {
return renderList.value.filter((item) => !item.status).length
})
@@ -190,9 +184,7 @@ const formatUnreadLength = (type: string) => {
const handleItemClick = (items: MessageListType) => {
if (renderList.value.length) readMessage([...items])
}
const emptyList = () => {
messageData.messageList = []
}
watch(totalUnreadCount, (count) => emit('unread-count-change', count), { immediate: true })
fetchSourceData()
useNotificationSocket<MessageRecord>({
onNotification: upsertMessageToTop,

View File

@@ -1,8 +1,5 @@
export default {
'messageBox.tab.title.message': 'Message',
'messageBox.tab.title.notice': 'Notice',
'messageBox.tab.title.todo': 'Todo',
'messageBox.tab.button': 'empty',
'messageBox.allRead': 'All Read',
'messageBox.viewMore': 'View More',
'messageBox.noContent': 'No Content',

View File

@@ -1,8 +1,5 @@
export default {
'messageBox.tab.title.message': '消息',
'messageBox.tab.title.notice': '通知',
'messageBox.tab.title.todo': '待办',
'messageBox.tab.button': '清空',
'messageBox.allRead': '全部已读',
'messageBox.viewMore': '查看更多',
'messageBox.noContent': '暂无内容',

View File

@@ -52,7 +52,7 @@
<li>
<a-tooltip :content="$t('settings.navbar.alerts')">
<div class="message-box-trigger">
<a-badge dot>
<a-badge dot :count="notificationUnreadCount">
<a-button class="nav-btn" type="outline" :shape="'circle'" @click="setPopoverVisible">
<icon-notification />
</a-button>
@@ -67,7 +67,7 @@
>
<div ref="refBtn" class="ref-btn"></div>
<template #content>
<message-box />
<message-box @unread-count-change="handleMessageBoxUnreadCountChange" />
</template>
</a-popover>
</li>
@@ -126,8 +126,10 @@
</template>
<script lang="ts" setup>
import { MessageRecord, queryMessageList } from '@/api/message'
import useLocale from '@/hooks/locale'
import useUser from '@/hooks/user'
import useNotificationSocket from '@/hooks/useNotificationSocket'
import { LOCALE_OPTIONS } from '@/locale'
import { useAppStore, useUserStore } from '@/store'
import { Message } from '@arco-design/web-vue'
@@ -173,8 +175,24 @@ const setVisible = () => {
}
const refBtn = ref()
const triggerBtn = ref()
const notificationUnreadCount = ref(0)
const visibleMessageTypes = ['message']
const passwordChangeVisible = ref(false)
const profileChangeVisible = ref(false)
const updateNotificationUnreadCount = (list: MessageRecord[] = []) => {
notificationUnreadCount.value = list.filter((item) => visibleMessageTypes.includes(item.type) && !item.status).length
}
const fetchNotificationUnreadCount = async () => {
try {
const { data } = await queryMessageList()
updateNotificationUnreadCount(data)
} catch (err) {
updateNotificationUnreadCount()
}
}
const handleMessageBoxUnreadCountChange = (count: number) => {
notificationUnreadCount.value = count
}
const setPopoverVisible = () => {
const event = new MouseEvent('click', {
view: window,
@@ -201,6 +219,12 @@ const setDropDownVisible = () => {
triggerBtn.value.dispatchEvent(event)
}
const toggleDrawerMenu = inject('toggleDrawerMenu') as () => void
fetchNotificationUnreadCount()
useNotificationSocket<MessageRecord>({
onNotification: fetchNotificationUnreadCount,
onReconnectAfter: fetchNotificationUnreadCount,
})
</script>
<style scoped lang="less">

View File

@@ -12,21 +12,20 @@ let isMenuLoading = false
let isMenuLoaded = false
export default function setupPermissionGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
router.beforeEach(async (to) => {
const appStore = useAppStore()
const userStore = useUserStore()
const Permission = usePermission()
const permissionsAllow = Permission.accessRouter(to)
try {
if (appStore.menuFromServer) {
// 针对来自服务端的菜单配置进行处理
// Handle routing configuration from the server
// 检查是否在白名单中
if (WHITE_LIST.find((el) => el.name === to.name)) {
next()
NProgress.done()
return
return true
}
console.log('[Permission Guard] Menu not loaded, loading...')
@@ -42,40 +41,31 @@ export default function setupPermissionGuard(router: Router) {
isMenuLoaded = true
console.log('[Permission Guard] Menu loaded, redirecting to:', to.path)
// 路由注册后需要重新导航到当前路径
next({ path: to.path, query: to.query, params: to.params, replace: true } as RouteLocationRaw)
return { path: to.path, query: to.query, params: to.params, replace: true } as RouteLocationRaw
} catch (error) {
console.error('[Permission Guard] Failed to load menu:', error)
isMenuLoading = false
next(NOT_FOUND)
return NOT_FOUND
} finally {
isMenuLoading = false
}
NProgress.done()
return
}
// 如果正在加载菜单,等待加载完成
if (isMenuLoading) {
next({ path: to.path, query: to.query, params: to.params, replace: true } as RouteLocationRaw)
NProgress.done()
return
return { path: to.path, query: to.query, params: to.params, replace: true } as RouteLocationRaw
}
// 动态路由已加载,直接放行
// 因为路由已经通过 addRoute 注册Vue Router 会自动匹配
return permissionsAllow ? true : NOT_FOUND
}
if (permissionsAllow) {
next()
} else {
next(NOT_FOUND)
}
} else {
// eslint-disable-next-line no-lonely-if
if (permissionsAllow) next()
else {
const destination = Permission.findFirstPermissionRoute(appRoutes, userStore.role) || NOT_FOUND
next(destination)
}
return true
}
return Permission.findFirstPermissionRoute(appRoutes, userStore.role) || NOT_FOUND
} finally {
NProgress.done()
}
})
}

View File

@@ -8,40 +8,39 @@ import { isLogin } from '@/utils/auth'
const whiteList = ['login', 'home']
export default function setupUserLoginInfoGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
router.beforeEach(async (to) => {
NProgress.start()
const userStore = useUserStore()
if (isLogin()) {
if (userStore.role) {
next()
return true
} else {
try {
await userStore.info()
next()
return true
} catch (error) {
await userStore.logout()
next({
return {
name: 'login',
query: {
redirect: to.name,
...to.query,
} as LocationQueryRaw,
})
}
}
}
} else {
// 在白名单中的路由不需要登录验证
if (whiteList.includes(to.name as string)) {
next()
return
return true
}
next({
return {
name: 'login',
query: {
redirect: to.name,
...to.query,
} as LocationQueryRaw,
})
}
}
})
}

View File

@@ -12,7 +12,7 @@
<a-tag v-if="card.title === '警告' || card.title === '工单' || card.title === '审核'" size="small" class="stat-tag">
待处理/总数
</a-tag>
<a-tag v-else size="small" class="stat-tag">在线/总数</a-tag>
<a-tag v-else size="small" class="stat-tag">启用/总数</a-tag>
</div>
<div class="stat-icon" :style="{ background: getIconBackground(card) }">
<component :is="getIconComponent(card.title)" :style="{ fontSize: '24px', color: getIconColor(card) }" />
@@ -41,13 +41,21 @@
</div>
<span class="title-text">告警趋势</span>
</div>
<a-select style="width: 200px" v-model="month" size="small" class="month-select" @change="handleChange">
<a-option value="2025年3月">2025年3月</a-option>
<a-option value="2025年2月">2025年2月</a-option>
<a-select style="width: 160px" v-model="month" size="small" class="month-select" @change="handleChange">
<a-option v-for="item in monthOptions" :key="item.value" :value="item.value">
{{ item.label }}
</a-option>
</a-select>
</div>
<div class="chart-container">
<v-chart v-if="!chartLoading" :option="alertChartOption" class="chart" autoresize />
<div v-if="!chartLoading && isTrendEmpty" class="chart-empty">
<a-empty>
<template #description>
<span>当前月份暂无告警趋势数据</span>
</template>
</a-empty>
</div>
<v-chart v-else-if="!chartLoading" :option="alertChartOption" class="chart" autoresize />
<div v-else class="chart-loading">
<a-spin size="large" />
<div class="loading-text">加载图表数据...</div>
@@ -131,16 +139,53 @@ import { CanvasRenderer } from 'echarts/renderers'
import { BarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, GridComponent } from 'echarts/components'
import dayjs from 'dayjs'
import { fetchAlertCount, fetchHistories } from '@/api/ops/alertHistory'
import { fetchAlertCount, fetchAlertTrend, fetchHistories } from '@/api/ops/alertHistory'
import { fetchCollectorStatistics } from '@/api/ops/dcControl'
import { fetchFeedbackTicketStatistics } from '@/api/ops/feedbackTicket'
import { fetchNetworkDeviceList } from '@/api/ops/network-device'
import { fetchReviewStats } from '@/api/kb/review'
// 注册必要的 ECharts 组件
use([CanvasRenderer, BarChart, TitleComponent, TooltipComponent, GridComponent])
const router = useRouter()
const month = ref('2025年3月')
const getCurrentMonthValue = () => dayjs().format('YYYY-MM')
const formatMonthLabel = (value: string) => dayjs(`${value}-01`).format('YYYY年M月')
const monthOptions = computed(() => {
return Array.from({ length: 6 }, (_, index) => {
const value = dayjs().subtract(index, 'month').format('YYYY-MM')
return {
label: formatMonthLabel(value),
value,
}
})
})
const getMonthRange = (value: string) => {
const start = dayjs(`${value}-01`).startOf('month')
const end = start.endOf('month')
return {
start,
end,
startTime: start.toISOString(),
endTime: end.toISOString(),
}
}
const buildMonthDays = (value: string) => {
const { start, end } = getMonthRange(value)
const days: string[] = []
let cursor = start
while (cursor.isBefore(end) || cursor.isSame(end, 'day')) {
days.push(cursor.format('YYYY-MM-DD'))
cursor = cursor.add(1, 'day')
}
return days
}
const month = ref(getCurrentMonthValue())
const loading = ref(true)
const chartLoading = ref(false)
const numberAnimated = ref([false, false, false, false, false, false])
@@ -149,7 +194,7 @@ const animatedNumbers = ref([0, 0, 0, 0, 0, 0])
// 统计数据
const statistics = reactive({
serverPc: { pending: 0, total: 0 },
cluster: { pending: 0, total: 0 },
database: { pending: 0, total: 0 },
network: { pending: 0, total: 0 },
alert: { pending: 0, total: 0 },
ticket: { pending: 0, total: 0 },
@@ -157,6 +202,21 @@ const statistics = reactive({
})
const pendingAlerts = ref<any[]>([])
const trendDates = ref<string[]>([])
const trendCounts = ref<number[]>([])
const isTrendEmpty = computed(() => trendCounts.value.length > 0 && trendCounts.value.every((count) => count === 0))
const unwrapDetails = (payload: any) => payload?.details ?? payload?.data ?? payload ?? {}
const normalizeCountPair = (source: any) => ({
pending: Number(source?.collect_enabled ?? source?.enabled ?? 0) || 0,
total: Number(source?.total ?? 0) || 0,
})
const getPageTotal = (payload: any) => {
const body = unwrapDetails(payload)
return Number(body?.total ?? payload?.total ?? 0) || 0
}
// 统计卡片数据
const statCards = computed(() => [
@@ -169,9 +229,9 @@ const statCards = computed(() => [
iconColor: '#409EFF',
},
{
title: '集群采集',
pending: statistics.cluster.pending,
total: statistics.cluster.total,
title: '数据库服务',
pending: statistics.database.pending,
total: statistics.database.total,
lightColor: 'rgba(255, 153, 0, 0.1)',
darkColor: 'rgba(255, 153, 0, 0.15)',
iconColor: '#FF9900',
@@ -236,7 +296,8 @@ const alertChartOption = computed(() => ({
const data = params[0]
const date = data.axisValue
const value = data.value
const percent = ((value / 200) * 100).toFixed(1)
const maxValue = Math.max(...trendCounts.value, 0)
const percent = maxValue > 0 ? ((value / maxValue) * 100).toFixed(1) : '0.0'
return `
<div style="margin-bottom: 4px; font-size: 12px; color: rgba(255, 255, 255, 0.7);">${date}</div>
<div style="display: flex; align-items: center; gap: 8px;">
@@ -257,7 +318,7 @@ const alertChartOption = computed(() => ({
},
xAxis: {
type: 'category',
data: ['03/01', '03/02', '03/03', '03/04', '03/05', '03/06', '03/07'],
data: trendDates.value,
axisLine: {
show: false,
},
@@ -281,7 +342,7 @@ const alertChartOption = computed(() => ({
yAxis: {
type: 'value',
min: 0,
max: 250,
max: (value: { max: number }) => Math.max(5, Math.ceil(value.max * 1.2)),
splitNumber: 5,
splitLine: {
lineStyle: {
@@ -311,7 +372,7 @@ const alertChartOption = computed(() => ({
{
name: '告警数量',
type: 'bar',
data: [120, 200, 150, 80, 60, 110, 130],
data: trendCounts.value,
barWidth: '50%',
itemStyle: {
color: {
@@ -371,31 +432,6 @@ const alertChartOption = computed(() => ({
color: 'rgba(99, 102, 241, 0.05)',
borderRadius: [12, 12, 0, 0],
},
markLine: {
silent: true,
symbol: 'none',
lineStyle: {
type: 'dashed',
color: 'rgba(245, 158, 11, 0.6)',
width: 2,
},
data: [
{
yAxis: 150,
label: {
show: true,
position: 'end',
formatter: '预警值',
fontSize: 12,
color: 'rgba(245, 158, 11, 0.8)',
fontWeight: 500,
backgroundColor: 'rgba(245, 158, 11, 0.1)',
padding: [4, 8],
borderRadius: 4,
},
},
],
},
},
],
}))
@@ -419,7 +455,7 @@ const getIconColor = (card: any) => {
const getIconComponent = (title: string) => {
const iconMap: any = {
服务器及PC: IconDesktop,
集群采集: IconCloud,
数据库服务: IconCloud,
网络设备: IconWifi,
警告: IconExclamationCircle,
工单: IconFile,
@@ -428,21 +464,47 @@ const getIconComponent = (title: string) => {
return iconMap[title] || IconDesktop
}
const normalizeTrendItems = (payload: any) => {
const body = unwrapDetails(payload)
return Array.isArray(body?.data) ? body.data : []
}
const loadAlertTrend = async (monthValue: string) => {
chartLoading.value = true
const days = buildMonthDays(monthValue)
try {
const { startTime, endTime } = getMonthRange(monthValue)
const response = await fetchAlertTrend({
granularity: 'day',
start_time: startTime,
end_time: endTime,
})
const countMap = new Map<string, number>()
normalizeTrendItems(response).forEach((item: any) => {
countMap.set(String(item.date), Number(item.count) || 0)
})
trendDates.value = days.map((day) => dayjs(day).format('MM/DD'))
trendCounts.value = days.map((day) => countMap.get(day) || 0)
} catch (error) {
console.warn('告警趋势数据加载失败:', error)
trendDates.value = days.map((day) => dayjs(day).format('MM/DD'))
trendCounts.value = days.map(() => 0)
} finally {
chartLoading.value = false
}
}
// 处理月份选择变更
const handleChange = (value: string) => {
month.value = value
chartLoading.value = true
// 模拟加载延迟
setTimeout(() => {
chartLoading.value = false
}, 500)
loadAlertTrend(value)
}
// 处理统计卡片点击
const handleCardClick = (cardTitle: string) => {
const routeMap: any = {
服务器及PC: '/ops/monitor/server',
集群采集: '/ops/monitor/collector',
数据库服务: '/ops/dc/database',
网络设备: '/ops/monitor/network',
警告: '/alert/tackle',
工单: '/ops/ticket',
@@ -489,30 +551,45 @@ const loadStatistics = async () => {
loading.value = true
// 并行请求所有统计接口和待处理告警
const [alertData, collectorData, ticketData, alertListData, reviewStatsData] = await Promise.allSettled([
const [
alertData,
collectorData,
ticketData,
alertListData,
reviewStatsData,
networkTotalData,
networkEnabledData,
] = await Promise.allSettled([
fetchAlertCount().catch((e) => ({ error: e, success: false })),
fetchCollectorStatistics().catch((e) => ({ error: e, success: false })),
fetchFeedbackTicketStatistics().catch((e) => ({ error: e, success: false })),
fetchHistories({ page: 1, page_size: 5, status: 'pending' }).catch((e) => ({ error: e, success: false })),
fetchReviewStats({ resource_type: 'all' }).catch((e) => ({ error: e, success: false })),
fetchNetworkDeviceList({ page: 1, size: 1 }).catch((e) => ({ error: e, success: false })),
fetchNetworkDeviceList({ page: 1, size: 1, enabled: true }).catch((e) => ({ error: e, success: false })),
])
// 处理服务器及PC统计数据
if (collectorData.status === 'fulfilled' && collectorData.value?.success !== false) {
statistics.serverPc = {
pending: collectorData.value?.details?.servers?.online || 0,
total: collectorData.value?.details?.servers?.total || 0,
}
statistics.cluster = {
pending: collectorData.value?.details?.collectors?.online || 0,
total: collectorData.value?.details?.collectors?.total || 0,
const resourceStats = unwrapDetails(collectorData.value)
statistics.serverPc = normalizeCountPair(resourceStats.servers)
statistics.database = normalizeCountPair(resourceStats.database_services)
} else {
console.warn('资源统计数据加载失败:', collectorData.status === 'rejected' ? collectorData.reason : collectorData.value?.error)
}
if (
networkTotalData.status === 'fulfilled' &&
networkTotalData.value?.success !== false &&
networkEnabledData.status === 'fulfilled' &&
networkEnabledData.value?.success !== false
) {
statistics.network = {
pending: collectorData.value?.details?.network_devices?.online || 0,
total: collectorData.value?.details?.network_devices?.total || 0,
pending: getPageTotal(networkEnabledData.value),
total: getPageTotal(networkTotalData.value),
}
} else {
console.warn('采集器统计数据加载失败:', collectorData.status === 'rejected' ? collectorData.reason : collectorData.value?.error)
console.warn('网络设备统计数据加载失败')
}
// 处理告警统计数据
@@ -559,7 +636,7 @@ const loadStatistics = async () => {
// 启动数字动画
const values = [
statistics.serverPc.pending,
statistics.cluster.pending,
statistics.database.pending,
statistics.network.pending,
statistics.alert.pending,
statistics.ticket.pending,
@@ -646,6 +723,7 @@ const formatDate = (date: string) => {
onMounted(() => {
loadStatistics()
loadAlertTrend(month.value)
})
</script>
@@ -874,6 +952,13 @@ onMounted(() => {
gap: 16px;
}
.chart-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loading-text {
color: var(--color-text-3);
font-size: 14px;