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 VITE_USE_MOCK=false
# API 基础URL # 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 同源代理,避免依赖本机 80/443 nginx
VITE_API_BASE_URL= # VITE_API_BASE_URL=
# Logs 本地调试地址(仅 logs 模块使用) # Logs 本地调试地址(仅 logs 模块使用)
VITE_LOGS_API_BASE_URL=http://127.0.0.1:12440 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"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智能运维管理系统</title> <title>智能运维管理系统</title>
</head> </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" @view-history="openHistory"
/> />
</a-tab-pane> </a-tab-pane>
<template #extra>
<a-button type="text" @click="emptyList">
{{ $t('messageBox.tab.button') }}
</a-button>
</template>
</a-tabs> </a-tabs>
<a-drawer v-model:visible="historyVisible" width="760px" title="通知投递历史" :footer="false" unmount-on-close @open="fetchHistory"> <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"> <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 useLoading from '@/hooks/loading'
import useNotificationSocket from '@/hooks/useNotificationSocket' import useNotificationSocket from '@/hooks/useNotificationSocket'
import { Message } from '@arco-design/web-vue' 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 { useI18n } from 'vue-i18n'
import List from './list.vue' import List from './list.vue'
@@ -59,9 +54,13 @@ interface TabItem {
title: string title: string
avatar?: string avatar?: string
} }
const visibleMessageTypes = ['message']
const { loading, setLoading } = useLoading(true) const { loading, setLoading } = useLoading(true)
const messageType = ref('message') const messageType = ref('message')
const { t } = useI18n() const { t } = useI18n()
const emit = defineEmits<{
(event: 'unread-count-change', count: number): void
}>()
const messageData = reactive<{ const messageData = reactive<{
renderList: MessageRecord[] renderList: MessageRecord[]
messageList: MessageRecord[] messageList: MessageRecord[]
@@ -92,14 +91,6 @@ const tabList: TabItem[] = [
key: 'message', key: 'message',
title: t('messageBox.tab.title.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() { async function fetchSourceData() {
setLoading(true) setLoading(true)
@@ -176,6 +167,9 @@ const getDeliveryColor = (status: string) => {
const renderList = computed(() => { const renderList = computed(() => {
return messageData.messageList.filter((item) => messageType.value === item.type) 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(() => { const unreadCount = computed(() => {
return renderList.value.filter((item) => !item.status).length return renderList.value.filter((item) => !item.status).length
}) })
@@ -190,9 +184,7 @@ const formatUnreadLength = (type: string) => {
const handleItemClick = (items: MessageListType) => { const handleItemClick = (items: MessageListType) => {
if (renderList.value.length) readMessage([...items]) if (renderList.value.length) readMessage([...items])
} }
const emptyList = () => { watch(totalUnreadCount, (count) => emit('unread-count-change', count), { immediate: true })
messageData.messageList = []
}
fetchSourceData() fetchSourceData()
useNotificationSocket<MessageRecord>({ useNotificationSocket<MessageRecord>({
onNotification: upsertMessageToTop, onNotification: upsertMessageToTop,

View File

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

View File

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

View File

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

View File

@@ -12,70 +12,60 @@ let isMenuLoading = false
let isMenuLoaded = false let isMenuLoaded = false
export default function setupPermissionGuard(router: Router) { export default function setupPermissionGuard(router: Router) {
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to) => {
const appStore = useAppStore() const appStore = useAppStore()
const userStore = useUserStore() const userStore = useUserStore()
const Permission = usePermission() const Permission = usePermission()
const permissionsAllow = Permission.accessRouter(to) const permissionsAllow = Permission.accessRouter(to)
if (appStore.menuFromServer) { try {
// 针对来自服务端的菜单配置进行处理 if (appStore.menuFromServer) {
// Handle routing configuration from the server // 针对来自服务端的菜单配置进行处理
// Handle routing configuration from the server
// 检查是否在白名单中 // 检查是否在白名单中
if (WHITE_LIST.find((el) => el.name === to.name)) { if (WHITE_LIST.find((el) => el.name === to.name)) {
next() return true
NProgress.done()
return
}
console.log('[Permission Guard] Menu not loaded, loading...')
// 检查动态路由是否已加载(使用标志位而非菜单长度,更可靠)
if (!isMenuLoaded && !isMenuLoading) {
console.log('[Permission Guard] Menu not loaded, loading...')
// 设置加载标志
isMenuLoading = true
try {
// 动态路由未加载,先获取菜单配置并注册路由
await appStore.fetchServerMenuConfig()
// 标记加载完成
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)
} catch (error) {
console.error('[Permission Guard] Failed to load menu:', error)
isMenuLoading = false
next(NOT_FOUND)
} finally {
isMenuLoading = false
} }
NProgress.done() console.log('[Permission Guard] Menu not loaded, loading...')
return
// 检查动态路由是否已加载(使用标志位而非菜单长度,更可靠)
if (!isMenuLoaded && !isMenuLoading) {
console.log('[Permission Guard] Menu not loaded, loading...')
// 设置加载标志
isMenuLoading = true
try {
// 动态路由未加载,先获取菜单配置并注册路由
await appStore.fetchServerMenuConfig()
// 标记加载完成
isMenuLoaded = true
console.log('[Permission Guard] Menu loaded, redirecting to:', to.path)
// 路由注册后需要重新导航到当前路径
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)
return NOT_FOUND
} finally {
isMenuLoading = false
}
}
// 如果正在加载菜单,等待加载完成
if (isMenuLoading) {
return { path: to.path, query: to.query, params: to.params, replace: true } as RouteLocationRaw
}
// 动态路由已加载,直接放行
// 因为路由已经通过 addRoute 注册Vue Router 会自动匹配
return permissionsAllow ? true : NOT_FOUND
} }
// 如果正在加载菜单,等待加载完成
if (isMenuLoading) {
next({ path: to.path, query: to.query, params: to.params, replace: true } as RouteLocationRaw)
NProgress.done()
return
}
// 动态路由已加载,直接放行
// 因为路由已经通过 addRoute 注册Vue Router 会自动匹配
if (permissionsAllow) { if (permissionsAllow) {
next() return true
} 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 Permission.findFirstPermissionRoute(appRoutes, userStore.role) || NOT_FOUND
} finally {
NProgress.done()
} }
NProgress.done()
}) })
} }

View File

@@ -8,40 +8,39 @@ import { isLogin } from '@/utils/auth'
const whiteList = ['login', 'home'] const whiteList = ['login', 'home']
export default function setupUserLoginInfoGuard(router: Router) { export default function setupUserLoginInfoGuard(router: Router) {
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to) => {
NProgress.start() NProgress.start()
const userStore = useUserStore() const userStore = useUserStore()
if (isLogin()) { if (isLogin()) {
if (userStore.role) { if (userStore.role) {
next() return true
} else { } else {
try { try {
await userStore.info() await userStore.info()
next() return true
} catch (error) { } catch (error) {
await userStore.logout() await userStore.logout()
next({ return {
name: 'login', name: 'login',
query: { query: {
redirect: to.name, redirect: to.name,
...to.query, ...to.query,
} as LocationQueryRaw, } as LocationQueryRaw,
}) }
} }
} }
} else { } else {
// 在白名单中的路由不需要登录验证 // 在白名单中的路由不需要登录验证
if (whiteList.includes(to.name as string)) { if (whiteList.includes(to.name as string)) {
next() return true
return
} }
next({ return {
name: 'login', name: 'login',
query: { query: {
redirect: to.name, redirect: to.name,
...to.query, ...to.query,
} as LocationQueryRaw, } 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 v-if="card.title === '警告' || card.title === '工单' || card.title === '审核'" size="small" class="stat-tag">
待处理/总数 待处理/总数
</a-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>
<div class="stat-icon" :style="{ background: getIconBackground(card) }"> <div class="stat-icon" :style="{ background: getIconBackground(card) }">
<component :is="getIconComponent(card.title)" :style="{ fontSize: '24px', color: getIconColor(card) }" /> <component :is="getIconComponent(card.title)" :style="{ fontSize: '24px', color: getIconColor(card) }" />
@@ -41,13 +41,21 @@
</div> </div>
<span class="title-text">告警趋势</span> <span class="title-text">告警趋势</span>
</div> </div>
<a-select style="width: 200px" v-model="month" size="small" class="month-select" @change="handleChange"> <a-select style="width: 160px" v-model="month" size="small" class="month-select" @change="handleChange">
<a-option value="2025年3月">2025年3月</a-option> <a-option v-for="item in monthOptions" :key="item.value" :value="item.value">
<a-option value="2025年2月">2025年2月</a-option> {{ item.label }}
</a-option>
</a-select> </a-select>
</div> </div>
<div class="chart-container"> <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"> <div v-else class="chart-loading">
<a-spin size="large" /> <a-spin size="large" />
<div class="loading-text">加载图表数据...</div> <div class="loading-text">加载图表数据...</div>
@@ -131,16 +139,53 @@ import { CanvasRenderer } from 'echarts/renderers'
import { BarChart } from 'echarts/charts' import { BarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, GridComponent } from 'echarts/components' import { TitleComponent, TooltipComponent, GridComponent } from 'echarts/components'
import dayjs from 'dayjs' 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 { fetchCollectorStatistics } from '@/api/ops/dcControl'
import { fetchFeedbackTicketStatistics } from '@/api/ops/feedbackTicket' import { fetchFeedbackTicketStatistics } from '@/api/ops/feedbackTicket'
import { fetchNetworkDeviceList } from '@/api/ops/network-device'
import { fetchReviewStats } from '@/api/kb/review' import { fetchReviewStats } from '@/api/kb/review'
// 注册必要的 ECharts 组件 // 注册必要的 ECharts 组件
use([CanvasRenderer, BarChart, TitleComponent, TooltipComponent, GridComponent]) use([CanvasRenderer, BarChart, TitleComponent, TooltipComponent, GridComponent])
const router = useRouter() 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 loading = ref(true)
const chartLoading = ref(false) const chartLoading = ref(false)
const numberAnimated = ref([false, false, false, false, false, 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({ const statistics = reactive({
serverPc: { pending: 0, total: 0 }, serverPc: { pending: 0, total: 0 },
cluster: { pending: 0, total: 0 }, database: { pending: 0, total: 0 },
network: { pending: 0, total: 0 }, network: { pending: 0, total: 0 },
alert: { pending: 0, total: 0 }, alert: { pending: 0, total: 0 },
ticket: { pending: 0, total: 0 }, ticket: { pending: 0, total: 0 },
@@ -157,6 +202,21 @@ const statistics = reactive({
}) })
const pendingAlerts = ref<any[]>([]) 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(() => [ const statCards = computed(() => [
@@ -169,9 +229,9 @@ const statCards = computed(() => [
iconColor: '#409EFF', iconColor: '#409EFF',
}, },
{ {
title: '集群采集', title: '数据库服务',
pending: statistics.cluster.pending, pending: statistics.database.pending,
total: statistics.cluster.total, total: statistics.database.total,
lightColor: 'rgba(255, 153, 0, 0.1)', lightColor: 'rgba(255, 153, 0, 0.1)',
darkColor: 'rgba(255, 153, 0, 0.15)', darkColor: 'rgba(255, 153, 0, 0.15)',
iconColor: '#FF9900', iconColor: '#FF9900',
@@ -236,7 +296,8 @@ const alertChartOption = computed(() => ({
const data = params[0] const data = params[0]
const date = data.axisValue const date = data.axisValue
const value = data.value 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 ` return `
<div style="margin-bottom: 4px; font-size: 12px; color: rgba(255, 255, 255, 0.7);">${date}</div> <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;"> <div style="display: flex; align-items: center; gap: 8px;">
@@ -257,7 +318,7 @@ const alertChartOption = computed(() => ({
}, },
xAxis: { xAxis: {
type: 'category', type: 'category',
data: ['03/01', '03/02', '03/03', '03/04', '03/05', '03/06', '03/07'], data: trendDates.value,
axisLine: { axisLine: {
show: false, show: false,
}, },
@@ -281,7 +342,7 @@ const alertChartOption = computed(() => ({
yAxis: { yAxis: {
type: 'value', type: 'value',
min: 0, min: 0,
max: 250, max: (value: { max: number }) => Math.max(5, Math.ceil(value.max * 1.2)),
splitNumber: 5, splitNumber: 5,
splitLine: { splitLine: {
lineStyle: { lineStyle: {
@@ -311,7 +372,7 @@ const alertChartOption = computed(() => ({
{ {
name: '告警数量', name: '告警数量',
type: 'bar', type: 'bar',
data: [120, 200, 150, 80, 60, 110, 130], data: trendCounts.value,
barWidth: '50%', barWidth: '50%',
itemStyle: { itemStyle: {
color: { color: {
@@ -371,31 +432,6 @@ const alertChartOption = computed(() => ({
color: 'rgba(99, 102, 241, 0.05)', color: 'rgba(99, 102, 241, 0.05)',
borderRadius: [12, 12, 0, 0], 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 getIconComponent = (title: string) => {
const iconMap: any = { const iconMap: any = {
服务器及PC: IconDesktop, 服务器及PC: IconDesktop,
集群采集: IconCloud, 数据库服务: IconCloud,
网络设备: IconWifi, 网络设备: IconWifi,
警告: IconExclamationCircle, 警告: IconExclamationCircle,
工单: IconFile, 工单: IconFile,
@@ -428,21 +464,47 @@ const getIconComponent = (title: string) => {
return iconMap[title] || IconDesktop 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) => { const handleChange = (value: string) => {
month.value = value month.value = value
chartLoading.value = true loadAlertTrend(value)
// 模拟加载延迟
setTimeout(() => {
chartLoading.value = false
}, 500)
} }
// 处理统计卡片点击 // 处理统计卡片点击
const handleCardClick = (cardTitle: string) => { const handleCardClick = (cardTitle: string) => {
const routeMap: any = { const routeMap: any = {
服务器及PC: '/ops/monitor/server', 服务器及PC: '/ops/monitor/server',
集群采集: '/ops/monitor/collector', 数据库服务: '/ops/dc/database',
网络设备: '/ops/monitor/network', 网络设备: '/ops/monitor/network',
警告: '/alert/tackle', 警告: '/alert/tackle',
工单: '/ops/ticket', 工单: '/ops/ticket',
@@ -489,30 +551,45 @@ const loadStatistics = async () => {
loading.value = true 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 })), fetchAlertCount().catch((e) => ({ error: e, success: false })),
fetchCollectorStatistics().catch((e) => ({ error: e, success: false })), fetchCollectorStatistics().catch((e) => ({ error: e, success: false })),
fetchFeedbackTicketStatistics().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 })), fetchHistories({ page: 1, page_size: 5, status: 'pending' }).catch((e) => ({ error: e, success: false })),
fetchReviewStats({ resource_type: 'all' }).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统计数据 // 处理服务器及PC统计数据
if (collectorData.status === 'fulfilled' && collectorData.value?.success !== false) { if (collectorData.status === 'fulfilled' && collectorData.value?.success !== false) {
statistics.serverPc = { const resourceStats = unwrapDetails(collectorData.value)
pending: collectorData.value?.details?.servers?.online || 0, statistics.serverPc = normalizeCountPair(resourceStats.servers)
total: collectorData.value?.details?.servers?.total || 0, statistics.database = normalizeCountPair(resourceStats.database_services)
} } else {
statistics.cluster = { console.warn('资源统计数据加载失败:', collectorData.status === 'rejected' ? collectorData.reason : collectorData.value?.error)
pending: collectorData.value?.details?.collectors?.online || 0, }
total: collectorData.value?.details?.collectors?.total || 0,
} if (
networkTotalData.status === 'fulfilled' &&
networkTotalData.value?.success !== false &&
networkEnabledData.status === 'fulfilled' &&
networkEnabledData.value?.success !== false
) {
statistics.network = { statistics.network = {
pending: collectorData.value?.details?.network_devices?.online || 0, pending: getPageTotal(networkEnabledData.value),
total: collectorData.value?.details?.network_devices?.total || 0, total: getPageTotal(networkTotalData.value),
} }
} else { } else {
console.warn('采集器统计数据加载失败:', collectorData.status === 'rejected' ? collectorData.reason : collectorData.value?.error) console.warn('网络设备统计数据加载失败')
} }
// 处理告警统计数据 // 处理告警统计数据
@@ -559,7 +636,7 @@ const loadStatistics = async () => {
// 启动数字动画 // 启动数字动画
const values = [ const values = [
statistics.serverPc.pending, statistics.serverPc.pending,
statistics.cluster.pending, statistics.database.pending,
statistics.network.pending, statistics.network.pending,
statistics.alert.pending, statistics.alert.pending,
statistics.ticket.pending, statistics.ticket.pending,
@@ -646,6 +723,7 @@ const formatDate = (date: string) => {
onMounted(() => { onMounted(() => {
loadStatistics() loadStatistics()
loadAlertTrend(month.value)
}) })
</script> </script>
@@ -874,6 +952,13 @@ onMounted(() => {
gap: 16px; gap: 16px;
} }
.chart-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loading-text { .loading-text {
color: var(--color-text-3); color: var(--color-text-3);
font-size: 14px; font-size: 14px;