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 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

@@ -34,6 +34,7 @@ export interface HostHardwareDevice {
snmp_port?: number snmp_port?: number
community?: string community?: string
snmp_version?: string snmp_version?: string
snmp_oids?: string
redfish_base_url?: string redfish_base_url?: string
redfish_tls_skip_verify?: boolean redfish_tls_skip_verify?: boolean
ipmi_timeout_seconds?: number ipmi_timeout_seconds?: number
@@ -65,6 +66,7 @@ export interface HostHardwareDeviceUpsert {
snmp_port?: number snmp_port?: number
community?: string community?: string
snmp_version?: string snmp_version?: string
snmp_oids?: string
redfish_base_url?: string redfish_base_url?: string
redfish_tls_skip_verify?: boolean redfish_tls_skip_verify?: boolean
location?: string 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"> <svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="33" height="33">
<g clip-path="url(#clip0)"> <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 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 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>
<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> </svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

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

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

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

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

View File

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

View File

@@ -2,7 +2,7 @@
<div class="login-bg"> <div class="login-bg">
<div class="container"> <div class="container">
<div class="logo"> <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 class="logo-text">智能运维管理系统</div>
</div> </div>
<LoginBanner /> <LoginBanner />
@@ -104,6 +104,8 @@ const goToHome = () => {
img { img {
height: 32px; height: 32px;
width: 32px; width: 32px;
flex-shrink: 0;
object-fit: contain;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
position: static; position: static;

View File

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