Compare commits

..

2 Commits

19 changed files with 303 additions and 170 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

@@ -34,6 +34,7 @@ export interface HostHardwareDevice {
snmp_port?: number
community?: string
snmp_version?: string
snmp_oids?: string
redfish_base_url?: string
redfish_tls_skip_verify?: boolean
ipmi_timeout_seconds?: number
@@ -65,6 +66,7 @@ export interface HostHardwareDeviceUpsert {
snmp_port?: number
community?: string
snmp_version?: string
snmp_oids?: string
redfish_base_url?: string
redfish_tls_skip_verify?: boolean
location?: string

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,12 +1,4 @@
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
</clipPath>
</defs>
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

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

@@ -19,10 +19,7 @@
<div class="menu-wrapper">
<div class="left-side">
<a-space>
<img
alt="logo"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image"
/>
<img class="app-logo" alt="logo" src="@/assets/logo.svg?url" />
<a-typography-title>智能运维管理系统</a-typography-title>
</a-space>
</div>
@@ -183,6 +180,13 @@ onMounted(() => {
font-size: 18px;
width: 200px;
}
.app-logo {
width: 32px;
height: 32px;
flex-shrink: 0;
object-fit: contain;
}
}
&::after {

View File

@@ -12,70 +12,60 @@ 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)
if (appStore.menuFromServer) {
// 针对来自服务端的菜单配置进行处理
// Handle routing configuration from the server
try {
if (appStore.menuFromServer) {
// 针对来自服务端的菜单配置进行处理
// Handle routing configuration from the server
// 检查是否在白名单中
if (WHITE_LIST.find((el) => el.name === to.name)) {
next()
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
// 检查是否在白名单中
if (WHITE_LIST.find((el) => el.name === to.name)) {
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)
// 路由注册后需要重新导航到当前路径
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) {
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()
}
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

@@ -4,7 +4,7 @@
<header class="navbar">
<div class="navbar-content">
<div class="logo" @click="goToHome">
<img src="@/assets/logo.png" alt="Logo" />
<img src="@/assets/logo.svg?url" alt="Logo" />
<span class="logo-text">智能运维管理系统</span>
</div>
<nav class="nav-links">
@@ -699,7 +699,9 @@ defineExpose({
img {
height: 32px;
width: auto;
width: 32px;
flex-shrink: 0;
object-fit: contain;
}
.logo-text {

View File

@@ -4,7 +4,7 @@
<header class="navbar">
<div class="navbar-content">
<div class="logo">
<img src="@/assets/logo.png" alt="Logo" />
<img src="@/assets/logo.svg?url" alt="Logo" />
<span class="logo-text">智能运维管理系统</span>
</div>
<nav class="nav-links">
@@ -158,7 +158,9 @@ const scrollToSection = (id: string) => {
img {
height: 32px;
width: auto;
width: 32px;
flex-shrink: 0;
object-fit: contain;
}
.logo-text {

View File

@@ -2,7 +2,7 @@
<div class="login-bg">
<div class="container">
<div class="logo">
<img alt="logo" src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image" />
<img alt="logo" src="@/assets/logo.svg?url" />
<div class="logo-text">智能运维管理系统</div>
</div>
<LoginBanner />
@@ -104,6 +104,8 @@ const goToHome = () => {
img {
height: 32px;
width: 32px;
flex-shrink: 0;
object-fit: contain;
}
@media (max-width: 900px) {
position: static;

View File

@@ -136,6 +136,11 @@
</a-form-item>
</a-col>
</a-row>
<SnmpOidEditor
ref="snmpOidEditorRef"
v-model="form.snmp_oids"
:reset-key="snmpOidEditorResetKey"
/>
</template>
<template v-if="form.protocol === 'redfish'">
@@ -195,6 +200,7 @@ import {
type HostHardwareDevice,
type HostHardwareDeviceUpsert,
} from '@/api/ops/host-hardware'
import SnmpOidEditor from '../../components/SnmpOidEditor.vue'
interface Props {
visible: boolean
@@ -205,6 +211,8 @@ const props = defineProps<Props>()
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref<FormInstance>()
const snmpOidEditorRef = ref<InstanceType<typeof SnmpOidEditor>>()
const snmpOidEditorResetKey = ref(0)
const loading = ref(false)
const submitLoading = ref(false)
const blockedNoIdentity = ref(false)
@@ -254,6 +262,7 @@ const form = reactive({
snmp_port: 161,
community: '',
snmp_version: 'v2c',
snmp_oids: '',
redfish_base_url: '',
redfish_tls_skip_verify: false,
description: '',
@@ -283,6 +292,7 @@ function resetForm() {
form.snmp_port = 161
form.community = ''
form.snmp_version = 'v2c'
form.snmp_oids = ''
form.redfish_base_url = ''
form.redfish_tls_skip_verify = false
form.description = ''
@@ -296,6 +306,7 @@ function resetForm() {
form.redfish_collect_enabled = true
form.collect_interval = 0
form.enabled = true
snmpOidEditorResetKey.value += 1
}
function applyFromServer(r: ServerItem) {
@@ -315,6 +326,7 @@ function applyFromDevice(d: HostHardwareDevice) {
form.snmp_port = d.snmp_port ?? 161
form.community = d.community || ''
form.snmp_version = d.snmp_version || 'v2c'
form.snmp_oids = d.snmp_oids || ''
form.redfish_base_url = d.redfish_base_url || ''
form.redfish_tls_skip_verify = !!d.redfish_tls_skip_verify
form.description = d.description || ''
@@ -328,6 +340,7 @@ function applyFromDevice(d: HostHardwareDevice) {
form.redfish_collect_enabled = d.redfish_collect_enabled !== false
form.collect_interval = d.collect_interval ?? 0
form.enabled = d.enabled !== false
snmpOidEditorResetKey.value += 1
}
function validateExtraConfigJson(): boolean {
@@ -357,6 +370,7 @@ function buildPayload(): HostHardwareDeviceUpsert {
snmp_port: form.snmp_port,
community: form.community?.trim() || undefined,
snmp_version: form.snmp_version?.trim() || undefined,
snmp_oids: form.protocol === 'snmp' ? form.snmp_oids?.trim() || '[]' : '[]',
redfish_base_url: form.redfish_base_url?.trim() || undefined,
redfish_tls_skip_verify: form.redfish_tls_skip_verify,
location: location || undefined,
@@ -485,6 +499,9 @@ async function handleSubmit() {
return
}
if (!validateExtraConfigJson()) return
if (form.protocol === 'snmp' && !snmpOidEditorRef.value?.validate()) {
return
}
const r = props.record
console.log('r,', r)

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;