Compare commits

..

24 Commits

Author SHA1 Message Date
zxr
947fa2f9b9 fix: 消息展示修正 2026-07-15 13:06:09 +08:00
zxr
e5df404aaa fix: 标签栏图标修正;概览页展示数据切换;消息通知异常显示修复 2026-07-15 12:40:38 +08:00
zxr
8a6ec3ef96 fix: 更换logo及oid自定义弹窗修改 2026-07-14 10:13:20 +08:00
zxr
922f7dfc4b Merge branch 'feat/snmp-oid-table-editor' 2026-07-05 23:30:41 +08:00
zxr
ee101984f0 refactor: reuse snmp oid editor in storage form 2026-07-05 23:06:18 +08:00
zxr
3f4d50b395 refactor: reuse snmp oid editor in security form 2026-07-05 23:04:21 +08:00
zxr
58365870ef refactor: reuse snmp oid editor in device collect form 2026-07-05 23:02:09 +08:00
zxr
aa6db97457 fix: validate snmp oid json field types 2026-07-05 22:18:52 +08:00
zxr
f5d0bae89b feat: add shared snmp oid editor 2026-07-05 22:13:39 +08:00
zxr
cc6ea1acc6 fix snmp链路测试 2026-07-05 21:21:53 +08:00
zxr
7a20329594 fix: validate advanced snmp oid json rows 2026-07-05 18:35:01 +08:00
zxr
83b713890b fix: preserve advanced snmp oid json fields 2026-07-05 18:31:47 +08:00
zxr
c3f38f5e2a fix: preserve snmp oid validation row numbers 2026-07-05 18:22:25 +08:00
zxr
c6d0df6fd2 fix: normalize blank snmp oid json 2026-07-05 18:18:47 +08:00
zxr
93e8f3349f feat: validate snmp oid table rows 2026-07-05 18:15:45 +08:00
zxr
fc5d653433 feat: add snmp oid table editor ui 2026-07-05 18:07:51 +08:00
zxr
fafdb0d830 feat: initialize snmp oid editor state 2026-07-05 18:00:17 +08:00
zxr
99a5f8bd16 fix: trim parsed snmp oid rows 2026-07-05 17:54:23 +08:00
zxr
9f1c3eb660 feat: add snmp oid editor state helpers 2026-07-05 17:48:49 +08:00
zxr
1ad0323715 fix 大屏管理 2026-07-03 15:48:23 +08:00
zxr
b01a0f0979 fix 报表数据无法查看与下载 2026-07-03 11:09:16 +08:00
zxr
8f3dd3e43e 新增 业务拓扑相关 2026-06-29 21:47:46 +08:00
zxr
2107f77081 修复 : 数据为空的时候,创建按钮不显示 2026-06-29 10:42:36 +08:00
zxr
fcaad4b3ae 修复 空数据时页面组件不显示 2026-06-29 10:09:28 +08:00
52 changed files with 3276 additions and 415 deletions

View File

@@ -9,8 +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=http://127.0.0.1
VITE_API_BASE_URL=https://ops-api.apinb.com
# 开发环境走 Vite 同源代理,避免依赖本机 80/443 nginx
# VITE_API_BASE_URL=
# Logs 本地调试地址(仅 logs 模块使用)
VITE_LOGS_API_BASE_URL=http://127.0.0.1:12440

View File

@@ -1,5 +1,9 @@
# Architect Mode Rules (Non-Obvious Only)
## Development Constraints
- Code is not deployed yet; do not spend effort preserving backward compatibility with old code.
## Architecture Overview
- Vue 3 SPA with dynamic route loading from server

View File

@@ -1,5 +1,9 @@
# Code Mode Rules (Non-Obvious Only)
## Development Constraints
- Code is not deployed yet; do not spend effort preserving backward compatibility with old code.
## API Layer
- Two axios instances exist: [`request.ts`](src/api/request.ts) (custom with workspace header) and [`interceptor.ts`](src/api/interceptor.ts) (global with Bearer token). Choose based on whether you need workspace support.

View File

@@ -13,6 +13,10 @@ This file provides guidance to agents when working with code in this repository.
## Critical Architecture Notes
### Development Constraints
- Code is not deployed yet; do not spend effort preserving backward compatibility with old code.
### Vite Config Location
Config files are in `config/` directory, NOT root. All vite commands reference `./config/vite.config.*.ts`.

View File

@@ -0,0 +1,47 @@
# Front - OPS 前端
`front` 是 OPS 平台的统一前端入口,基于 Vue 3、Vite、Arco Design Vue、Pinia 和 Vue Router。页面以动态菜单为主RBAC 菜单接口不可用时使用本地菜单数据兜底。
## 当前代码入口
| 类型 | 路径 |
| --- | --- |
| 应用入口 | `front/src/main.ts` |
| 路由入口 | `front/src/router/index.ts` |
| 静态路由 | `front/src/router/routes/modules/` |
| 动态菜单转换 | `front/src/router/menu-data.ts` |
| 本地菜单兜底 | `front/src/router/local-menu-items.ts``front/src/router/local-menu-flat.ts` |
| 页面目录 | `front/src/views/` |
| API 模块 | `front/src/api/module/` |
| 构建配置 | `front/config/` |
## 主要页面域
| 页面域 | 目录 |
| --- | --- |
| 首页与概览 | `front/src/views/home``front/src/views/ops/pages/overview` |
| 数据中心与资源 | `front/src/views/ops/pages/dc``datacenter``resource-context` |
| 告警与治理 | `front/src/views/ops/pages/alert``governance` |
| 工单闭环 | `front/src/views/ops/pages/feedback` |
| 知识库 | `front/src/views/ops/pages/kb` |
| 报表与自动化 | `front/src/views/ops/pages/report``automation` |
| 业务系统与拓扑 | `front/src/views/ops/pages/business-system``business-topology` |
| 大屏 | `front/src/views/ops/pages/big-screen``front/src/views/visualization` |
| 日志管理 | `front/src/views/ops/pages/log-mgmt` |
| 系统设置 | `front/src/views/ops/pages/system-settings` |
## 常用命令
```powershell
cd D:\work\ops\front
pnpm.cmd install
pnpm.cmd dev
pnpm.cmd build
```
## 相关文档
- `front/PAGES_README.md`:页面清单和路由说明。
- `front/QUICK_START.md`:本地启动说明。
- `front/docs/`:页面设计、适配验收和部分接口对接文档。
- `docs/模块文档索引.md`:全工作区模块边界与文档入口。

View File

@@ -2,6 +2,11 @@ import { mergeConfig } from 'vite'
// import eslint from 'vite-plugin-eslint'
import baseConfig from './vite.config.base'
const proxyTarget = (port: number) => ({
target: `http://127.0.0.1:${port}`,
changeOrigin: true,
})
export default mergeConfig(
{
mode: 'development',
@@ -11,6 +16,31 @@ export default mergeConfig(
fs: {
strict: true,
},
proxy: {
'/rbac2': proxyTarget(10001),
'/Alert': proxyTarget(12427),
'/alert': proxyTarget(12427),
'/DC-Control': proxyTarget(3031),
'/dc-control': proxyTarget(3031),
'/dc-network': proxyTarget(12429),
'/DC-Hardware': proxyTarget(12450),
'/dc-hardware': proxyTarget(12450),
'/dc-host': proxyTarget(9030),
'/dc-middleware': proxyTarget(12428),
'/dc-database': proxyTarget(12580),
'/Feedback': proxyTarget(12432),
'/feedback': proxyTarget(12432),
'/Assets': proxyTarget(12430),
'/assets': proxyTarget(12430),
'/Logs': proxyTarget(12440),
'/logs': proxyTarget(12440),
'/Kb': proxyTarget(12434),
'/kb': proxyTarget(12434),
'/Mgt': proxyTarget(12436),
'/mgt': proxyTarget(12436),
'/Visual': proxyTarget(12438),
'/visual': proxyTarget(12438),
},
},
plugins: [
// eslint({

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>

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
allowBuilds:
esbuild: true
less: true
unrs-resolver: true
vue-demi: true

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

@@ -6,7 +6,7 @@ const FtsUpload = (data: FormData, onUploadProgress?: (progress: number) => void
data.append('provider', 'local')
data.append('bucket', 'visual')
return request.post(`/fts/v1/uploader`, data, {
return request.post(`/Assets/v1/fts/uploader`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},

View File

@@ -71,6 +71,27 @@ export interface BusinessResourceMember {
description?: string
}
/** 业务拓扑资源 */
export interface BusinessTopologyResource {
id: number
resource_uid: string
resource_category: string
service_identity: string
display_name: string
description?: string
status: string
business_system_id?: number
owner_user_id?: number
department_id?: number
asset_id?: number
tags?: string
source_service?: string
source_table?: string
source_id?: number
created_at?: string
updated_at?: string
}
/** 业务拓扑节点 */
export interface BusinessTopologyNode {
id?: number
@@ -102,10 +123,10 @@ export interface BusinessDependency {
/** 业务拓扑 */
export interface BusinessTopology {
business_system_id: number
resources: unknown[]
dependencies: BusinessDependency[]
nodes: BusinessTopologyNode[]
links: BusinessTopologyEdge[]
resources: BusinessTopologyResource[] | null
dependencies: BusinessDependency[] | null
nodes: BusinessTopologyNode[] | null
links: BusinessTopologyEdge[] | null
}
/** 业务时间线项 */
@@ -146,7 +167,7 @@ export interface BusinessDocumentLink {
/** 列表响应 */
export interface ListResult<T> {
list: T[]
list: T[] | null
count: number
}
@@ -166,6 +187,21 @@ export const fetchBusinessHealth = (id: number) =>
export const fetchBusinessMembers = (id: number) =>
request.get<{ code?: number; details?: ListResult<BusinessResourceMember>; message?: string }>(`/DC-Control/v1/business-systems/${id}/resources`)
export interface BusinessResourceMemberPayload {
resource_uid?: string
resource_id?: number
role?: string
criticality?: string
weight?: number
description?: string
}
export const createBusinessMember = (id: number, data: BusinessResourceMemberPayload) =>
request.post<{ code?: number; details?: BusinessResourceMember; message?: string }>(`/DC-Control/v1/business-systems/${id}/resources`, data)
export const deleteBusinessMember = (id: number, memberId: number) =>
request.delete<{ code?: number; details?: { message: string }; message?: string }>(`/DC-Control/v1/business-systems/${id}/resources/${memberId}`)
/** 获取业务拓扑 */
export const fetchBusinessTopology = (id: number) =>
request.get<{ code?: number; details?: BusinessTopology; message?: string }>(`/DC-Control/v1/business-systems/${id}/topology`)

View File

@@ -80,6 +80,25 @@ export interface ControlResource {
updated_at?: string
}
export interface ControlResourcePayload {
resource_category?: string
service_identity?: string
display_name?: string
description?: string
status?: string
business_system_id?: number | null
owner_user_id?: number | null
department_id?: number | null
asset_id?: number | null
datacenter_id?: number | null
room_id?: number | null
rack_id?: number | null
unit_start?: number | null
unit_end?: number | null
location_ref?: string | null
tags?: string
}
export interface ControlResourceType {
id: number
resource_category: string
@@ -165,10 +184,10 @@ export const fetchControlResources = (params?: ResourceListParams) =>
export const fetchControlResourceOptions = (params?: { resource_category?: string }) =>
request.get<{ code?: number; details?: { list: OptionItem[]; count: number }; message?: string }>('/DC-Control/v1/resources/options', { params })
export const createControlResource = (data: Partial<ControlResource>) =>
export const createControlResource = (data: ControlResourcePayload) =>
request.post<{ code?: number; details?: ControlResource; message?: string }>('/DC-Control/v1/resources', data)
export const updateControlResource = (id: number, data: Partial<ControlResource>) =>
export const updateControlResource = (id: number, data: ControlResourcePayload) =>
request.put<{ code?: number; details?: ControlResource; message?: string }>(`/DC-Control/v1/resources/${id}`, data)
export const deleteControlResource = (id: number) =>

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

@@ -162,6 +162,13 @@ export const fetchServerMetricsSummary = (serverIdentity: string) => {
})
}
/** 立即拉取一次主机指标并返回最新统计卡片 */
export const collectServerMetricsNow = (serverIdentity: string) => {
return request.post<{ code: number; details?: HostMetricsSummary; message?: string }>('/DC-Control/v1/servers/metrics/collect', {
server_identity: serverIdentity,
})
}
/** 近 N 小时网络收/发速率Mbps相邻采样字节差分 */
export const fetchServerNetworkTraffic = (serverIdentity: string, hours = 6) => {
return request.get<{ code: number; details?: HostNetworkTrafficPayload; message?: 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

@@ -51,27 +51,37 @@
</a-col>
</a-row>
<!-- 表格 -->
<a-table
row-key="id"
:loading="loading"
:pagination="pagination"
:columns="cloneColumns"
:data="data"
:bordered="bordered"
:size="size"
:row-selection="rowSelection"
:scroll="scroll"
@page-change="onPageChange"
@page-size-change="onPageSizeChange"
@selection-change="onSelectionChange"
@row-click="onRowClick"
<!-- 表格主体状态占位与表格分离工具栏始终展示 -->
<PageState
:status="status"
:loading-text="loadingText"
:empty-text="emptyText"
:error-text="errorText"
:partial-text="partialText"
@retry="emit('retry')"
>
<!-- 动态插槽根据 columns slotName 动态渲染 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
<slot :name="col.slotName" v-bind="slotProps" />
</template>
</a-table>
<a-table
v-if="status === 'success' || status === 'partial'"
row-key="id"
:loading="loading"
:pagination="pagination"
:columns="cloneColumns"
:data="data"
:bordered="bordered"
:size="size"
:row-selection="rowSelection"
:scroll="scroll"
@page-change="onPageChange"
@page-size-change="onPageSizeChange"
@selection-change="onSelectionChange"
@row-click="onRowClick"
>
<!-- 动态插槽根据 columns slotName 动态渲染 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
<slot :name="col.slotName" v-bind="slotProps" />
</template>
</a-table>
</PageState>
</div>
</template>
@@ -140,6 +150,26 @@ const props = defineProps({
type: Boolean,
default: true,
},
status: {
type: String as PropType<'loading' | 'empty' | 'error' | 'success' | 'partial' | 'unauthorized'>,
default: 'success',
},
loadingText: {
type: String,
default: '加载中',
},
emptyText: {
type: String,
default: '暂无数据',
},
errorText: {
type: String,
default: '数据暂时不可用,请稍后重试。',
},
partialText: {
type: String,
default: '部分数据加载失败,当前页面展示可用数据。',
},
downloadButtonText: {
type: String,
default: '下载',
@@ -167,6 +197,7 @@ const emit = defineEmits<{
(e: 'download'): void
(e: 'density-change', size: SizeProps): void
(e: 'column-change', columns: TableColumnData[]): void
(e: 'retry'): void
}>()
const size = ref<SizeProps>('medium')

View File

@@ -1,11 +1,11 @@
<template>
<a-spin style="display: block" :loading="loading">
<a-spin class="message-box" :class="{ 'is-empty': !renderList.length }" style="display: block" :loading="loading">
<a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide>
<a-tab-pane v-for="item in tabList" :key="item.key">
<template #title>
<span>{{ item.title }}{{ formatUnreadLength(item.key) }}</span>
</template>
<a-result v-if="!renderList.length" status="404">
<a-result v-if="!renderList.length" class="message-empty" status="404">
<template #subtitle>{{ $t('messageBox.noContent') }}</template>
</a-result>
<List
@@ -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">
@@ -48,8 +43,9 @@ import {
setMessageStatus,
} from '@/api/message'
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'
@@ -58,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[]
@@ -91,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)
@@ -111,6 +103,9 @@ async function fetchSourceData() {
setLoading(false)
}
}
const upsertMessageToTop = (message: MessageRecord) => {
messageData.messageList = [message, ...messageData.messageList.filter((item) => item.id !== message.id)]
}
async function readMessage(data: MessageListType) {
const ids = data.map((item) => item.id)
await setMessageStatus({ ids })
@@ -172,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
})
@@ -186,13 +184,41 @@ 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,
onReconnectAfter: fetchSourceData,
})
</script>
<style scoped lang="less">
.message-box {
width: 400px;
overflow: hidden;
:deep(.arco-spin-children) {
overflow: hidden;
}
:deep(.arco-tabs-content) {
max-height: 360px;
overflow-x: hidden;
overflow-y: auto;
}
&.is-empty {
:deep(.arco-tabs-content) {
max-height: none;
overflow: hidden;
}
:deep(.message-empty) {
padding: 34px 0 22px;
}
}
}
:deep(.arco-popover-popup-content) {
padding: 0;
}

View File

@@ -54,7 +54,7 @@
</div>
</a-list-item>
<template #footer>
<a-space fill :size="0" :class="{ 'add-border-top': renderList.length < showMax }">
<a-space fill :size="0" class="message-footer-actions" :class="{ 'add-border-top': renderList.length < showMax }">
<div class="footer-wrap">
<a-link @click="allRead">{{ $t('messageBox.allRead') }}</a-link>
</div>
@@ -147,10 +147,20 @@ const showMax = 3
.arco-list-footer {
padding: 0;
height: 50px;
line-height: 50px;
line-height: normal;
border-top: none;
.arco-space-item {
.message-footer-actions {
display: flex;
width: 100%;
height: 50px;
}
.arco-space-item {
display: flex;
flex: 1;
width: 50%;
height: 50px;
border-right: 1px solid rgb(var(--gray-3));
&:last-child {
border-right: none;
@@ -161,7 +171,20 @@ const showMax = 3
}
}
.footer-wrap {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 50px;
:deep(.arco-link) {
line-height: 1;
}
:deep(.arco-link:focus-visible) {
background-color: transparent;
box-shadow: none;
}
}
.arco-typography {
margin-bottom: 0;

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

@@ -49,10 +49,10 @@
</a-button>
</a-tooltip>
</li>
<!-- <li>
<li>
<a-tooltip :content="$t('settings.navbar.alerts')">
<div class="message-box-trigger">
<a-badge :count="9" dot>
<a-badge dot :count="notificationUnreadCount">
<a-button class="nav-btn" type="outline" :shape="'circle'" @click="setPopoverVisible">
<icon-notification />
</a-button>
@@ -62,15 +62,15 @@
<a-popover
trigger="click"
:arrow-style="{ display: 'none' }"
:content-style="{ padding: 0, minWidth: '400px' }"
:content-style="{ padding: 0, minWidth: '400px', overflow: 'hidden' }"
content-class="message-popover"
>
<div ref="refBtn" class="ref-btn"></div>
<template #content>
<message-box />
<message-box @unread-count-change="handleMessageBoxUnreadCountChange" />
</template>
</a-popover>
</li> -->
</li>
<li>
<a-tooltip :content="isFullscreen ? t('settings.navbar.screen.toExit') : t('settings.navbar.screen.toFull')">
<a-button class="nav-btn" type="outline" :shape="'circle'" @click="toggleFullScreen">
@@ -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,54 +19,50 @@
<a-divider style="margin-top: 0" />
<!-- 数据表格 -->
<PageState
<!-- 数据表格工具栏始终展示表格主体随状态切换 -->
<DataTable
:data="data"
:columns="columns"
:loading="loading"
:pagination="pagination"
:bordered="bordered"
:row-selection="rowSelection"
:scroll="responsiveScroll"
:show-toolbar="showToolbar"
:show-download="showDownload"
:show-refresh="showRefresh"
:show-density="showDensity"
:show-column-setting="showColumnSetting"
:download-button-text="downloadButtonText"
:refresh-tooltip-text="refreshTooltipText"
:density-tooltip-text="densityTooltipText"
:column-setting-tooltip-text="columnSettingTooltipText"
:status="stateStatus"
:loading-text="loadingText"
:empty-text="emptyText"
:error-text="errorText"
:partial-text="partialText"
:loading-text="loadingText"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
@refresh="handleRefresh"
@download="handleDownload"
@density-change="handleDensityChange"
@column-change="handleColumnChange"
@retry="handleRefresh"
>
<DataTable
v-if="stateStatus !== 'empty' && stateStatus !== 'error' && stateStatus !== 'unauthorized'"
:data="data"
:columns="columns"
:loading="loading"
:pagination="pagination"
:bordered="bordered"
:row-selection="rowSelection"
:scroll="responsiveScroll"
:show-toolbar="showToolbar"
:show-download="showDownload"
:show-refresh="showRefresh"
:show-density="showDensity"
:show-column-setting="showColumnSetting"
:download-button-text="downloadButtonText"
:refresh-tooltip-text="refreshTooltipText"
:density-tooltip-text="densityTooltipText"
:column-setting-tooltip-text="columnSettingTooltipText"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
@refresh="handleRefresh"
@download="handleDownload"
@density-change="handleDensityChange"
@column-change="handleColumnChange"
>
<template #toolbar-left>
<slot name="toolbar-left" />
</template>
<template #toolbar-right>
<slot name="toolbar-right" />
</template>
<!-- 动态插槽透传 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
<slot :name="col.slotName" v-bind="slotProps" />
</template>
</DataTable>
</PageState>
<template #toolbar-left>
<slot name="toolbar-left" />
</template>
<template #toolbar-right>
<slot name="toolbar-right" />
</template>
<!-- 动态插槽透传 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
<slot :name="col.slotName" v-bind="slotProps" />
</template>
</DataTable>
</a-card>
</div>
</template>

View File

@@ -0,0 +1,126 @@
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
import { onBeforeUnmount, onMounted } from 'vue'
interface NotificationSocketMessage<T> {
type: string
data?: T
}
interface UseNotificationSocketOptions<T> {
onNotification: (data: T) => void
onReconnectAfter?: () => void | Promise<void>
}
const MAX_RECONNECT_ATTEMPTS = 8
const MAX_RECONNECT_DELAY = 30_000
const RECONNECT_STEP_DELAY = 1_000
function buildNotificationSocketUrl() {
const token = SafeStorage.get<string>(AppStorageKey.TOKEN)
if (!token) return null
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin
const url = new URL(baseUrl, window.location.origin)
const basePath = url.pathname.replace(/\/$/, '')
url.pathname = `${basePath}/Alert/v1/message/ws`
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
// WebSocket 无法直接设置 Authorization header如后端仍依赖 header 认证,
// 需要后端适配 query token 或签发一次性 ws ticket。
url.searchParams.set('token', token)
return url.toString()
}
export default function useNotificationSocket<T>(options: UseNotificationSocketOptions<T>) {
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectAttempt = 0
let isManualClose = false
let isReconnecting = false
const clearReconnectTimer = () => {
if (!reconnectTimer) return
clearTimeout(reconnectTimer)
reconnectTimer = null
}
const closeSocket = () => {
if (!socket) return
socket.onopen = null
socket.onmessage = null
socket.onerror = null
socket.onclose = null
socket.close()
socket = null
}
const scheduleReconnect = () => {
if (isManualClose || reconnectTimer || reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) return
reconnectAttempt += 1
isReconnecting = true
const delay = Math.min(reconnectAttempt * RECONNECT_STEP_DELAY, MAX_RECONNECT_DELAY)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
if (isManualClose) return
connect()
}, delay)
}
const handleMessage = (event: MessageEvent<string>) => {
try {
const message = JSON.parse(event.data) as NotificationSocketMessage<T>
if (message.type === 'notification' && message.data) {
options.onNotification(message.data)
}
} catch (error) {
// 忽略无法识别的消息,避免单条异常数据中断后续通知接收。
}
}
function connect() {
if (isManualClose) return
const socketUrl = buildNotificationSocketUrl()
if (!socketUrl) return
closeSocket()
socket = new WebSocket(socketUrl)
socket.onopen = () => {
reconnectAttempt = 0
if (isReconnecting) {
isReconnecting = false
options.onReconnectAfter?.()
}
}
socket.onmessage = handleMessage
socket.onerror = () => {
socket?.close()
}
socket.onclose = () => {
socket = null
scheduleReconnect()
}
}
const cleanup = () => {
isManualClose = true
clearReconnectTimer()
closeSocket()
}
onMounted(() => {
isManualClose = false
connect()
})
onBeforeUnmount(cleanup)
return {
cleanup,
}
}

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

@@ -29,6 +29,7 @@ export default {
'menu.ops.netarch': 'Network Architecture',
'menu.ops.netarch.topoGroup': 'Topology Group Management',
'menu.ops.netarch.topo': 'Topology',
'menu.ops.businessTopology': 'Business Topology',
'menu.ops.systemSettings': 'System Settings',
'menu.ops.systemSettings.menuManagement': 'Menu Management',
'menu.ops.systemSettings.systemLogs': 'System Logs',

View File

@@ -29,6 +29,7 @@ export default {
'menu.ops.netarch': '网络架构',
'menu.ops.netarch.topoGroup': '拓扑组管理',
'menu.ops.netarch.topo': '拓扑图',
'menu.ops.businessTopology': '业务拓扑',
'menu.ops.systemSettings': '系统设置',
'menu.ops.systemSettings.menuManagement': '菜单管理',
'menu.ops.systemSettings.systemLogs': '系统日志',

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

@@ -45,7 +45,7 @@ export const localMenuFlatItems: MenuItem[] = [
type: 1,
sort_key: 3,
is_web_page: true,
web_url: 'https://ops.apinb.com/view/#/project/items',
web_url: 'https://ops2.apinb.com/view/#/project/items',
created_at: '2025-12-26T13:23:51.644296+08:00',
},
{
@@ -60,7 +60,7 @@ export const localMenuFlatItems: MenuItem[] = [
type: 1,
sort_key: 4,
is_web_page: true,
web_url: 'https://ops.apinb.com/view/#/project/management',
web_url: 'https://ops2.apinb.com/view/#/project/management',
created_at: '2026-01-25T10:44:15.33024+08:00',
},
{
@@ -108,6 +108,22 @@ export const localMenuFlatItems: MenuItem[] = [
hide_menu: true,
created_at: '2026-06-25T10:00:00+08:00',
},
{
id: 12063,
identity: '019ca000-0001-7000-8000-000000000063',
title: '业务拓扑',
title_en: 'Business Topology',
code: 'ops:业务系统视图:业务拓扑',
description: '业务依赖、资源链路、影响范围和时间线拓扑',
app_id: 2,
parent_id: 12060,
menu_path: '/business-topology',
component: 'ops/pages/business-topology',
menu_icon: 'Cluster',
type: 1,
sort_key: 4.5,
created_at: '2026-06-29T10:00:00+08:00',
},
{
id: 16,
identity: '019b591d-00f4-73a0-bbdb-aa7da79ed390',

View File

@@ -78,7 +78,7 @@ export const localMenuItems: MenuItem[] = [
type: 1,
sort_key: 2,
is_web_page: true,
web_url: 'https://ops.apinb.com/view/#/project/items',
web_url: 'https://ops2.apinb.com/view/#/project/items',
created_at: '2025-12-26T13:23:51.644296+08:00',
children: [],
},
@@ -94,7 +94,7 @@ export const localMenuItems: MenuItem[] = [
type: 1,
sort_key: 2,
is_web_page: true,
web_url: 'https://ops.apinb.com/view/#/project/management',
web_url: 'https://ops2.apinb.com/view/#/project/management',
created_at: '2026-01-25T10:44:15.33024+08:00',
children: [],
},
@@ -130,6 +130,23 @@ export const localMenuItems: MenuItem[] = [
created_at: '2026-06-25T10:00:00+08:00',
children: [],
},
{
id: 12063,
identity: '019ca000-0001-7000-8000-000000000063',
title: '业务拓扑',
title_en: 'Business Topology',
code: 'ops:业务系统视图:业务拓扑',
description: '业务依赖、资源链路、影响范围和时间线拓扑',
app_id: 2,
parent_id: 12060,
menu_path: '/business-topology',
component: 'ops/pages/business-topology',
menu_icon: 'Cluster',
type: 1,
sort_key: 3,
created_at: '2026-06-29T10:00:00+08:00',
children: [],
},
{
id: 12062,
identity: '019ca000-0001-7000-8000-000000000062',

View File

@@ -68,6 +68,17 @@ const OPS: AppRouteRecordRaw = {
hideInMenu: true,
},
},
{
path: 'business-topology',
alias: ['/business-topology'],
name: 'OpsBusinessTopology',
component: () => import('@/views/ops/pages/business-topology/index.vue'),
meta: {
locale: 'menu.ops.businessTopology',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'big-screen',
name: 'OpsBigScreen',

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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,418 @@
import type {
BusinessDependency,
BusinessHealth,
BusinessSystemItem,
BusinessTopology,
BusinessTopologyEdge,
BusinessTopologyNode,
} from '@/api/ops/businessSystem'
import type {
BusinessTopologyFlowEdge,
BusinessTopologyFlowNode,
BusinessTopologyGraph,
BusinessTopologyHealthFilter,
BusinessTopologyViewMode,
} from './types'
const HEALTHY_THRESHOLD = 90
const DEGRADED_THRESHOLD = 60
export function scoreValue(score?: number): number {
if (typeof score !== 'number' || Number.isNaN(score)) {
return 0
}
return Math.max(0, Math.min(100, Math.round(score)))
}
export function healthLevelFromScore(score?: number): 'healthy' | 'degraded' | 'critical' {
const value = scoreValue(score)
if (value >= HEALTHY_THRESHOLD) {
return 'healthy'
}
if (value >= DEGRADED_THRESHOLD) {
return 'degraded'
}
return 'critical'
}
export function statusColor(status?: string): string {
const normalized = status?.trim().toLowerCase()
if (!normalized) {
return '#8c8c8c'
}
if (['healthy', 'normal', 'ok', 'online', 'active', 'running', 'enabled'].includes(normalized)) {
return '#52c41a'
}
if (['degraded', 'warning', 'warn', 'pending', 'maintenance', 'medium'].includes(normalized)) {
return '#faad14'
}
if (['critical', 'error', 'failed', 'failure', 'offline', 'inactive', 'disabled', 'high'].includes(normalized)) {
return '#ff4d4f'
}
return '#1677ff'
}
export function healthFilterMatch(item: BusinessSystemItem, filter: BusinessTopologyHealthFilter): boolean {
if (filter === 'all') {
return true
}
const level = normalizedHealthLevel(item)
if (filter === 'unknown') {
return level === 'unknown'
}
return level === filter
}
export function buildBusinessTopologyGraph(input: {
currentBusiness: BusinessSystemItem
topology: BusinessTopology
health?: BusinessHealth | null
mode: BusinessTopologyViewMode
keyword?: string
}): BusinessTopologyGraph {
const { currentBusiness, topology, health, mode } = input
const keyword = input.keyword?.trim().toLowerCase()
const currentBusinessId = businessNodeId(currentBusiness.id)
const includeResourceTopology = mode === 'resource'
const emphasizeDependencyEdges = mode === 'dependency'
const dependencies = topology.dependencies ?? []
const topologyNodes = topology.nodes ?? []
const topologyLinks = topology.links ?? []
const nodes: BusinessTopologyFlowNode[] = []
const edges: BusinessTopologyFlowEdge[] = []
const businessNodeIds = new Set<string>()
const resourceNodeIds = new Map<string, string>()
const resourceFlowNodeIds = new Set<string>()
const resourceContainmentEdgeIds = new Set<string>()
const matchedNodeIds = new Set<string>()
const addBusinessNode = (node: BusinessTopologyFlowNode) => {
if (businessNodeIds.has(node.id)) {
return
}
businessNodeIds.add(node.id)
nodes.push(node)
if (keyword && nodeMatchesKeyword(node, keyword)) {
matchedNodeIds.add(node.id)
}
}
addBusinessNode(createCurrentBusinessNode(currentBusiness, health))
dependencies.forEach((dependency, index) => {
const sourceBusinessId = dependency.business_system_id
const targetBusinessId = dependency.depends_on_business_system_id
const externalIds = [sourceBusinessId, targetBusinessId].filter((id) => id !== currentBusiness.id)
externalIds.forEach((businessId, externalIndex) => {
addBusinessNode(createDependencyBusinessNode(businessId, dependency, index, externalIndex))
})
const edge: BusinessTopologyFlowEdge = {
id: `business-dependency:${dependency.id}:${sourceBusinessId}->${targetBusinessId}`,
source: businessNodeId(sourceBusinessId),
target: businessNodeId(targetBusinessId),
animated: false,
data: {
kind: 'business_dependency',
label: dependency.dependency_type || '依赖',
status: dependency.criticality,
dependency,
},
label: dependency.dependency_type || '依赖',
...(emphasizeDependencyEdges ? { style: dependencyEdgeStyle(dependency) } : {}),
}
edge.animated = shouldAnimateEdge(edge, matchedNodeIds, keyword) || (emphasizeDependencyEdges && !keyword)
edges.push(edge)
})
if (includeResourceTopology) {
topologyNodes.forEach((topologyNode, index) => {
const key = resourceKey(topologyNode)
if (!key) {
return
}
const id = resourceNodeId(key)
rememberResourceNodeIds(resourceNodeIds, topologyNode, id)
if (resourceFlowNodeIds.has(id)) {
return
}
resourceFlowNodeIds.add(id)
const node = createResourceNode(topologyNode, index)
nodes.push(node)
if (keyword && nodeMatchesKeyword(node, keyword)) {
matchedNodeIds.add(node.id)
}
})
topologyNodes.forEach((topologyNode) => {
const target = resourceNodeIds.get(resourceKey(topologyNode))
if (!target) {
return
}
const edgeId = `business-resource:${currentBusiness.id}->${target}`
if (resourceContainmentEdgeIds.has(edgeId)) {
return
}
resourceContainmentEdgeIds.add(edgeId)
const edge: BusinessTopologyFlowEdge = {
id: edgeId,
source: currentBusinessId,
target,
animated: false,
data: {
kind: 'resource_link',
label: '包含',
},
label: '包含',
}
edge.animated = shouldAnimateEdge(edge, matchedNodeIds, keyword)
edges.push(edge)
})
topologyLinks.forEach((link, index) => {
const source = resourceNodeIds.get(link.source_node_id)
const target = resourceNodeIds.get(link.target_node_id)
if (!source || !target) {
return
}
edges.push(createResourceLinkEdge(link, index, source, target, matchedNodeIds, keyword))
})
}
return { nodes, edges }
}
function normalizedHealthLevel(item: BusinessSystemItem): 'healthy' | 'degraded' | 'critical' | 'unknown' {
const level = item.health_level?.trim().toLowerCase()
if (level === 'healthy' || level === 'degraded' || level === 'critical') {
return level
}
if (typeof item.health_score === 'number' && !Number.isNaN(item.health_score)) {
return healthLevelFromScore(item.health_score)
}
return 'unknown'
}
function createCurrentBusinessNode(currentBusiness: BusinessSystemItem, health?: BusinessHealth | null): BusinessTopologyFlowNode {
const healthScore = health?.health.score ?? currentBusiness.health_score
const label = currentBusiness.name || currentBusiness.code || `业务系统 ${currentBusiness.id}`
return {
id: businessNodeId(currentBusiness.id),
type: 'businessTopology',
position: { x: 0, y: 0 },
data: {
kind: 'business',
label,
status: currentBusiness.status,
...(typeof healthScore === 'number' ? { healthScore } : {}),
businessSystem: currentBusiness,
},
}
}
function createDependencyBusinessNode(
businessId: number,
dependency: BusinessDependency,
index: number,
externalIndex: number,
): BusinessTopologyFlowNode {
const isUpstream = dependency.depends_on_business_system_id === businessId
const x = isUpstream ? 320 : -320
const y = (index + externalIndex) * 120 - 120
return {
id: businessNodeId(businessId),
type: 'businessTopology',
position: { x, y },
data: {
kind: 'business',
label: `业务系统 ${businessId}`,
status: dependency.criticality || dependency.dependency_type || 'unknown',
dependency,
},
}
}
function createResourceNode(topologyNode: BusinessTopologyNode, index: number): BusinessTopologyFlowNode {
const key = resourceKey(topologyNode)
return {
id: resourceNodeId(key),
type: 'businessTopology',
position: {
x: (index % 4) * 240 - 360,
y: 260 + Math.floor(index / 4) * 140,
},
data: {
kind: 'resource',
label: topologyNode.label || topologyNode.resource_uid || topologyNode.node_id,
status: topologyNode.status,
...(topologyNode.resource_uid ? { resourceUID: topologyNode.resource_uid } : {}),
...(topologyNode.type ? { resourceType: topologyNode.type } : {}),
topologyNode,
},
}
}
function createResourceLinkEdge(
link: BusinessTopologyEdge,
index: number,
source: string,
target: string,
matchedNodeIds: Set<string>,
keyword?: string,
): BusinessTopologyFlowEdge {
const edge: BusinessTopologyFlowEdge = {
id: `resource-link:${link.id ?? index}:${source}->${target}`,
source,
target,
animated: false,
data: {
kind: 'resource_link',
label: '关联',
topologyLink: link,
},
label: '关联',
}
edge.animated = shouldAnimateEdge(edge, matchedNodeIds, keyword)
return edge
}
function dependencyEdgeStyle(dependency: BusinessDependency): NonNullable<BusinessTopologyFlowEdge['style']> {
return {
stroke: dependencyCriticalityColor(dependency.criticality),
strokeWidth: 3,
}
}
function dependencyCriticalityColor(criticality?: string): string {
const normalized = criticality?.trim().toLowerCase()
if (!normalized) {
return '#f53f3f'
}
if (['critical', 'high', 'error', 'failed', 'failure'].includes(normalized)) {
return '#f53f3f'
}
if (['medium', 'warning', 'warn', 'degraded'].includes(normalized)) {
return '#faad14'
}
if (['low', 'healthy', 'normal', 'ok'].includes(normalized)) {
return '#1677ff'
}
return '#f53f3f'
}
function businessNodeId(id: number): string {
return `business:${id}`
}
function resourceNodeId(id: string): string {
return `resource:${id}`
}
function resourceKey(node: BusinessTopologyNode): string {
return node.node_id || node.resource_uid
}
function rememberResourceNodeIds(resourceNodeIds: Map<string, string>, node: BusinessTopologyNode, id: string) {
const aliases = [resourceKey(node), node.node_id, node.resource_uid]
aliases.forEach((alias) => {
if (alias && !resourceNodeIds.has(alias)) {
resourceNodeIds.set(alias, id)
}
})
}
function nodeMatchesKeyword(node: BusinessTopologyFlowNode, keyword: string): boolean {
const data = node.data
if (!data) {
return node.id.toLowerCase().includes(keyword)
}
const values: Array<string | number | undefined> = [node.id, data.label, data.status]
if (data.kind === 'business') {
values.push(data.businessSystem?.code, data.businessSystem?.name, data.dependency?.dependency_type, data.dependency?.description)
} else {
values.push(data.resourceUID, data.resourceType, data.topologyNode?.node_id)
}
return values.some((value) => value?.toString().toLowerCase().includes(keyword))
}
function shouldAnimateEdge(edge: BusinessTopologyFlowEdge, matchedNodeIds: Set<string>, keyword?: string): boolean {
if (!keyword) {
return false
}
return matchedNodeIds.has(edge.source) || matchedNodeIds.has(edge.target) || edgeMatchesKeyword(edge, keyword)
}
function edgeMatchesKeyword(edge: BusinessTopologyFlowEdge, keyword: string): boolean {
const data = edge.data
const values: Array<string | number | undefined> = [
edge.id,
edge.source,
edge.target,
typeof edge.label === 'string' ? edge.label : undefined,
data?.label,
data?.status,
]
if (data?.kind === 'business_dependency') {
values.push(
data.dependency?.id,
data.dependency?.business_system_id,
data.dependency?.depends_on_business_system_id,
data.dependency?.dependency_type,
data.dependency?.criticality,
data.dependency?.description,
)
}
if (data?.kind === 'resource_link') {
values.push(data.topologyLink?.id, data.topologyLink?.source_node_id, data.topologyLink?.target_node_id, data.topologyLink?.topology_id)
}
return values.some((value) => value?.toString().toLowerCase().includes(keyword))
}

View File

@@ -0,0 +1,67 @@
import type { Edge, Node } from '@vue-flow/core'
import type { BusinessDependency, BusinessSystemItem, BusinessTopologyEdge, BusinessTopologyNode } from '@/api/ops/businessSystem'
export type BusinessTopologyViewMode = 'business' | 'resource' | 'dependency'
export type BusinessTopologyHealthFilter = 'all' | 'healthy' | 'degraded' | 'critical' | 'unknown'
export type BusinessTopologyNodeKind = 'business' | 'resource'
export type BusinessTopologyEdgeKind = 'business_dependency' | 'resource_link'
export interface BusinessTopologyBusinessNodeData {
kind: 'business'
label: string
status: string
healthScore?: number
businessSystem?: BusinessSystemItem
dependency?: BusinessDependency
resourceUID?: never
resourceType?: never
topologyNode?: never
}
export interface BusinessTopologyResourceNodeData {
kind: 'resource'
label: string
status: string
resourceUID?: string
resourceType?: string
topologyNode?: BusinessTopologyNode
healthScore?: never
businessSystem?: never
dependency?: never
}
export type BusinessTopologyGraphNodeData = BusinessTopologyBusinessNodeData | BusinessTopologyResourceNodeData
export interface BusinessTopologyDependencyEdgeData {
kind: 'business_dependency'
label: string
status?: string
dependency?: BusinessDependency
topologyLink?: never
}
export interface BusinessTopologyResourceLinkEdgeData {
kind: 'resource_link'
label: string
status?: string
topologyLink?: BusinessTopologyEdge
dependency?: never
}
export type BusinessTopologyGraphEdgeData = BusinessTopologyDependencyEdgeData | BusinessTopologyResourceLinkEdgeData
export type BusinessTopologyFlowNode = Node<BusinessTopologyGraphNodeData, any, 'businessTopology'>
export type BusinessTopologyFlowEdge = Edge<BusinessTopologyGraphEdgeData>
export interface BusinessTopologyGraph {
nodes: BusinessTopologyFlowNode[]
edges: BusinessTopologyFlowEdge[]
}
export type BusinessTopologySelection =
| { type: 'node'; id: string; data: BusinessTopologyGraphNodeData }
| { type: 'edge'; id: string; data: BusinessTopologyGraphEdgeData }

View File

@@ -0,0 +1,343 @@
<template>
<a-form-item field="snmp_oids">
<template #label>
<div class="snmp-oid-label">
<span>SNMP OID配置</span>
<div class="snmp-oid-actions">
<a-button size="mini" type="text" @click="switchEditMode">
{{ editMode === 'table' ? 'JSON' : '表格' }}
</a-button>
<a-button v-if="editMode === 'table'" size="mini" type="primary" @click="addRow">添加</a-button>
</div>
</div>
</template>
<template v-if="editMode === 'table'">
<a-table :data="rows" :pagination="false" size="small" class="snmp-oid-table">
<template #columns>
<a-table-column title="OID" data-index="oid" :width="220">
<template #cell="{ record }">
<a-input v-model="record.oid" @input="syncJsonFromRows" />
</template>
</a-table-column>
<a-table-column title="指标名称" data-index="metric_name" :width="150">
<template #cell="{ record }">
<a-input v-model="record.metric_name" @input="syncJsonFromRows" />
</template>
</a-table-column>
<a-table-column title="指标unit" data-index="metric_unit" :width="120">
<template #cell="{ record }">
<a-input v-model="record.metric_unit" @input="syncJsonFromRows" />
</template>
</a-table-column>
<a-table-column title="类型" data-index="type" :width="100">
<template #cell="{ record }">
<a-input v-model="record.type" @input="syncJsonFromRows" />
</template>
</a-table-column>
<a-table-column title="操作" :width="80">
<template #cell="{ rowIndex }">
<a-button size="mini" type="text" status="danger" @click="removeRow(rowIndex)">删除</a-button>
</template>
</a-table-column>
</template>
</a-table>
</template>
<a-textarea
v-else
v-model="jsonText"
:rows="5"
placeholder='可留空使用默认模板,或填写如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime"}]'
@input="emitJsonText"
/>
</a-form-item>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue'
import { onMounted, ref, watch } from 'vue'
interface Props {
modelValue?: string
resetKey?: number
}
type EditMode = 'table' | 'json'
interface SnmpOidRow {
oid: string
metric_name: string
metric_unit: string
type: string
}
type JsonRecord = Record<string, unknown>
const props = defineProps<Props>()
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const tableFields = ['oid', 'metric_name', 'metric_unit', 'type']
const editMode = ref<EditMode>('table')
const rows = ref<SnmpOidRow[]>([])
const jsonText = ref('')
const createEmptyRow = (): SnmpOidRow => ({
oid: '',
metric_name: '',
metric_unit: '',
type: '',
})
const isJsonRecord = (value: unknown): value is JsonRecord =>
!!value && typeof value === 'object' && !Array.isArray(value)
const parseJsonArray = (raw: string): unknown[] => {
const text = raw.trim()
if (!text) {
return []
}
const parsed = JSON.parse(text)
if (!Array.isArray(parsed)) {
throw new Error('SNMP OID 配置必须是合法 JSON 数组')
}
return parsed
}
const hasUnsupportedFields = (items: unknown[]) =>
items.some((item) => {
if (!isJsonRecord(item)) {
return true
}
return Object.keys(item).some((key) => !tableFields.includes(key))
})
const normalizeRows = (sourceRows: SnmpOidRow[]) =>
sourceRows
.map((row) => ({
oid: row.oid.trim(),
metric_name: row.metric_name.trim(),
metric_unit: row.metric_unit.trim(),
type: row.type.trim(),
}))
.filter((row) => row.oid || row.metric_name || row.metric_unit || row.type)
const findIncompleteRowIndex = (sourceRows: SnmpOidRow[]) =>
sourceRows.findIndex((row) => {
const normalizedRow = {
oid: row.oid.trim(),
metric_name: row.metric_name.trim(),
metric_unit: row.metric_unit.trim(),
type: row.type.trim(),
}
const hasAnyValue =
normalizedRow.oid || normalizedRow.metric_name || normalizedRow.metric_unit || normalizedRow.type
return !!hasAnyValue && (!normalizedRow.oid || !normalizedRow.metric_name)
})
const findInvalidJsonRowIndex = (items: unknown[]) =>
items.findIndex((item) => {
if (!isJsonRecord(item)) {
return true
}
return (
typeof item.oid !== 'string' ||
typeof item.metric_name !== 'string' ||
!item.oid.trim() ||
!item.metric_name.trim()
)
})
const normalizeJsonItems = (items: unknown[]) =>
items.map((item) => {
const record = item as JsonRecord
return {
...record,
oid: String(record.oid ?? '').trim(),
metric_name: String(record.metric_name ?? '').trim(),
metric_unit: typeof record.metric_unit === 'string' ? record.metric_unit.trim() : record.metric_unit,
type: typeof record.type === 'string' ? record.type.trim() : record.type,
}
})
const parseRows = (raw: string): SnmpOidRow[] =>
parseJsonArray(raw).map((item) => {
const record = isJsonRecord(item) ? item : {}
return {
oid: typeof record.oid === 'string' ? record.oid.trim() : '',
metric_name: typeof record.metric_name === 'string' ? record.metric_name.trim() : '',
metric_unit: typeof record.metric_unit === 'string' ? record.metric_unit.trim() : '',
type: typeof record.type === 'string' ? record.type.trim() : '',
}
})
const stringifyRows = (sourceRows: SnmpOidRow[]) => {
const normalizedRows = normalizeRows(sourceRows)
return normalizedRows.length ? JSON.stringify(normalizedRows, null, 2) : ''
}
const emitValue = (value: string) => {
jsonText.value = value
emit('update:modelValue', value)
}
const syncRowsFromJson = () => {
rows.value = parseRows(jsonText.value)
}
const syncJsonFromRows = () => {
emitValue(stringifyRows(rows.value))
}
const emitJsonText = () => {
emit('update:modelValue', jsonText.value)
}
const addRow = () => {
if (editMode.value !== 'table') {
return
}
rows.value.push(createEmptyRow())
syncJsonFromRows()
}
const removeRow = (index: number) => {
rows.value.splice(index, 1)
syncJsonFromRows()
}
const switchEditMode = () => {
if (editMode.value === 'table') {
syncJsonFromRows()
editMode.value = 'json'
return
}
let items: unknown[]
try {
items = parseJsonArray(jsonText.value)
} catch {
Message.warning('SNMP OID 配置必须是合法 JSON 数组,修正后才能切换到表格模式')
return
}
if (hasUnsupportedFields(items)) {
Message.warning('当前 SNMP OID JSON 包含表格不支持的字段,请在 JSON 模式下编辑')
return
}
syncRowsFromJson()
editMode.value = 'table'
}
const validate = () => {
if (editMode.value === 'table') {
const invalidIndex = findIncompleteRowIndex(rows.value)
if (invalidIndex >= 0) {
Message.warning(`SNMP OID 第 ${invalidIndex + 1} 行请填写 OID 和指标名称`)
return false
}
syncJsonFromRows()
return true
}
if (!jsonText.value.trim()) {
rows.value = []
emitValue('')
return true
}
let parsedItems: unknown[]
try {
parsedItems = parseJsonArray(jsonText.value)
} catch {
Message.warning('SNMP OID 配置必须是合法 JSON 数组')
return false
}
const invalidIndex = findInvalidJsonRowIndex(parsedItems)
if (invalidIndex >= 0) {
Message.warning(`SNMP OID 第 ${invalidIndex + 1} 行请填写 OID 和指标名称`)
return false
}
const normalizedItems = normalizeJsonItems(parsedItems)
rows.value = parseRows(JSON.stringify(normalizedItems))
emitValue(normalizedItems.length ? JSON.stringify(normalizedItems, null, 2) : '')
return true
}
const resetFromModelValue = () => {
jsonText.value = props.modelValue ?? ''
if (!jsonText.value.trim()) {
editMode.value = 'table'
rows.value = []
return
}
try {
const items = parseJsonArray(jsonText.value)
if (hasUnsupportedFields(items)) {
editMode.value = 'json'
rows.value = []
return
}
rows.value = parseRows(jsonText.value)
editMode.value = 'table'
} catch {
editMode.value = 'json'
rows.value = []
}
}
watch(
() => props.modelValue,
(value) => {
if ((value ?? '') !== jsonText.value) {
resetFromModelValue()
}
}
)
watch(
() => props.resetKey,
() => {
resetFromModelValue()
}
)
onMounted(() => {
resetFromModelValue()
})
defineExpose({
validate,
resetFromModelValue,
})
</script>
<style scoped>
.snmp-oid-label {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.snmp-oid-actions {
display: flex;
gap: 8px;
}
.snmp-oid-table {
width: 100%;
}
</style>

View File

@@ -140,13 +140,11 @@
</a-form-item>
</a-col>
</a-row>
<a-form-item field="snmp_oids" label="SNMP OID配置(JSON数组)">
<a-textarea
v-model="formData.snmp_oids"
:rows="3"
placeholder='可留空使用默认模板,或填写如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime"}]'
/>
</a-form-item>
<SnmpOidEditor
ref="snmpOidEditorRef"
v-model="formData.snmp_oids"
:reset-key="snmpOidEditorResetKey"
/>
</template>
<a-row :gutter="20">
@@ -194,6 +192,7 @@ import {
} from '@/api/ops/room-device'
import { fetchPolicyOptions, type PolicyOptionItem } from '@/api/ops/alertPolicy'
import { fetchRoomOptions, type RoomOptionItem } from '@/api/ops/room'
import SnmpOidEditor from '../../components/SnmpOidEditor.vue'
interface Props {
visible: boolean
@@ -207,6 +206,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref<FormInstance>()
const snmpOidEditorRef = ref<InstanceType<typeof SnmpOidEditor>>()
const snmpOidEditorResetKey = ref(0)
const confirmLoading = ref(false)
const policyOptions = ref<PolicyOptionItem[]>([])
const roomOptions = ref<RoomOptionItem[]>([])
@@ -309,6 +310,7 @@ watch(
collect_interval: props.record.collect_interval || 60,
policy_ids: props.record.policy_ids || [],
})
snmpOidEditorResetKey.value += 1
} else {
Object.assign(formData, {
name: '',
@@ -336,6 +338,7 @@ watch(
collect_interval: 60,
policy_ids: [],
})
snmpOidEditorResetKey.value += 1
}
}
}
@@ -370,13 +373,8 @@ const handleOk = async () => {
Message.warning('SNMP v2c 模式下请填写 community')
return
}
if (formData.snmp_oids?.trim()) {
try {
JSON.parse(formData.snmp_oids)
} catch {
Message.warning('SNMP OID 配置必须是合法 JSON')
return
}
if (!snmpOidEditorRef.value?.validate()) {
return
}
}

View File

@@ -9,9 +9,9 @@
@cancel="handleCancel"
@update:visible="handleUpdateVisible"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" auto-label-width>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" auto-label-width>
<a-alert type="info" show-icon class="mb-12">
采集协议固定为 SNMP + SSH 双协议请同时完成两部分配置编辑时密码不回显留空表示不修改
采集协议固定为 SNMP仅在勾选采集FIB信息时需要填写 SSH编辑时密码不回显留空表示不修改
</a-alert>
<a-divider orientation="left">基础信息</a-divider>
<a-row :gutter="20">
@@ -75,7 +75,7 @@
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="采集协议"><a-tag color="purple">SNMP + SSH固定</a-tag></a-form-item>
<a-form-item label="采集协议"><a-tag color="purple">SNMP + SSHFIB可选</a-tag></a-form-item>
</a-col>
</a-row>
@@ -152,20 +152,24 @@
</a-col>
</a-row>
<a-divider orientation="left">SSH 配置必填</a-divider>
<a-divider orientation="left">
{{ fibEnabled ? 'SSH 配置(采集 FIB 时必填)' : 'SSH 配置(未采集 FIB 时可选)' }}
</a-divider>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="ssh_port" label="SSH端口" required>
<a-form-item field="ssh_port" label="SSH端口" :required="fibEnabled">
<a-input-number v-model="formData.ssh.port" :min="1" :max="65535" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="ssh_username" label="SSH用户名" required><a-input v-model="formData.ssh.username" /></a-form-item>
<a-form-item field="ssh_username" label="SSH用户名" :required="fibEnabled">
<a-input v-model="formData.ssh.username" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="ssh_password" label="SSH密码" required>
<a-form-item field="ssh_password" label="SSH密码" :required="fibEnabled">
<a-input-password v-model="formData.ssh.password" :placeholder="isEdit ? '留空表示不修改' : '请输入 SSH 密码'" />
</a-form-item>
</a-col>
@@ -259,12 +263,23 @@ const defaults = () => ({
collect: { interface_enabled: true, arp_enabled: true, route_enabled: true, fib_enabled: true, timeout_sec: 5, retries: 1 },
})
const formData = reactive(defaults())
const fibEnabled = computed(() => formData.collect.fib_enabled)
const rules = {
name: [{ required: true, message: '请输入设备名称' }],
category: [{ required: true, message: '请选择设备类型' }],
host: [{ required: true, message: '请输入设备地址' }],
community: [{ required: true, message: '请输入 SNMP community' }],
ssh_username: [{ required: true, message: '请输入 SSH 用户名' }],
ssh_username: [
{
validator: (value: string | undefined, callback: (error?: string) => void) => {
if (formData.collect.fib_enabled && !String(value || '').trim()) {
callback('请输入 SSH 用户名')
return
}
callback()
},
},
],
}
watch(
@@ -322,6 +337,15 @@ watch(tagList, (tags) => {
formData.tags = tags.join(',')
})
watch(
() => formData.collect.fib_enabled,
(enabled) => {
if (!enabled) {
;(formRef.value as any)?.clearValidate?.(['ssh_port', 'ssh_username', 'ssh_password'])
}
}
)
const handleOk = async () => {
try {
await formRef.value?.validate()
@@ -343,13 +367,15 @@ const handleOk = async () => {
return
}
}
if (!formData.ssh.username.trim()) {
Message.warning('请填写 SSH 用户名')
return
}
if (!isEdit.value && !formData.ssh.password.trim()) {
Message.warning('请填写 SSH 密码')
return
if (formData.collect.fib_enabled) {
if (!formData.ssh.username.trim()) {
Message.warning('请填写 SSH 用户名')
return
}
if (!isEdit.value && !formData.ssh.password.trim() && !formData.ssh.private_key.trim()) {
Message.warning('请填写 SSH 密码或 SSH 私钥')
return
}
}
confirmLoading.value = true
const payload: any = {

View File

@@ -157,13 +157,11 @@
</a-form-item>
</a-col>
</a-row>
<a-form-item field="snmp_oids" label="SNMP OID配置(JSON数组)">
<a-textarea
v-model="formData.snmp_oids"
:rows="3"
placeholder='可留空使用默认模板,或填写如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime","metric_unit":"timeticks"}]'
/>
</a-form-item>
<SnmpOidEditor
ref="snmpOidEditorRef"
v-model="formData.snmp_oids"
:reset-key="snmpOidEditorResetKey"
/>
</template>
<a-row :gutter="20">
@@ -214,6 +212,7 @@ import type { FormInstance } from '@arco-design/web-vue'
import { createSecurityService, updateSecurityService, SECURITY_TYPE_OPTIONS, type SecurityServiceFormData } from '@/api/ops/security'
import { fetchPolicyOptions, type PolicyOptionItem } from '@/api/ops/alertPolicy'
import { fetchServerList, type ServerItem } from '@/api/ops/server'
import SnmpOidEditor from '../../components/SnmpOidEditor.vue'
interface Props {
visible: boolean
@@ -227,6 +226,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref<FormInstance>()
const snmpOidEditorRef = ref<InstanceType<typeof SnmpOidEditor>>()
const snmpOidEditorResetKey = ref(0)
const confirmLoading = ref(false)
const policyOptions = ref<PolicyOptionItem[]>([])
const serverOptions = ref<ServerItem[]>([])
@@ -336,6 +337,7 @@ watch(
extra: props.record.extra || '',
policy_ids: props.record.policy_ids || [],
})
snmpOidEditorResetKey.value += 1
} else {
Object.assign(formData, {
service_identity: '',
@@ -368,6 +370,7 @@ watch(
extra: '',
policy_ids: [],
})
snmpOidEditorResetKey.value += 1
}
}
}
@@ -402,13 +405,8 @@ const handleOk = async () => {
Message.warning('SNMP v2c 模式下请填写 community')
return
}
if (formData.snmp_oids?.trim()) {
try {
JSON.parse(formData.snmp_oids)
} catch {
Message.warning('SNMP OID配置必须是合法 JSON')
return
}
if (!snmpOidEditorRef.value?.validate()) {
return
}
}

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

@@ -45,6 +45,12 @@ export const columns = [
title: '类型',
width: 100,
},
{
dataIndex: 'asset_id',
title: '关联资产',
width: 100,
slotName: 'asset_id',
},
{
dataIndex: 'location',
title: '位置',

View File

@@ -30,6 +30,13 @@
{{ record.id }}
</template>
<!-- 关联资产 -->
<template #asset_id="{ record }">
<a-tag :color="record.asset_id ? 'green' : 'gray'">
{{ record.asset_id ? `已关联 #${record.asset_id}` : '未关联' }}
</a-tag>
</template>
<!-- 远程访问 -->
<template #remote_access="{ record }">
<a-tag :color="record.remote_access ? 'green' : 'gray'">
@@ -120,6 +127,12 @@
<icon-down />
</a-button>
<template #content>
<a-doption :disabled="isCollecting(record) || !record.server_identity" @click="handleCollectNow(record)">
<template #icon>
<icon-refresh />
</template>
{{ isCollecting(record) ? '采集中' : '立即采集' }}
</a-doption>
<a-doption @click="handleQuickConfig(record)">
<template #icon>
<icon-settings />
@@ -200,13 +213,7 @@ import ServerFormDialog from './components/ServerFormDialog.vue'
import QuickConfigDialog from './components/QuickConfigDialog.vue'
import HardwareDeviceConfigDialog from './components/HardwareDeviceConfigDialog.vue'
import { columns as columnsConfig } from './config/columns'
import { fetchServerList, deleteServer } from '@/api/ops/server'
import axios from 'axios'
// 创建独立的 axios 实例用于请求外部 agent绕过全局拦截器
const agentAxios = axios.create({
timeout: 5000,
})
import { fetchServerList, deleteServer, fetchServerMetricsSummary, collectServerMetricsNow, type HostMetricsSummary } from '@/api/ops/server'
const router = useRouter()
@@ -217,6 +224,7 @@ const formDialogVisible = ref(false)
const quickConfigVisible = ref(false)
const hardwareConfigVisible = ref(false)
const currentRecord = ref<any>(null)
const collectingMap = reactive<Record<string, boolean>>({})
const formModel = ref({
keyword: '',
collect_on: undefined as boolean | undefined,
@@ -394,6 +402,13 @@ const handleHardwareConfigSuccess = () => {
fetchServers()
}
const getCollectingKey = (record: any) => record.server_identity || String(record.id || '')
const isCollecting = (record: any) => {
const key = getCollectingKey(record)
return !!collectingMap[key]
}
// 编辑服务器
const handleEdit = (record: any) => {
currentRecord.value = record
@@ -465,74 +480,86 @@ const handleDelete = async (record: any) => {
})
}
/** dc-host `GET agent_config`(一般为 `/dc-host/stats`)返回裸 Metrics JSON */
// 获取所有服务器的监控指标
const resetMetrics = (record: any) => {
record.cpu_info = { value: 0, total: '', used: '' }
record.memory_info = { value: 0, total: '', used: '' }
record.disk_info = { value: 0, total: '', used: '' }
}
const applyMetricsSummary = (record: any, summary?: HostMetricsSummary) => {
if (!summary || !summary.has_data) {
resetMetrics(record)
return
}
record.cpu_info = {
value: Number((summary.cpu?.usage_percent || 0).toFixed(2)),
total: summary.cpu?.logical_cores_total ? `${summary.cpu.logical_cores_total}` : '',
used: '',
}
record.memory_info = {
value: Number((summary.memory?.used_percent || 0).toFixed(2)),
total: summary.memory?.total_bytes ? `${(summary.memory.total_bytes / 1024 / 1024 / 1024).toFixed(1)}GB` : '',
used: summary.memory?.used_bytes ? `${(summary.memory.used_bytes / 1024 / 1024 / 1024).toFixed(1)}GB` : '',
}
record.disk_info = {
value: Number((summary.disk_root?.used_percent || 0).toFixed(2)),
total: summary.disk_root?.total_bytes ? `${(summary.disk_root.total_bytes / 1024 / 1024 / 1024).toFixed(0)}GB` : '',
used: summary.disk_root?.used_bytes ? `${(summary.disk_root.used_bytes / 1024 / 1024 / 1024).toFixed(0)}GB` : '',
}
if (summary.timestamp) {
record.last_check_time = summary.timestamp
}
}
// 从 dc-control 获取所有服务器最新时序指标
const getAllMetrics = async () => {
try {
// 遍历每个服务器记录
const metricsPromises = tableData.value.map(async (record) => {
// 检查是否有 agent_config 配置
if (record.agent_config) {
try {
// 从 agent_config 中解析 URL
let metricsUrl = record.agent_config
// 验证 URL 是否合法
try {
new URL(metricsUrl)
} catch (urlError) {
console.warn(`服务器 ${record.name} 的 agent_config 不是合法的 URL:`, metricsUrl)
// 设置默认值 0
record.cpu_info = { value: 0, total: '', used: '' }
record.memory_info = { value: 0, total: '', used: '' }
record.disk_info = { value: 0, total: '', used: '' }
return
}
// 使用独立的 axios 实例请求外部 agent绕过全局拦截器
const response = await agentAxios.get(metricsUrl)
console.log('获取指标数据:', response.data)
if (response.data) {
// 更新记录的监控数据
record.cpu_info = {
value: Number((response.data.cpu_usage || 0).toFixed(2)),
total: response.data.cpu?.length ? `${response.data.cpu.length}` : '',
used: '',
}
record.memory_info = {
value: Number((response.data.mem_usage?.used_percent || 0).toFixed(2)),
total: response.data.mem_usage?.total ? `${(response.data.mem_usage.total / 1024 / 1024 / 1024).toFixed(1)}GB` : '',
used: response.data.mem_usage?.used ? `${(response.data.mem_usage.used / 1024 / 1024 / 1024).toFixed(1)}GB` : '',
}
record.disk_info = {
value: Number((response.data.disk_usage?.used_percent || 0).toFixed(2)),
total: response.data.disk_usage?.total ? `${(response.data.disk_usage.total / 1024 / 1024 / 1024).toFixed(0)}GB` : '',
used: response.data.disk_usage?.used ? `${(response.data.disk_usage.used / 1024 / 1024 / 1024).toFixed(0)}GB` : '',
}
}
} catch (error) {
console.warn(`获取服务器 ${record.name} 的监控指标失败:`, error)
// 初始化默认值
record.cpu_info = { value: 0, total: '', used: '' }
record.memory_info = { value: 0, total: '', used: '' }
record.disk_info = { value: 0, total: '', used: '' }
}
} else {
// 没有配置 agent设置默认值
record.cpu_info = { value: 0, total: '', used: '' }
record.memory_info = { value: 0, total: '', used: '' }
record.disk_info = { value: 0, total: '', used: '' }
if (!record.server_identity) {
resetMetrics(record)
return
}
try {
const response = await fetchServerMetricsSummary(record.server_identity)
applyMetricsSummary(record, response.details)
} catch (error) {
console.warn(`获取服务器 ${record.name} 的监控指标失败:`, error)
resetMetrics(record)
}
})
// 等待所有请求完成
await Promise.all(metricsPromises)
} catch (error) {
console.error('获取所有服务器监控指标失败:', error)
}
}
const handleCollectNow = async (record: any) => {
if (!record.server_identity) {
Message.warning('缺少服务器唯一标识,无法采集')
return
}
const key = getCollectingKey(record)
if (collectingMap[key]) {
return
}
collectingMap[key] = true
try {
const response = await collectServerMetricsNow(record.server_identity)
if (response.code === 0) {
applyMetricsSummary(record, response.details)
Message.success('采集完成')
} else {
Message.error(response.message || '采集失败')
}
} catch (error) {
console.error('立即采集失败:', error)
Message.error('采集失败')
} finally {
collectingMap[key] = false
}
}
onMounted(() => {
fetchServers()
})

View File

@@ -173,13 +173,11 @@
</a-col>
</a-row>
<a-form-item field="snmp_oids" label="SNMP OID配置">
<a-textarea
v-model="formData.snmp_oids"
:rows="3"
placeholder='可留空用默认模板,或填写 JSON 数组如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime"}]'
/>
</a-form-item>
<SnmpOidEditor
ref="snmpOidEditorRef"
v-model="formData.snmp_oids"
:reset-key="snmpOidEditorResetKey"
/>
</template>
<a-form-item field="collect_args" label="采集参数">
@@ -208,6 +206,7 @@ import type { FormInstance } from '@arco-design/web-vue'
import { createStorage, updateStorage } from '@/api/ops/storage'
import type { StorageCreateData, StorageItem } from '@/api/ops/storage'
import { fetchServerList, type ServerItem } from '@/api/ops/server'
import SnmpOidEditor from '../../components/SnmpOidEditor.vue'
interface Props {
visible: boolean
@@ -221,6 +220,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref<FormInstance>()
const snmpOidEditorRef = ref<InstanceType<typeof SnmpOidEditor>>()
const snmpOidEditorResetKey = ref(0)
const confirmLoading = ref(false)
const serverOptions = ref<ServerItem[]>([])
@@ -297,6 +298,7 @@ watch(
collect_args: props.record.collect_args || '',
collect_interval: props.record.collect_interval || 60,
})
snmpOidEditorResetKey.value += 1
} else {
Object.assign(formData, {
name: '',
@@ -328,6 +330,7 @@ watch(
collect_args: '',
collect_interval: 60,
})
snmpOidEditorResetKey.value += 1
}
}
}
@@ -362,13 +365,8 @@ const handleOk = async () => {
Message.warning('SNMP v2c 模式下请填写 community')
return
}
if (formData.snmp_oids?.trim()) {
try {
JSON.parse(formData.snmp_oids)
} catch {
Message.warning('SNMP OID 配置必须是合法 JSON')
return
}
if (!snmpOidEditorRef.value?.validate()) {
return
}
}

View File

@@ -60,6 +60,16 @@
</a-col>
</a-row>
<a-alert
v-if="showVirtualEmptyTip"
class="empty-tip"
type="info"
show-icon
banner
>
当前暂无已关联资产的虚拟服务器请在服务器管理中将虚拟服务器关联到资产宿主机趋势还需要配置已关联资产的物理服务器
</a-alert>
<!-- 图表区域 -->
<a-row :gutter="16" class="chart-row">
<a-col :xs="24" :lg="8">
@@ -188,7 +198,7 @@ const stats = ref({
memoryUsage: 0,
})
const loading = ref(false)
const loading = ref(true)
// 宿主机状态
const hostStatus = ref<
@@ -317,6 +327,7 @@ const cpuPhysicalText = computed(() => formatMaxTwoDecimals(cpuPhysical.value))
const memoryTotalText = computed(() => formatMaxTwoDecimals(memVirtualGb.value + memPhysicalGb.value))
const memoryVirtualText = computed(() => formatMaxTwoDecimals(memVirtualGb.value))
const memoryPhysicalText = computed(() => formatMaxTwoDecimals(memPhysicalGb.value))
const showVirtualEmptyTip = computed(() => !loading.value && stats.value.total === 0)
const toGb = (bytes: number) => bytes / 1024 / 1024 / 1024
const safeNum = (v: number | null | undefined) => (typeof v === 'number' ? v : 0)
const hourLabel = (v: string) => {
@@ -410,6 +421,10 @@ export default {
margin-bottom: 16px;
}
.empty-tip {
margin-bottom: 16px;
}
.stats-card {
height: 100%;

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;

View File

@@ -0,0 +1,54 @@
export type StatisticsReportOutputMode = 'scalar' | 'timeseries' | ''
export interface StatisticsReportViewContent {
outputMode: StatisticsReportOutputMode
scalarRows: Record<string, any>[]
seriesRows: Record<string, any>[]
hasContent: boolean
}
const toRows = (value: unknown): Record<string, any>[] => {
if (!Array.isArray(value)) return []
return value.filter((item): item is Record<string, any> => item !== null && typeof item === 'object' && !Array.isArray(item))
}
const normalizeOutputMode = (value: unknown): StatisticsReportOutputMode => {
if (value === 'scalar' || value === 'timeseries') return value
return ''
}
/** 将后端统计报表 payload 归一化为弹窗可渲染的数据结构。 */
export const normalizeStatisticsReportContent = (content: Record<string, any> | null | undefined): StatisticsReportViewContent => {
if (!content) {
return {
outputMode: '',
scalarRows: [],
seriesRows: [],
hasContent: false,
}
}
const scalarRows = toRows(content.rows)
const legacyScalarRows =
content.scalars && typeof content.scalars === 'object' && !Array.isArray(content.scalars) ? [content.scalars] : []
const seriesRows = toRows(content.series)
let outputMode = normalizeOutputMode(content.output_mode || content.params?.output_mode)
if (!outputMode) {
if (seriesRows.length > 0) {
outputMode = 'timeseries'
} else if (scalarRows.length > 0 || legacyScalarRows.length > 0) {
outputMode = 'scalar'
}
}
const normalizedScalarRows = scalarRows.length > 0 ? scalarRows : legacyScalarRows
const hasContent = outputMode === 'timeseries' ? seriesRows.length > 0 : normalizedScalarRows.length > 0
return {
outputMode,
scalarRows: normalizedScalarRows,
seriesRows,
hasContent,
}
}

View File

@@ -215,22 +215,23 @@
<div v-if="contentLoading" class="loading-container">
<a-spin />
</div>
<div v-else-if="reportContent">
<div v-else-if="normalizedReportContent.hasContent">
<!-- 标量结果 -->
<template v-if="reportContent.output_mode === 'scalar'">
<template v-if="normalizedReportContent.outputMode === 'scalar'">
<a-card title="统计结果" :bordered="false" class="summary-card">
<a-descriptions :column="3" bordered>
<a-descriptions-item v-for="(value, key) in reportContent.scalars" :key="key" :label="formatLabel(String(key))">
{{ formatValue(String(key), value) }}
</a-descriptions-item>
</a-descriptions>
<a-table
:data="normalizedReportContent.scalarRows"
:columns="scalarTableColumns"
:pagination="{ pageSize: 10 }"
stripe
/>
</a-card>
</template>
<!-- 时间序列结果 -->
<template v-else-if="reportContent.output_mode === 'timeseries'">
<template v-else-if="normalizedReportContent.outputMode === 'timeseries'">
<div ref="chartRef" class="chart-container"></div>
<a-table :data="reportContent.series || []" :columns="seriesTableColumns" :pagination="{ pageSize: 10 }" stripe />
<a-table :data="normalizedReportContent.seriesRows" :columns="seriesTableColumns" :pagination="{ pageSize: 10 }" stripe />
</template>
</div>
<a-empty v-else description="暂无数据" />
@@ -256,6 +257,7 @@ import * as echarts from 'echarts'
import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions'
import { useReportMetricRegistryOptions } from '../useReportMetricRegistryOptions'
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
import { normalizeStatisticsReportContent } from './content'
const { targetIdentityOptions, targetOptionsLoading, loadTargetIdentityOptions } =
useReportTargetIdentityOptions()
@@ -399,19 +401,17 @@ const reportContent = ref<Record<string, any> | null>(null)
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
const normalizedReportContent = computed(() => normalizeStatisticsReportContent(reportContent.value))
// 标量结果表格列配置
const scalarTableColumns = computed(() =>
buildContentColumns(normalizedReportContent.value.scalarRows, ['target_identity', 'avg', 'max', 'min', 'sum', 'count']),
)
// 时间序列表格列配置
const seriesTableColumns = computed(() => [
{
title: '时间',
dataIndex: 'timestamp',
width: 180,
},
{
title: '值',
dataIndex: 'value',
width: 150,
},
])
const seriesTableColumns = computed(() =>
buildContentColumns(normalizedReportContent.value.seriesRows, ['target_identity', 'time', 'timestamp', 'value', 'count']),
)
// 获取报表列表
const fetchList = async () => {
@@ -604,17 +604,18 @@ const handleViewContent = async (record?: ReportRecord) => {
contentModalVisible.value = true
contentModalTitle.value = targetRecord.title
reportContent.value = null
let seriesToRender: Record<string, any>[] = []
try {
const res = await fetchReportContent(targetRecord.id)
if (res.code === 0 && res.details) {
reportContent.value = res.details
const normalized = normalizeStatisticsReportContent(res.details)
// 如果是时间序列模式,渲染图表
if (res.details.output_mode === 'timeseries' && res.details.series) {
await nextTick()
renderChart(res.details.series)
if (normalized.outputMode === 'timeseries' && normalized.seriesRows.length > 0) {
seriesToRender = normalized.seriesRows
}
} else {
Message.error(res.message || '获取报表内容失败')
@@ -625,6 +626,11 @@ const handleViewContent = async (record?: ReportRecord) => {
} finally {
contentLoading.value = false
}
if (seriesToRender.length > 0) {
await nextTick()
renderChart(seriesToRender)
}
}
// 导出报表
@@ -666,13 +672,15 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
}
// 格式化标签
const formatLabel = (key: string) => {
function formatLabel(key: string) {
const labelMap: Record<string, string> = {
avg: '平均值',
max: '最大值',
min: '最小值',
sum: '求和',
count: '计数',
target_identity: '目标标识',
time: '时间',
timestamp: '时间',
value: '值',
}
@@ -680,16 +688,18 @@ const formatLabel = (key: string) => {
return labelMap[key] || key
}
// 格式化数值
const formatValue = (key: string, value: any) => {
if (value === null || value === undefined) return '-'
function buildContentColumns(rows: Record<string, any>[], preferredKeys: string[]) {
const firstRow = rows[0]
if (!firstRow) return []
// 数字类型保留两位小数
if (typeof value === 'number') {
return value.toFixed(2)
}
const keys = Object.keys(firstRow)
const orderedKeys = [...preferredKeys.filter((key) => keys.includes(key)), ...keys.filter((key) => !preferredKeys.includes(key))]
return value
return orderedKeys.map((key) => ({
title: formatLabel(key),
dataIndex: key,
width: key === 'time' || key === 'timestamp' ? 180 : 150,
}))
}
// 渲染图表
@@ -715,7 +725,7 @@ const renderChart = (series: any[]) => {
xAxis: {
type: 'category',
boundaryGap: false,
data: series.map((item: any) => item.timestamp),
data: series.map((item: any) => item.time || item.timestamp),
},
yAxis: {
type: 'value',

View File

@@ -28,9 +28,13 @@
</a-row>
<a-card :bordered="false">
<template #title>资源上下文</template>
<template #title>资源</template>
<template #extra>
<a-space>
<a-button type="primary" :disabled="!canOperate || loading" @click="openCreateResource">
<template #icon><icon-plus /></template>
新增资源
</a-button>
<a-button :disabled="!canOperate || loading" @click="handleBackfill">
<template #icon><icon-refresh /></template>
回填资源
@@ -87,9 +91,17 @@
<a-tag :color="statusColor(record.status)">{{ record.status || 'unknown' }}</a-tag>
</template>
<template #actions="{ record }">
<a-button type="text" size="small" :disabled="pageUnauthorized" @click="openDetail(record)">
<template #icon><icon-eye /></template>
</a-button>
<a-space>
<a-button type="text" size="small" :disabled="pageUnauthorized" @click="openDetail(record)">
<template #icon><icon-eye /></template>
</a-button>
<a-button type="text" size="small" :disabled="!canOperate" @click="openEditResource(record)">
<template #icon><icon-edit /></template>
</a-button>
<a-button type="text" status="danger" size="small" :disabled="!canOperate" @click="handleDeleteResource(record)">
<template #icon><icon-delete /></template>
</a-button>
</a-space>
</template>
</a-table>
</PageState>
@@ -118,23 +130,71 @@
:pagination="false"
/>
</a-drawer>
<a-modal
v-model:visible="resourceFormVisible"
:title="resourceFormTitle"
:confirm-loading="resourceFormSubmitting"
:on-before-ok="submitResourceForm"
unmount-on-close
@cancel="resetResourceForm"
>
<a-form :model="resourceForm" layout="vertical">
<a-form-item field="resource_category" label="资源分类" required>
<a-select v-model="resourceForm.resource_category" allow-search placeholder="请选择资源分类" :options="resourceTypeOptions" />
</a-form-item>
<a-form-item field="service_identity" label="服务标识" required>
<a-input v-model="resourceForm.service_identity" placeholder="例如 his-app-01" />
</a-form-item>
<a-form-item field="display_name" label="展示名称" required>
<a-input v-model="resourceForm.display_name" placeholder="例如 HIS 应用服务 01" />
</a-form-item>
<a-form-item field="status" label="状态" required>
<a-select v-model="resourceForm.status" placeholder="请选择状态">
<a-option value="normal">normal</a-option>
<a-option value="online">online</a-option>
<a-option value="offline">offline</a-option>
<a-option value="error">error</a-option>
<a-option value="unknown">unknown</a-option>
</a-select>
</a-form-item>
<a-form-item field="business_system_id" label="业务系统">
<a-select v-model="resourceForm.business_system_id" allow-clear allow-search placeholder="可选">
<a-option v-for="item in businessSystems" :key="item.id" :value="item.id">{{ item.name }}</a-option>
</a-select>
</a-form-item>
<a-form-item field="asset_id" label="资产 ID">
<a-input-number v-model="resourceForm.asset_id" :min="1" :precision="0" placeholder="可选" />
</a-form-item>
<a-form-item field="tags" label="标签">
<a-input v-model="resourceForm.tags" placeholder="多个标签用逗号分隔" />
</a-form-item>
<a-form-item field="description" label="描述">
<a-textarea v-model="resourceForm.description" :auto-size="{ minRows: 3, maxRows: 6 }" placeholder="请输入资源说明" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconEye, IconRefresh, IconSearch } from '@arco-design/web-vue/es/icon'
import { Message, Modal } from '@arco-design/web-vue'
import { IconDelete, IconEdit, IconEye, IconPlus, IconRefresh, IconSearch } from '@arco-design/web-vue/es/icon'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import {
backfillControlResources,
createControlResource,
deleteControlResource,
fetchControlBusinessSystems,
fetchControlMetricSeries,
fetchControlResources,
fetchControlResourceTypes,
updateControlResource,
type ControlBusinessSystem,
type ControlMetricSeries,
type ControlResource,
type ControlResourcePayload,
type ControlResourceType,
} from '@/api/ops/dcControl'
@@ -150,6 +210,28 @@ const resourceTypes = ref<ControlResourceType[]>([])
const businessSystems = ref<ControlBusinessSystem[]>([])
const currentResource = ref<ControlResource | null>(null)
const selectedMetricTotal = ref(0)
const resourceFormVisible = ref(false)
const resourceFormSubmitting = ref(false)
const editingResource = ref<ControlResource | null>(null)
const resourceForm = reactive<ControlResourcePayload>({
resource_category: '',
service_identity: '',
display_name: '',
status: 'normal',
business_system_id: undefined,
owner_user_id: undefined,
department_id: undefined,
asset_id: undefined,
datacenter_id: undefined,
room_id: undefined,
rack_id: undefined,
unit_start: undefined,
unit_end: undefined,
location_ref: '',
tags: '',
description: '',
})
const filters = reactive({
keyword: '',
@@ -173,7 +255,7 @@ const resourceColumns: TableColumnData[] = [
{ title: '责任人', dataIndex: 'owner_user_id', slotName: 'owner_user_id', width: 100 },
{ title: '资产', dataIndex: 'asset_id', slotName: 'asset_id', width: 100 },
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 110 },
{ title: '操作', dataIndex: 'actions', slotName: 'actions', width: 80, fixed: 'right' },
{ title: '操作', dataIndex: 'actions', slotName: 'actions', width: 150, fixed: 'right' },
]
const metricColumns: TableColumnData[] = [
@@ -194,6 +276,62 @@ const resourceTypeOptions = computed(() =>
const canOperate = computed(() => !pageUnauthorized.value)
const resourceFormTitle = computed(() => (editingResource.value ? '编辑资源' : '新增资源'))
const resetResourceForm = () => {
editingResource.value = null
Object.assign(resourceForm, {
resource_category: '',
service_identity: '',
display_name: '',
status: 'normal',
business_system_id: undefined,
owner_user_id: undefined,
department_id: undefined,
asset_id: undefined,
datacenter_id: undefined,
room_id: undefined,
rack_id: undefined,
unit_start: undefined,
unit_end: undefined,
location_ref: '',
tags: '',
description: '',
})
}
const normalizeNullableNumber = (value: unknown) => {
const numberValue = Number(value)
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : null
}
const buildResourcePayload = (): ControlResourcePayload => {
const locationRef = resourceForm.location_ref?.trim()
const payload: ControlResourcePayload = {
resource_category: resourceForm.resource_category?.trim(),
service_identity: resourceForm.service_identity?.trim(),
display_name: resourceForm.display_name?.trim(),
status: resourceForm.status?.trim() || 'normal',
business_system_id: normalizeNullableNumber(resourceForm.business_system_id),
owner_user_id: normalizeNullableNumber(resourceForm.owner_user_id),
department_id: normalizeNullableNumber(resourceForm.department_id),
asset_id: normalizeNullableNumber(resourceForm.asset_id),
datacenter_id: normalizeNullableNumber(resourceForm.datacenter_id),
room_id: normalizeNullableNumber(resourceForm.room_id),
rack_id: normalizeNullableNumber(resourceForm.rack_id),
unit_start: normalizeNullableNumber(resourceForm.unit_start),
unit_end: normalizeNullableNumber(resourceForm.unit_end),
tags: resourceForm.tags?.trim(),
description: resourceForm.description?.trim(),
}
if (locationRef) {
payload.location_ref = locationRef
}
return payload
}
const pageStatus = computed(() => {
if (pageUnauthorized.value) return 'unauthorized'
if (loading.value) return 'loading'
@@ -313,6 +451,85 @@ const openDetail = (record: ControlResource) => {
fetchMetricSeries(record.resource_uid)
}
const openCreateResource = () => {
resetResourceForm()
resourceFormVisible.value = true
}
const openEditResource = (record: ControlResource) => {
editingResource.value = record
Object.assign(resourceForm, {
resource_category: record.resource_category || '',
service_identity: record.service_identity || '',
display_name: record.display_name || '',
status: record.status || 'normal',
business_system_id: record.business_system_id,
owner_user_id: record.owner_user_id,
department_id: record.department_id,
asset_id: record.asset_id,
datacenter_id: record.datacenter_id,
room_id: record.room_id,
rack_id: record.rack_id,
unit_start: record.unit_start,
unit_end: record.unit_end,
location_ref: record.location_ref || '',
tags: record.tags || '',
description: record.description || '',
})
resourceFormVisible.value = true
}
const submitResourceForm = async () => {
const payload = buildResourcePayload()
if (!payload.resource_category || !payload.service_identity || !payload.display_name) {
Message.warning('请填写资源分类、服务标识和展示名称')
return false
}
resourceFormSubmitting.value = true
try {
if (editingResource.value) {
await updateControlResource(editingResource.value.id, payload)
Message.success('资源已更新')
} else {
await createControlResource(payload)
Message.success('资源已新增')
}
await fetchResources()
return true
} catch (error) {
console.error('保存资源失败:', error)
Message.error('保存资源失败')
return false
} finally {
resourceFormSubmitting.value = false
}
}
const handleDeleteResource = (record: ControlResource) => {
Modal.warning({
title: '删除资源',
content: `确认删除资源「${record.display_name || '-'} / ${record.resource_uid} / ${resourceTypeName(record.resource_category)}」?这会删除资源库中的全局资源,可能影响业务拓扑、告警和指标序列。`,
hideCancel: false,
okText: '删除',
cancelText: '取消',
onOk: async () => {
try {
await deleteControlResource(record.id)
Message.success('资源已删除')
if (currentResource.value?.id === record.id) {
detailVisible.value = false
currentResource.value = null
}
await fetchResources()
} catch (error) {
console.error('删除资源失败:', error)
Message.error('删除资源失败')
}
},
})
}
const handleBackfill = async () => {
try {
await backfillControlResources()