Compare commits
2 Commits
922f7dfc4b
...
e5df404aaa
| Author | SHA1 | Date | |
|---|---|---|---|
| e5df404aaa | |||
| 8a6ec3ef96 |
@@ -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
24
dev/components.d.ts
vendored
Normal 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']
|
||||
}
|
||||
@@ -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
4
public/favicon.svg
Normal 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 |
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '暂无内容',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user