This commit is contained in:
ygx
2026-03-21 15:52:38 +08:00
parent 090b00002e
commit 4708a8bbf7
36 changed files with 7280 additions and 28 deletions

View File

@@ -37,7 +37,8 @@
"vue": "^3.5.29",
"vue-echarts": "^8.0.1",
"vue-i18n": "^11.2.8",
"vue-router": "5"
"vue-router": "5",
"vue-web-terminal": "^3.4.1"
},
"devDependencies": {
"@arco-plugins/vite-vue": "^1.4.6",

View File

@@ -20,6 +20,8 @@ export interface MenuItem {
web_url?: string; // 嵌入的网页URL
component?: string; // 路由文件地址
is_new_tab?: boolean; // 是否在新窗口打开
is_full?: boolean; // 单独的页面,不包含菜单
hide_menu?: boolean; // 隐藏菜单栏的菜单
}
/** 获取菜单 */

40
src/api/ops/pc.ts Normal file
View File

@@ -0,0 +1,40 @@
import { request } from "@/api/request";
/** 获取PC列表分页 */
export const fetchPCList = (data?: {
page?: number;
page_size?: number;
keyword?: string;
datacenter_id?: number;
rack_id?: number;
status?: string;
sort?: string;
order?: string;
}) => {
return request.post("/Assets/v1/pc/list", data || {});
};
/** 获取PC详情 */
export const fetchPCDetail = (id: number) => {
return request.get(`/Assets/v1/pc/detail/${id}`);
};
/** 创建PC */
export const createPC = (data: any) => {
return request.post("/Assets/v1/pc/create", data);
};
/** 更新PC */
export const updatePC = (data: any) => {
return request.put("/Assets/v1/pc/update", data);
};
/** 删除PC */
export const deletePC = (id: number) => {
return request.delete(`/Assets/v1/pc/delete/${id}`);
};
/** 获取机柜列表(用于下拉选择) */
export const fetchRackListForSelect = (datacenterId?: number) => {
return request.get("/Assets/v1/rack/all", { params: { datacenter_id: datacenterId } });
};

39
src/api/ops/report.ts Normal file
View File

@@ -0,0 +1,39 @@
import { request } from "@/api/request";
// ============ 监测指标类接口 ============
/** 监测指标TOPN */
export const fetchMetricsTopN = (params: any) =>
request.get("/DC-Control/v1/reports/metrics/topn", { params });
/** 监测指标汇总 */
export const fetchMetricsSummary = (params: any) =>
request.get("/DC-Control/v1/reports/metrics/summary", { params });
/** 监测指标汇总导出 */
export const exportMetricsSummary = (params: any) =>
request.get("/DC-Control/v1/reports/metrics/export", { params, responseType: 'blob' });
// ============ 流量报表接口 ============
/** 流量报表汇总 */
export const fetchTrafficSummary = (params: any) =>
request.get("/DC-Control/v1/reports/traffic/summary", { params });
/** 流量报表趋势 */
export const fetchTrafficTrend = (params: any) =>
request.get("/DC-Control/v1/reports/traffic/trend", { params });
/** 流量报表导出 */
export const exportTrafficReport = (params: any) =>
request.get("/DC-Control/v1/reports/traffic/export", { params, responseType: 'blob' });
// ============ 状态报表接口 ============
/** 服务器状态报表 */
export const fetchServerStatus = (params: any) =>
request.get("/DC-Control/v1/reports/servers/status", { params });
/** 网络设备状态报表 */
export const fetchNetworkDeviceStatus = (params: any) =>
request.get("/DC-Control/v1/reports/network-devices/status", { params });

40
src/api/ops/server.ts Normal file
View File

@@ -0,0 +1,40 @@
import { request } from "@/api/request";
/** 获取服务器列表(分页) */
export const fetchServerList = (data?: {
page?: number;
page_size?: number;
keyword?: string;
datacenter_id?: number;
rack_id?: number;
status?: string;
sort?: string;
order?: string;
}) => {
return request.post("/Assets/v1/server/list", data || {});
};
/** 获取服务器详情 */
export const fetchServerDetail = (id: number) => {
return request.get(`/Assets/v1/server/detail/${id}`);
};
/** 创建服务器 */
export const createServer = (data: any) => {
return request.post("/Assets/v1/server/create", data);
};
/** 更新服务器 */
export const updateServer = (data: any) => {
return request.put("/Assets/v1/server/update", data);
};
/** 删除服务器 */
export const deleteServer = (id: number) => {
return request.delete(`/Assets/v1/server/delete/${id}`);
};
/** 获取机柜列表(用于下拉选择) */
export const fetchRackListForSelect = (datacenterId?: number) => {
return request.get("/Assets/v1/rack/all", { params: { datacenter_id: datacenterId } });
};

31
src/api/ops/template.ts Normal file
View File

@@ -0,0 +1,31 @@
import { request } from "@/api/request";
/** 获取工单模板列表 */
export const fetchTemplates = (data?: {
page?: number,
page_size?: number,
status?: string
}) => {
return request.get("/Feedback/v1/templates", data ? { params: data } : undefined);
};
/** 创建工单模板 */
export const createTemplate = (data: any) => request.post("/Feedback/v1/templates", data);
/** 更新工单模板 */
export const updateTemplate = (id: number, data: any) => request.put(`/Feedback/v1/templates/${id}`, data);
/** 获取工单模板详情 */
export const fetchTemplateDetail = (id: number) => request.get(`/Feedback/v1/templates/${id}`);
/** 删除工单模板 */
export const deleteTemplate = (id: number) => request.delete(`/Feedback/v1/templates/${id}`);
/** 启用工单模板 */
export const activateTemplate = (id: number) => request.post(`/Feedback/v1/templates/${id}/activate`);
/** 禁用工单模板 */
export const deactivateTemplate = (id: number) => request.post(`/Feedback/v1/templates/${id}/deactivate`);
/** 按模板创建工单 */
export const createTicketByTemplate = (id: number) => request.post(`/Feedback/v1/templates/${id}/create-ticket`);

View File

@@ -1,13 +1,13 @@
<template>
<a-layout class="layout" :class="{ mobile: appStore.hideMenu }">
<div v-if="navbar && !route?.meta?.isNewTab" class="layout-navbar">
<div v-if="navbar && !route?.meta?.isNewTab && !route?.meta?.is_full" class="layout-navbar">
<NavBar />
</div>
<a-layout>
<a-layout>
<a-layout-sider
v-if="renderMenu"
v-show="!hideMenu && !route?.meta?.isNewTab"
v-show="!hideMenu && !route?.meta?.isNewTab && !route?.meta?.is_full"
class="layout-sider"
:breakpoint="'xl'"
:collapsible="true"
@@ -132,8 +132,8 @@ watch(
)
const paddingStyle = computed(() => {
const paddingLeft = renderMenu.value && !hideMenu.value ? { paddingLeft: `${menuWidth.value}px` } : {}
const paddingTop = navbar.value ? { paddingTop: navbarHeight } : {}
const paddingLeft = renderMenu.value && !hideMenu.value && !route?.meta?.is_full ? { paddingLeft: `${menuWidth.value}px` } : {}
const paddingTop = navbar.value && !route?.meta?.is_full ? { paddingTop: navbarHeight } : {}
return { ...paddingLeft, ...paddingTop }
})
const setCollapsed = (val: boolean) => {

View File

@@ -33,6 +33,8 @@ export default {
'menu.ops.systemSettings.menuManagement': 'Menu Management',
'menu.ops.systemSettings.systemLogs': 'System Logs',
'menu.ops.webTest': 'Web Test',
'menu.ops.report': 'Report Management',
'menu.ops.report.history': 'Report History',
'menu.management': 'Menu Management',
'menu.addRoot': 'Add Root Menu',
'menu.tip': 'Click a menu item to edit, hover to show action buttons',

View File

@@ -33,6 +33,8 @@ export default {
'menu.ops.systemSettings.menuManagement': '菜单管理',
'menu.ops.systemSettings.systemLogs': '系统日志',
'menu.ops.webTest': '网页测试',
'menu.ops.report': '报表管理',
'menu.ops.report.history': '报表历史',
'menu.management': '菜单管理',
'menu.addRoot': '添加根菜单',
'menu.tip': '点击菜单项可编辑,悬停显示操作按钮',

View File

@@ -1,4 +1,4 @@
import { DEFAULT_LAYOUT } from './routes/base'
import { DEFAULT_LAYOUT, STANDALONE_LAYOUT } from './routes/base'
import type { AppRouteRecordRaw } from './routes/types'
import type { TreeNodeBase } from '@/utils/tree'
@@ -23,6 +23,8 @@ export interface ServerMenuItem extends TreeNodeBase {
requiresAuth?: boolean
roles?: string[]
children?: ServerMenuItem[]
is_full?: boolean // 是否为独立页面(不包含菜单栏)
hide_menu?: boolean // 是否隐藏菜单栏
[key: string]: any
}
@@ -74,42 +76,60 @@ export function transformMenuToRoutes(menuItems: ServerMenuItem[]): AppRouteReco
const routes: AppRouteRecordRaw[] = []
for (const item of menuItems) {
// 根据 is_full 决定如何设置 component
let routeComponent: AppRouteRecordRaw['component']
if (item.is_full) {
// 独立页面:直接加载视图组件,不使用布局
if (item.component) {
routeComponent = loadViewComponent(item.component)
} else {
// 如果没有 component使用默认重定向页面
routeComponent = () => import('@/views/redirect/index.vue')
}
} else {
// 非独立页面:使用默认布局
routeComponent = DEFAULT_LAYOUT
}
const route: AppRouteRecordRaw = {
path: item.menu_path || '',
name: item.title || item.name || `menu_${item.id}`,
component: routeComponent,
meta: {
// ...item,
locale: item.locale || item.title,
requiresAuth: item.requiresAuth !== false,
icon: item.icon || item?.menu_icon,
order: item.sort_key ?? item.order,
hideInMenu: item.hideInMenu,
hideInMenu: item.hideInMenu || item.hide_menu,
hideChildrenInMenu: item.hideChildrenInMenu,
roles: item.roles,
isNewTab: item.is_new_tab,
},
component: DEFAULT_LAYOUT,
}
// 处理子菜单
if (item.children && item.children.length > 0) {
// 传递父级的 component 和 path 给子路由处理函数
route.children = transformChildRoutes(item.children, item.component, item.menu_path)
} else if (item.component) {
// 一级菜单没有 children 但有 component创建一个空路径的子路由
const routeName = route.name
route.children = [
{
path: item.menu_path || '',
name: typeof routeName === 'string' ? `${routeName}Index` : `menu_${item.id}_index`,
component: loadViewComponent(item.component),
meta: {
locale: item.locale || item.title,
requiresAuth: item.requiresAuth !== false,
isNewTab: item.is_new_tab,
// 非独立页面需要处理子菜单
if (!item.is_full) {
if (item.children && item.children.length > 0) {
// 传递父级的 component 和 path 给子路由处理函数
route.children = transformChildRoutes(item.children, item.component, item.menu_path, false)
} else if (item.component) {
// 一级菜单没有 children 但有 component创建一个空路径的子路由
const routeName = route.name
route.children = [
{
path: item.menu_path || '',
name: typeof routeName === 'string' ? `${routeName}Index` : `menu_${item.id}_index`,
component: loadViewComponent(item.component),
meta: {
locale: item.locale || item.title,
requiresAuth: item.requiresAuth !== false,
isNewTab: item.is_new_tab,
},
},
},
]
]
}
}
routes.push(route)
@@ -158,12 +178,14 @@ function extractRelativePath(childPath: string, parentPath: string): string {
* @param children 子菜单项
* @param parentComponent 父级菜单的 component 字段(用于子菜单没有 component 时继承)
* @param parentPath 父级菜单的路径(用于计算相对路径)
* @param parentIsFull 父级菜单的 is_full 字段
* @returns 子路由配置数组
*/
function transformChildRoutes(
children: ServerMenuItem[],
parentComponent?: string,
parentPath?: string
parentPath?: string,
parentIsFull?: boolean
): AppRouteRecordRaw[] {
return children.map((child) => {
// 优先使用子菜单自己的 component否则继承父级的 component
@@ -180,6 +202,7 @@ function transformChildRoutes(
locale: child.locale || child.title,
requiresAuth: child.requiresAuth !== false,
roles: child.roles,
hideInMenu: child.hideInMenu || child.hide_menu,
},
component: componentPath
? loadViewComponent(componentPath)
@@ -191,7 +214,8 @@ function transformChildRoutes(
route.children = transformChildRoutes(
child.children,
child.component || parentComponent,
childFullPath // 传递当前子菜单的完整路径作为下一层的父路径
childFullPath, // 传递当前子菜单的完整路径作为下一层的父路径
child.is_full || parentIsFull // 传递 is_full 标志
)
}

View File

@@ -2,6 +2,7 @@ import { REDIRECT_ROUTE_NAME } from '@/router/constants'
import type { RouteRecordRaw } from 'vue-router'
export const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue')
export const STANDALONE_LAYOUT = () => import('@/layout/standalone-layout.vue')
export const REDIRECT_MAIN: RouteRecordRaw = {
path: '/redirect',

View File

@@ -73,6 +73,16 @@ const OPS: AppRouteRecordRaw = {
roles: ['*'],
},
},
{
path: 'report/history',
name: 'ReportHistory',
component: () => import('@/views/ops/pages/report/history/index.vue'),
meta: {
locale: 'menu.ops.report.history',
requiresAuth: true,
roles: ['*'],
},
},
],
}

View File

@@ -0,0 +1,45 @@
import { DEFAULT_LAYOUT } from '../base'
import { AppRouteRecordRaw } from '../types'
const REMOTE: AppRouteRecordRaw = {
path: '/dc',
name: 'DC',
component: DEFAULT_LAYOUT,
meta: {
locale: 'menu.dc',
requiresAuth: true,
icon: 'icon-desktop',
order: 99,
hideInMenu: true,
},
children: [
{
path: 'detail',
name: 'DCDetail',
component: () => import('@/views/ops/pages/dc/detail/index.vue'),
meta: {
locale: 'menu.dc.detail',
requiresAuth: true,
roles: ['*'],
hideInMenu: true,
is_full: true,
isNewTab: true,
},
},
{
path: 'remote',
name: 'DCRemote',
component: () => import('@/views/ops/pages/dc/remote/index.vue'),
meta: {
locale: 'menu.dc.remote',
requiresAuth: true,
roles: ['*'],
hideInMenu: true,
is_full: true,
isNewTab: true,
},
},
],
}
export default REMOTE

View File

@@ -0,0 +1,430 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? `编辑工单模板 - ${form.template_name}` : '新建工单模板'"
:width="800"
:footer="false"
@update:visible="handleVisibleChange"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
layout="vertical"
@submit="handleSubmit"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="模板名称" field="template_name">
<a-input v-model="form.template_name" placeholder="请输入模板名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="模板编码" field="template_code">
<a-input
v-model="form.template_code"
placeholder="请输入模板编码"
:disabled="isEdit"
/>
<template v-if="isEdit" #extra>
<span style="color: var(--color-text-3); font-size: 12px;">模板编码创建后不可修改</span>
</template>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="描述" field="description">
<a-textarea
v-model="form.description"
placeholder="请输入模板描述"
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
<a-divider orientation="left">工单默认值</a-divider>
<a-form-item label="默认标题" field="title">
<a-input v-model="form.title" placeholder="请输入工单默认标题" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="类型" field="type">
<a-select v-model="form.type" placeholder="请选择类型" allow-clear>
<a-option value="incident">事件</a-option>
<a-option value="request">请求</a-option>
<a-option value="change">变更</a-option>
<a-option value="problem">问题</a-option>
<a-option value="consultation">咨询</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="优先级" field="priority">
<a-select v-model="form.priority" placeholder="请选择优先级" allow-clear>
<a-option value="low"></a-option>
<a-option value="medium"></a-option>
<a-option value="high"></a-option>
<a-option value="critical">紧急</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="默认分类ID" field="category_id">
<a-input-number
v-model="form.category_id"
placeholder="请输入分类ID"
:min="0"
style="width: 100%;"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="默认处理人ID" field="assignee_id">
<a-input-number
v-model="form.assignee_id"
placeholder="请输入处理人ID"
:min="0"
style="width: 100%;"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="默认内容" field="content">
<a-textarea
v-model="form.content"
placeholder="请输入工单默认内容"
:auto-size="{ minRows: 3, maxRows: 6 }"
/>
</a-form-item>
<a-divider orientation="left">触发配置</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="触发类型" field="trigger_type">
<a-select v-model="form.trigger_type" placeholder="请选择触发类型" allow-clear>
<a-option value="manual">手动</a-option>
<a-option value="scheduled">定时</a-option>
<a-option value="event">事件</a-option>
<a-option value="api">API</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="定时表达式"
field="cron_expression"
:disabled="form.trigger_type !== 'scheduled'"
>
<a-input
v-model="form.cron_expression"
placeholder="请输入Cron表达式"
:disabled="form.trigger_type !== 'scheduled'"
/>
<template v-if="form.trigger_type === 'scheduled'" #extra>
<span style="color: var(--color-text-3); font-size: 12px;">示例: 0 0 * * *</span>
</template>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="触发规则" field="trigger_rule">
<a-textarea
v-model="form.trigger_rule"
placeholder='请输入JSON格式的触发规则例如: {"key": "value"}'
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
<a-divider orientation="left">自动化配置</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="自动分配" field="auto_assign">
<a-switch v-model="form.auto_assign" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="自动接单" field="auto_accept">
<a-switch v-model="form.auto_accept" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="SLA(分钟)" field="sla_duration">
<a-input-number
v-model="form.sla_duration"
placeholder="请输入SLA"
:min="0"
style="width: 100%;"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="分配规则" field="assign_rule">
<a-textarea
v-model="form.assign_rule"
placeholder='请输入JSON格式的分配规则例如: {"rule": "value"}'
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
<a-divider orientation="left">表单与通知配置</a-divider>
<a-form-item label="字段配置" field="field_config">
<a-textarea
v-model="form.field_config"
placeholder='请输入JSON格式的字段配置例如: {"fields": []}'
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
<a-form-item label="通知配置" field="notify_config">
<a-textarea
v-model="form.notify_config"
placeholder='请输入JSON格式的通知配置例如: {"channels": []}'
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
<a-divider orientation="left">扩展配置</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="标签" field="tags">
<a-textarea
v-model="form.tags"
placeholder='请输入JSON格式的标签例如: ["tag1", "tag2"]'
:auto-size="{ minRows: 2, maxRows: 3 }"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="元数据" field="metadata">
<a-textarea
v-model="form.metadata"
placeholder='请输入JSON格式的元数据例如: {"key": "value"}'
:auto-size="{ minRows: 2, maxRows: 3 }"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit" :loading="loading">
{{ isEdit ? '更新' : '创建' }}
</a-button>
<a-button @click="handleCancel">取消</a-button>
</a-space>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { createTemplate, updateTemplate, fetchTemplateDetail } from '@/api/ops/template'
interface Props {
visible: boolean
templateId?: number
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref()
const loading = ref(false)
const isEdit = computed(() => !!props.templateId)
const form = reactive({
template_name: '',
template_code: '',
description: '',
title: '',
type: '',
priority: '',
category_id: undefined as number | undefined,
assignee_id: undefined as number | undefined,
content: '',
trigger_type: '',
trigger_rule: '',
cron_expression: '',
sla_duration: undefined as number | undefined,
field_config: '',
auto_assign: false,
auto_accept: false,
assign_rule: '',
notify_config: '',
tags: '',
metadata: '',
})
const rules = {
template_name: [{ required: true, message: '请输入模板名称' }],
template_code: [{ required: true, message: '请输入模板编码' }],
title: [{ required: true, message: '请输入默认标题' }],
}
// 监听弹窗显示状态,如果是编辑模式则加载详情
watch(
() => props.visible,
async (val) => {
if (val && props.templateId) {
await loadTemplateDetail()
} else if (val && !props.templateId) {
resetForm()
}
}
)
// 加载模板详情
const loadTemplateDetail = async () => {
try {
loading.value = true
const res = await fetchTemplateDetail(props.templateId!)
if (res.code === 0 && res.details) {
const data = res.details
Object.assign(form, {
template_name: data.template_name || '',
template_code: data.template_code || '',
description: data.description || '',
title: data.title || '',
type: data.type || '',
priority: data.priority || '',
category_id: data.category_id,
assignee_id: data.assignee_id,
content: data.content || '',
trigger_type: data.trigger_type || '',
trigger_rule: data.trigger_rule || '',
cron_expression: data.cron_expression || '',
sla_duration: data.sla_duration,
field_config: data.field_config || '',
auto_assign: data.auto_assign || false,
auto_accept: data.auto_accept || false,
assign_rule: data.assign_rule || '',
notify_config: data.notify_config || '',
tags: data.tags || '',
metadata: data.metadata || '',
})
}
} catch (error) {
console.error('加载模板详情失败:', error)
Message.error('加载模板详情失败')
} finally {
loading.value = false
}
}
// 重置表单
const resetForm = () => {
formRef.value?.resetFields()
Object.assign(form, {
template_name: '',
template_code: '',
description: '',
title: '',
type: '',
priority: '',
category_id: undefined,
assignee_id: undefined,
content: '',
trigger_type: '',
trigger_rule: '',
cron_expression: '',
sla_duration: undefined,
field_config: '',
auto_assign: false,
auto_accept: false,
assign_rule: '',
notify_config: '',
tags: '',
metadata: '',
})
}
// 验证JSON格式
const validateJSON = (value: string) => {
if (!value || value.trim() === '') return true
try {
JSON.parse(value)
return true
} catch {
return false
}
}
// 提交表单
const handleSubmit = async () => {
try {
const valid = await formRef.value?.validate()
if (!valid) return
// 验证JSON字段
const jsonFields = ['trigger_rule', 'field_config', 'assign_rule', 'notify_config', 'tags', 'metadata']
for (const field of jsonFields) {
const value = form[field as keyof typeof form] as string
if (value && value.trim() !== '' && !validateJSON(value)) {
Message.error(`${field} 必须是合法的 JSON 格式`)
return
}
}
loading.value = true
// 编辑时不包含 template_code
const submitData: any = { ...form }
if (isEdit.value) {
delete submitData.template_code
}
const res = isEdit.value
? await updateTemplate(props.templateId!, submitData)
: await createTemplate(submitData)
if (res.code === 0) {
Message.success(isEdit.value ? '更新成功' : '创建成功')
emit('success')
handleCancel()
} else {
Message.error(res.msg || (isEdit.value ? '更新失败' : '创建失败'))
}
} catch (error: any) {
console.error('提交失败:', error)
Message.error(error.msg || '提交失败')
} finally {
loading.value = false
}
}
// 取消
const handleCancel = () => {
resetForm()
emit('update:visible', false)
}
// 处理可见性变化
const handleVisibleChange = (value: boolean) => {
emit('update:visible', value)
}
</script>
<script lang="ts">
export default {
name: 'TemplateFormDialog',
}
</script>

View File

@@ -0,0 +1,113 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
export const columns: TableColumnData[] = [
{
title: '模板名称',
dataIndex: 'template_name',
width: 180,
ellipsis: true,
tooltip: true,
},
{
title: '模板编码',
dataIndex: 'template_code',
width: 160,
ellipsis: true,
tooltip: true,
},
{
title: '状态',
dataIndex: 'status',
slotName: 'status',
width: 100,
},
{
title: '默认标题',
dataIndex: 'title',
width: 180,
ellipsis: true,
tooltip: true,
},
{
title: '类型',
dataIndex: 'type',
slotName: 'type',
width: 100,
},
{
title: '优先级',
dataIndex: 'priority',
slotName: 'priority',
width: 100,
},
{
title: '默认分类',
dataIndex: 'category_id',
width: 100,
},
{
title: '默认处理人',
dataIndex: 'assignee_id',
width: 100,
},
{
title: '触发类型',
dataIndex: 'trigger_type',
slotName: 'trigger_type',
width: 100,
},
{
title: '定时表达式',
dataIndex: 'cron_expression',
width: 160,
ellipsis: true,
tooltip: true,
},
{
title: '自动分配',
dataIndex: 'auto_assign',
slotName: 'auto_assign',
width: 100,
},
{
title: '自动接单',
dataIndex: 'auto_accept',
slotName: 'auto_accept',
width: 100,
},
{
title: 'SLA(分钟)',
dataIndex: 'sla_duration',
width: 100,
},
{
title: '使用次数',
dataIndex: 'usage_count',
width: 100,
},
{
title: '创建人',
dataIndex: 'creator_name',
width: 120,
ellipsis: true,
},
{
title: '创建时间',
dataIndex: 'created_at',
slotName: 'created_at',
width: 180,
},
{
title: '最后修改',
dataIndex: 'updated_at',
slotName: 'updated_at',
width: 180,
},
{
title: '操作',
dataIndex: 'actions',
slotName: 'actions',
fixed: 'right',
width: 300,
},
]

View File

@@ -0,0 +1,15 @@
import type { FormItem } from '@/components/search-form/types';
export const searchFormConfig: FormItem[] = [
{
field: 'status',
label: '状态',
type: 'select',
placeholder: '请选择状态',
options: [
{ label: '全部', value: '' },
{ label: '启用', value: 'active' },
{ label: '禁用', value: 'inactive' },
],
},
];

View File

@@ -0,0 +1,396 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="工单模板管理"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<!-- 工具栏额外按钮 -->
<template #toolbar-extra>
<a-button type="primary" @click="handleCreate">
<template #icon>
<icon-plus />
</template>
新建模板
</a-button>
</template>
<!-- 状态 -->
<template #status="{ record }">
<a-tag :color="record.status === 'active' ? 'green' : 'gray'">
{{ record.status === 'active' ? '启用' : '禁用' }}
</a-tag>
</template>
<!-- 类型 -->
<template #type="{ record }">
<a-tag>{{ getTypeText(record.type) }}</a-tag>
</template>
<!-- 优先级 -->
<template #priority="{ record }">
<a-tag :color="getPriorityColor(record.priority)">
{{ getPriorityText(record.priority) }}
</a-tag>
</template>
<!-- 触发类型 -->
<template #trigger_type="{ record }">
<a-tag>{{ getTriggerTypeText(record.trigger_type) }}</a-tag>
</template>
<!-- 自动分配 -->
<template #auto_assign="{ record }">
<a-tag :color="record.auto_assign ? 'green' : 'gray'">
{{ record.auto_assign ? '是' : '否' }}
</a-tag>
</template>
<!-- 自动接单 -->
<template #auto_accept="{ record }">
<a-tag :color="record.auto_accept ? 'green' : 'gray'">
{{ record.auto_accept ? '是' : '否' }}
</a-tag>
</template>
<!-- 创建时间 -->
<template #created_at="{ record }">
{{ formatDate(record.created_at) }}
</template>
<!-- 最后修改 -->
<template #updated_at="{ record }">
{{ formatDate(record.updated_at) }}
</template>
<!-- 操作 -->
<template #actions="{ record }">
<a-space size="small">
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button
v-if="record.status === 'inactive'"
type="text"
size="small"
@click="handleActivate(record)"
>
启用
</a-button>
<a-button
v-if="record.status === 'active'"
type="text"
size="small"
@click="handleDeactivate(record)"
>
禁用
</a-button>
<a-button
v-if="record.status === 'active'"
type="text"
size="small"
@click="handleCreateTicket(record)"
>
按模板创建
</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
删除
</a-button>
</a-space>
</template>
</search-table>
<!-- 新建/编辑模板弹窗 -->
<template-form-dialog
v-model:visible="formDialogVisible"
:template-id="currentTemplateId"
@success="handleFormSuccess"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import { columns as columnsConfig } from './config/columns'
import {
fetchTemplates,
deleteTemplate,
activateTemplate,
deactivateTemplate,
createTicketByTemplate,
} from '@/api/ops/template'
import TemplateFormDialog from './components/TemplateFormDialog.vue'
// 状态管理
const loading = ref(false)
const tableData = ref<any[]>([])
// 表单模型
const formModel = ref({
status: '',
})
// 分页
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => searchFormConfig)
// 表格列配置
const columns = computed(() => columnsConfig)
// 表单弹窗
const formDialogVisible = ref(false)
const currentTemplateId = ref<number | undefined>(undefined)
// 获取模板列表
const fetchTemplatesData = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
page_size: pagination.pageSize,
}
if (formModel.value.status) {
params.status = formModel.value.status
}
const res = await fetchTemplates(params)
tableData.value = res.details?.items || []
pagination.total = res.details?.total || 0
} catch (error) {
console.error('获取模板列表失败:', error)
Message.error('获取模板列表失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// 获取类型文本
const getTypeText = (type: string) => {
const typeMap: Record<string, string> = {
incident: '事件',
request: '请求',
change: '变更',
problem: '问题',
consultation: '咨询',
}
return typeMap[type] || '-'
}
// 获取优先级文本
const getPriorityText = (priority: string) => {
const priorityMap: Record<string, string> = {
low: '低',
medium: '中',
high: '高',
critical: '紧急',
}
return priorityMap[priority] || '-'
}
// 获取优先级颜色
const getPriorityColor = (priority: string) => {
const colorMap: Record<string, string> = {
low: 'blue',
medium: 'green',
high: 'orange',
critical: 'red',
}
return colorMap[priority] || 'gray'
}
// 获取触发类型文本
const getTriggerTypeText = (triggerType: string) => {
const typeMap: Record<string, string> = {
manual: '手动',
scheduled: '定时',
event: '事件',
api: 'API',
}
return typeMap[triggerType] || '-'
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchTemplatesData()
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 重置
const handleReset = () => {
formModel.value = {
status: '',
}
pagination.current = 1
fetchTemplatesData()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchTemplatesData()
}
// 刷新
const handleRefresh = () => {
fetchTemplatesData()
Message.success('数据已刷新')
}
// 新建模板
const handleCreate = () => {
currentTemplateId.value = undefined
formDialogVisible.value = true
}
// 编辑模板
const handleEdit = (record: any) => {
currentTemplateId.value = record.id
formDialogVisible.value = true
}
// 启用模板
const handleActivate = async (record: any) => {
try {
const res = await activateTemplate(record.id)
if (res.code === 0) {
Message.success('启用成功')
fetchTemplatesData()
} else {
Message.error(res.msg || '启用失败')
}
} catch (error) {
console.error('启用模板失败:', error)
Message.error('启用模板失败')
}
}
// 禁用模板
const handleDeactivate = async (record: any) => {
try {
const res = await deactivateTemplate(record.id)
if (res.code === 0) {
Message.success('禁用成功')
fetchTemplatesData()
} else {
Message.error(res.msg || '禁用失败')
}
} catch (error) {
console.error('禁用模板失败:', error)
Message.error('禁用模板失败')
}
}
// 删除模板
const handleDelete = (record: any) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除模板「${record.template_name}」吗?删除后将无法恢复,且可能影响历史工单的引用。`,
okText: '删除',
okButtonProps: { status: 'danger' },
cancelText: '取消',
onOk: async () => {
try {
const res = await deleteTemplate(record.id)
if (res.code === 0) {
Message.success('删除成功')
fetchTemplatesData()
} else {
Message.error(res.msg || '删除失败')
}
} catch (error) {
console.error('删除模板失败:', error)
Message.error('删除模板失败')
}
},
})
}
// 按模板创建工单
const handleCreateTicket = async (record: any) => {
try {
const res = await createTicketByTemplate(record.id)
if (res.code === 0) {
Message.success('工单创建成功')
// 可以在这里跳转到工单详情页
// if (res.details?.id) {
// router.push(`/feedback/ticket/${res.details.id}`)
// }
fetchTemplatesData()
} else {
Message.error(res.msg || '工单创建失败')
}
} catch (error: any) {
console.error('按模板创建工单失败:', error)
Message.error(error.msg || '工单创建失败')
}
}
// 表单提交成功
const handleFormSuccess = () => {
fetchTemplatesData()
}
// 初始化加载数据
onMounted(() => {
fetchTemplatesData()
})
</script>
<script lang="ts">
export default {
name: 'TemplateList',
}
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,754 @@
<template>
<div class="server-detail-page">
<!-- 顶部导航栏 -->
<div class="page-header">
<div class="header-left">
<a-button type="text" @click="goBack">
<template #icon><icon-left /></template>
返回
</a-button>
<a-divider direction="vertical" />
<span class="server-name">{{ serverName }}</span>
<a-tag :color="getStatusColor(serverStatus)" size="small">{{ getStatusText(serverStatus) }}</a-tag>
</div>
<div class="header-right">
<a-button @click="handleRemoteControl">
<template #icon><icon-desktop /></template>
远程登录
</a-button>
<a-button type="primary" @click="handleRestart">
<template #icon><icon-refresh /></template>
重启
</a-button>
</div>
</div>
<!-- 主体内容 -->
<div class="page-body">
<a-tabs v-model:active-key="activeTab" class="detail-tabs">
<a-tab-pane key="overview" title="实例详情">
<a-descriptions :column="2" bordered class="info-descriptions">
<a-descriptions-item label="唯一标识">{{ record.unique_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="服务器名称">{{ record.name || '-' }}</a-descriptions-item>
<a-descriptions-item label="服务器类型">{{ getServerTypeText(record.server_type) }}</a-descriptions-item>
<a-descriptions-item label="操作系统">{{ record.os || '-' }}</a-descriptions-item>
<a-descriptions-item label="位置信息">{{ record.location || '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">{{ record.tags || '-' }}</a-descriptions-item>
<a-descriptions-item label="IP地址">{{ record.ip || '-' }}</a-descriptions-item>
<a-descriptions-item label="远程端口">{{ record.remote_port || '-' }}</a-descriptions-item>
<a-descriptions-item label="Agent URL">{{ record.agent_url || '-' }}</a-descriptions-item>
<a-descriptions-item label="数据采集">
<a-tag :color="record.data_collection ? 'green' : 'gray'">
{{ record.data_collection ? '已开启' : '未开启' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="采集间隔">{{ record.collection_interval ? `${record.collection_interval}分钟` : '-' }}</a-descriptions-item>
<a-descriptions-item label="备注信息" :span="2">{{ record.remark || '-' }}</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="monitor" title="监控">
<div class="monitor-section">
<div class="time-selector">
<a-radio-group v-model="timeRange" type="button">
<a-radio value="1h">1小时</a-radio>
<a-radio value="3h">3小时</a-radio>
<a-radio value="6h">6小时</a-radio>
<a-radio value="12h">12小时</a-radio>
<a-radio value="1d">1</a-radio>
<a-radio value="3d">3</a-radio>
<a-radio value="7d">7</a-radio>
<a-radio value="14d">14</a-radio>
<a-radio value="custom">自定义</a-radio>
</a-radio-group>
</div>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">CPU使用率_宿主机视角 (%)</span>
<div class="chart-actions">
<a-select v-model="cpuMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="cpuOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>(ECS)CPU使用率_平均值</span>
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)CPU使用率_最小值</span>
<span class="legend-item"><span class="legend-dot purple"></span>(ECS)CPU使用率_最大值</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">总带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="bandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>内网流入带宽</span>
<span class="legend-item"><span class="legend-dot blue"></span>内网流出带宽</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="publicBandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="publicBandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>公网流入带宽</span>
<span class="legend-item"><span class="legend-dot green"></span>公网流出带宽</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网流出带宽使用率 (%)</span>
<div class="chart-actions">
<a-select v-model="bandwidthUsageMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthUsageOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)公网流出带宽使用率</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入BPS (bytes/s)</span>
<div class="chart-actions">
<a-select v-model="diskMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskBpsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘读取BPS</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘写入BPS</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入IOPS (Count/Second)</span>
<div class="chart-actions">
<a-select v-model="diskIopsMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskIopsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘每秒读取次数</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘每秒写入次数</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">同时连接数 (Count)</span>
<div class="chart-actions">
<a-select v-model="connectionMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="connectionOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>同时连接数</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA流量使用信息 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="rdmaMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA资源使用信息 (Count)</span>
<div class="chart-actions">
<a-select v-model="rdmaResourceMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaResourceOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
</a-row>
</div>
</a-tab-pane>
<a-tab-pane key="operation" title="操作记录">
<div class="operation-records">
<a-empty description="暂无操作记录" />
</div>
</a-tab-pane>
</a-tabs>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Message, Modal } from '@arco-design/web-vue'
import { IconLeft, IconDesktop, IconRefresh, IconSettings } from '@arco-design/web-vue/es/icon'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
} from 'echarts/components'
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
])
const route = useRoute()
const router = useRouter()
// 服务器信息
const serverId = computed(() => route.query.id as string)
const serverName = computed(() => route.query.name as string || '服务器详情')
const serverStatus = computed(() => route.query.status as string || 'offline')
// 模拟从API获取数据
const record = ref<any>({
id: serverId.value,
name: route.query.name as string || '服务器详情',
status: route.query.status as string || 'offline',
server_type: 'physical',
os: 'CentOS 7.9',
unique_id: `SERVER-${serverId.value}`,
location: '机房A-机柜01',
tags: '生产环境,核心服务',
ip: route.query.ip as string || '-',
remote_port: 22,
agent_url: 'http://192.168.1.100:8080',
data_collection: true,
collection_interval: 5,
remark: '核心业务服务器',
})
const activeTab = ref('overview')
const timeRange = ref('1h')
const cpuMetric = ref('average')
const bandwidthMetric = ref('average')
const publicBandwidthMetric = ref('average')
const bandwidthUsageMetric = ref('average')
const diskMetric = ref('average')
const diskIopsMetric = ref('average')
const connectionMetric = ref('average')
const rdmaMetric = ref('average')
const rdmaResourceMetric = ref('average')
const generateTimeData = (count: number) => {
const now = Date.now()
const data = []
for (let i = count - 1; i >= 0; i--) {
data.push(new Date(now - i * 60000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }))
}
return data
}
const generateRandomData = (count: number, min: number, max: number) => {
return Array.from({ length: count }, () => Math.floor(Math.random() * (max - min + 1)) + min)
}
const timeData = generateTimeData(60)
const baseOption = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#e5e6eb',
textStyle: {
color: '#1d2129',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: timeData,
axisLabel: {
fontSize: 10,
},
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 10,
},
},
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
],
}
const cpuOption = computed(() => ({
...baseOption,
series: [
{
name: '平均值',
type: 'line',
smooth: true,
data: generateRandomData(60, 20, 80),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(0, 180, 42, 0.3)' },
{ offset: 1, color: 'rgba(0, 180, 42, 0.05)' },
],
},
},
},
{
name: '最小值',
type: 'line',
smooth: true,
data: generateRandomData(60, 10, 40),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '最大值',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 95),
lineStyle: { color: '#722ed1' },
itemStyle: { color: '#722ed1' },
},
],
}))
const bandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '内网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 100000, 500000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
{
name: '内网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 300000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const publicBandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 200000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '公网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 30000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const bandwidthUsageOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流出带宽使用率',
type: 'line',
smooth: true,
data: generateRandomData(60, 5, 30),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(22, 93, 255, 0.3)' },
{ offset: 1, color: 'rgba(22, 93, 255, 0.05)' },
],
},
},
},
],
}))
const diskBpsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 10000, 100000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 20000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const diskIopsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 800),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const connectionOption = computed(() => ({
...baseOption,
series: [
{
name: '同时连接数',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const rdmaOption = computed(() => ({
...baseOption,
series: [],
}))
const rdmaResourceOption = computed(() => ({
...baseOption,
series: [],
}))
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
online: 'green',
offline: 'red',
maintenance: 'orange',
retired: 'gray',
}
return colorMap[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
online: '在线',
offline: '离线',
maintenance: '维护中',
retired: '已退役',
}
return textMap[status || ''] || '-'
}
const getServerTypeText = (type?: string) => {
const typeMap: Record<string, string> = {
physical: '物理服务器',
virtual: '虚拟服务器',
cloud: '云服务器',
}
return typeMap[type || ''] || '-'
}
const handleRemoteControl = () => {
router.push({
path: '/dc/remote',
query: {
id: record.value.id,
name: record.value.name,
ip: record.value.ip,
status: record.value.status,
},
})
}
const handleRestart = () => {
Modal.confirm({
title: '确认重启',
content: `确认重启服务器 ${record.value.name} 吗?`,
onOk: () => {
Message.info('正在发送重启指令...')
},
})
}
const goBack = () => {
router.back()
}
</script>
<style scoped lang="less">
.server-detail-page {
height: 100vh;
display: flex;
flex-direction: column;
background: #f0f2f5;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e5e6eb;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.server-name {
font-size: 16px;
font-weight: 500;
}
}
.header-right {
display: flex;
gap: 12px;
}
}
.page-body {
flex: 1;
overflow: auto;
padding: 16px;
.detail-tabs {
background: #fff;
border-radius: 4px;
padding: 20px;
:deep(.arco-tabs-header) {
margin-bottom: 20px;
}
}
}
}
.info-descriptions {
:deep(.arco-descriptions-item-label) {
width: 140px;
background: #f7f8fa;
}
}
.monitor-section {
.time-selector {
margin-bottom: 20px;
}
.charts-row {
margin-bottom: 20px;
}
.chart-col {
margin-bottom: 20px;
}
.chart-card {
background: #fff;
border-radius: 4px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.chart-title {
font-size: 14px;
font-weight: 500;
color: #1d2129;
}
.chart-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.chart {
height: 200px;
}
.chart-legend {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 16px;
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #4e5969;
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.green {
background: #00b42a;
}
&.blue {
background: #165dff;
}
&.purple {
background: #722ed1;
}
}
}
}
}
}
.operation-records {
padding: 40px 0;
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<div class="remote-control">
<!-- 顶部栏 -->
<div class="header">
<div class="header-left">
<span class="title">{{ record.name || '远程控制' }}</span>
<a-tag :color="getStatusColor(record.status)" size="small">{{ getStatusText(record.status) }}</a-tag>
</div>
<a-button size="small" @click="handleClose">
<template #icon><icon-close /></template>
关闭
</a-button>
</div>
<!-- 登录界面 -->
<div v-if="!isConnected" class="login-box">
<a-card title="SSH 登录" class="login-card">
<a-form :model="loginForm" layout="vertical">
<a-form-item label="连接协议">
<a-radio-group v-model="loginForm.protocol" type="button">
<a-radio value="ssh">SSH</a-radio>
<a-radio value="tat">免密连接</a-radio>
</a-radio-group>
</a-form-item>
<a-row :gutter="16">
<a-col :span="16">
<a-form-item label="主机地址">
<a-input v-model="loginForm.host" placeholder="IP或域名" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="端口">
<a-input-number v-model="loginForm.port" :min="1" :max="65535" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="用户名">
<a-input v-model="loginForm.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="密码">
<a-input-password v-model="loginForm.password" placeholder="请输入密码" />
</a-form-item>
<a-space>
<a-button type="primary" @click="handleLogin" :loading="loginLoading">
连接
</a-button>
<a-button @click="handleClose">取消</a-button>
</a-space>
</a-form>
</a-card>
</div>
<!-- 终端界面 -->
<div v-else class="terminal-box">
<div class="terminal-toolbar">
<span class="terminal-info">已连接: {{ loginForm.username }}@{{ loginForm.host }}:{{ loginForm.port }}</span>
<a-space>
<a-button size="small" @click="handleDisconnect">断开</a-button>
<a-button size="small" @click="handleFullscreen">全屏</a-button>
</a-space>
</div>
<div class="terminal-content" ref="terminalRef">
<div v-for="(line, index) in terminalLines" :key="index" class="terminal-line">
<span v-if="line.type === 'input'" class="prompt">{{ line.prompt }}</span>
<span :class="line.type">{{ line.content }}</span>
</div>
<div class="terminal-input">
<span class="prompt">{{ prompt }}</span>
<input
ref="inputRef"
v-model="currentInput"
@keyup.enter="handleCommand"
class="command-input"
spellcheck="false"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, nextTick, onMounted, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconClose } from '@arco-design/web-vue/es/icon'
interface Props {
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['close'])
const isConnected = ref(false)
const loginLoading = ref(false)
const terminalRef = ref<HTMLElement>()
const inputRef = ref<HTMLInputElement>()
const loginForm = ref({
protocol: 'ssh',
host: props.record?.ip || '',
port: 22,
username: 'root',
password: '',
})
const terminalLines = ref<Array<{type: string, content: string, prompt?: string}>>([
{ type: 'output', content: `Welcome to ${props.record?.name || 'Server'}!` },
{ type: 'output', content: `Last login: ${new Date().toLocaleString()}` },
])
const currentInput = ref('')
const prompt = computed(() => `${loginForm.value.username}@${loginForm.value.host}:~# `)
const getStatusColor = (status?: string) => {
const map: Record<string, string> = { online: 'green', offline: 'red', maintenance: 'orange' }
return map[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const map: Record<string, string> = { online: '在线', offline: '离线', maintenance: '维护中' }
return map[status || ''] || '-'
}
const handleLogin = async () => {
if (!loginForm.value.host || !loginForm.value.username) {
Message.warning('请填写完整信息')
return
}
loginLoading.value = true
await new Promise(r => setTimeout(r, 1000))
isConnected.value = true
loginLoading.value = false
Message.success('连接成功')
nextTick(() => inputRef.value?.focus())
}
const handleCommand = () => {
const cmd = currentInput.value.trim()
if (!cmd) return
terminalLines.value.push({ type: 'input', content: cmd, prompt: prompt.value })
const commands: Record<string, string> = {
help: '可用命令: help, ls, pwd, whoami, date, clear, exit',
ls: 'bin boot dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var',
pwd: '/root',
whoami: loginForm.value.username,
date: new Date().toString(),
}
if (cmd === 'clear') {
terminalLines.value = []
} else if (cmd === 'exit') {
handleDisconnect()
} else {
terminalLines.value.push({ type: 'output', content: commands[cmd] || `命令未找到: ${cmd}` })
}
currentInput.value = ''
nextTick(() => {
terminalRef.value?.scrollTo(0, terminalRef.value.scrollHeight)
})
}
const handleDisconnect = () => {
isConnected.value = false
terminalLines.value = [
{ type: 'output', content: `Welcome to ${props.record?.name || 'Server'}!` },
{ type: 'output', content: `Last login: ${new Date().toLocaleString()}` },
]
Message.info('已断开连接')
}
const handleFullscreen = () => {
if (!document.fullscreenElement) {
terminalRef.value?.requestFullscreen()
} else {
document.exitFullscreen()
}
}
const handleClose = () => {
emit('close')
}
onMounted(() => {
if (props.record?.ip) {
loginForm.value.host = props.record.ip
}
})
</script>
<style scoped lang="less">
.remote-control {
height: 100vh;
display: flex;
flex-direction: column;
background: #f0f2f5;
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e5e6eb;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.title {
font-size: 16px;
font-weight: 500;
}
}
}
.login-box {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
.login-card {
width: 480px;
}
}
.terminal-box {
flex: 1;
display: flex;
flex-direction: column;
margin: 16px;
background: #1e1e1e;
border-radius: 4px;
overflow: hidden;
.terminal-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #2d2d2d;
border-bottom: 1px solid #3a3a3a;
.terminal-info {
color: #c9cdd4;
font-size: 13px;
}
}
.terminal-content {
flex: 1;
padding: 16px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
line-height: 1.6;
.terminal-line {
margin-bottom: 4px;
color: #fff;
.prompt {
color: #00ff00;
margin-right: 8px;
}
&.input {
color: #fff;
}
&.output {
color: #c9cdd4;
}
}
.terminal-input {
display: flex;
align-items: center;
.prompt {
color: #00ff00;
margin-right: 8px;
white-space: nowrap;
}
.command-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #fff;
font-family: inherit;
font-size: inherit;
caret-color: #00ff00;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,714 @@
<template>
<div class="server-detail">
<div class="detail-header">
<div class="server-info">
<h2>{{ record.name || '服务器详情' }}</h2>
<div class="info-tags">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
<a-tag color="blue">{{ record.server_type || '未知类型' }}</a-tag>
<a-tag color="cyan">{{ record.os || '未知系统' }}</a-tag>
</div>
</div>
<div class="header-actions">
<a-button @click="handleRemoteControl">
<template #icon>
<icon-desktop />
</template>
远程登录
</a-button>
<a-button type="primary" @click="handleRestart">
<template #icon>
<icon-refresh />
</template>
重启
</a-button>
</div>
</div>
<a-tabs v-model:active-tab="activeTab" class="detail-tabs">
<a-tab-pane key="overview" title="实例详情">
<a-descriptions :column="2" bordered class="info-descriptions">
<a-descriptions-item label="唯一标识">{{ record.unique_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="服务器名称">{{ record.name || '-' }}</a-descriptions-item>
<a-descriptions-item label="服务器类型">{{ getServerTypeText(record.server_type) }}</a-descriptions-item>
<a-descriptions-item label="操作系统">{{ record.os || '-' }}</a-descriptions-item>
<a-descriptions-item label="位置信息">{{ record.location || '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">{{ record.tags || '-' }}</a-descriptions-item>
<a-descriptions-item label="IP地址">{{ record.ip || '-' }}</a-descriptions-item>
<a-descriptions-item label="远程端口">{{ record.remote_port || '-' }}</a-descriptions-item>
<a-descriptions-item label="Agent URL">{{ record.agent_url || '-' }}</a-descriptions-item>
<a-descriptions-item label="数据采集">
<a-tag :color="record.data_collection ? 'green' : 'gray'">
{{ record.data_collection ? '已开启' : '未开启' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="采集间隔">{{ record.collection_interval ? `${record.collection_interval}分钟` : '-' }}</a-descriptions-item>
<a-descriptions-item label="备注信息" :span="2">{{ record.remark || '-' }}</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="monitor" title="监控">
<div class="monitor-section">
<div class="time-selector">
<a-radio-group v-model="timeRange" type="button">
<a-radio value="1h">1小时</a-radio>
<a-radio value="3h">3小时</a-radio>
<a-radio value="6h">6小时</a-radio>
<a-radio value="12h">12小时</a-radio>
<a-radio value="1d">1</a-radio>
<a-radio value="3d">3</a-radio>
<a-radio value="7d">7</a-radio>
<a-radio value="14d">14</a-radio>
<a-radio value="custom">自定义</a-radio>
</a-radio-group>
</div>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">CPU使用率_宿主机视角 (%)</span>
<div class="chart-actions">
<a-select v-model="cpuMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="cpuOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>(ECS)CPU使用率_平均值</span>
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)CPU使用率_最小值</span>
<span class="legend-item"><span class="legend-dot purple"></span>(ECS)CPU使用率_最大值</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">总带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="bandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>内网流入带宽</span>
<span class="legend-item"><span class="legend-dot blue"></span>内网流出带宽</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="publicBandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="publicBandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>公网流入带宽</span>
<span class="legend-item"><span class="legend-dot green"></span>公网流出带宽</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网流出带宽使用率 (%)</span>
<div class="chart-actions">
<a-select v-model="bandwidthUsageMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthUsageOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)公网流出带宽使用率</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入BPS (bytes/s)</span>
<div class="chart-actions">
<a-select v-model="diskMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskBpsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘读取BPS</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘写入BPS</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入IOPS (Count/Second)</span>
<div class="chart-actions">
<a-select v-model="diskIopsMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskIopsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘每秒读取次数</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘每秒写入次数</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">同时连接数 (Count)</span>
<div class="chart-actions">
<a-select v-model="connectionMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="connectionOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>同时连接数</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA流量使用信息 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="rdmaMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA资源使用信息 (Count)</span>
<div class="chart-actions">
<a-select v-model="rdmaResourceMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaResourceOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
</a-row>
</div>
</a-tab-pane>
<a-tab-pane key="operation" title="操作记录">
<div class="operation-records">
<a-empty description="暂无操作记录" />
</div>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconDesktop, IconRefresh, IconSettings } from '@arco-design/web-vue/es/icon'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
} from 'echarts/components'
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
])
interface Props {
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['remote-control', 'restart', 'close'])
const activeTab = ref('overview')
const timeRange = ref('1h')
const cpuMetric = ref('average')
const bandwidthMetric = ref('average')
const publicBandwidthMetric = ref('average')
const bandwidthUsageMetric = ref('average')
const diskMetric = ref('average')
const diskIopsMetric = ref('average')
const connectionMetric = ref('average')
const rdmaMetric = ref('average')
const rdmaResourceMetric = ref('average')
const generateTimeData = (count: number) => {
const now = Date.now()
const data = []
for (let i = count - 1; i >= 0; i--) {
data.push(new Date(now - i * 60000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }))
}
return data
}
const generateRandomData = (count: number, min: number, max: number) => {
return Array.from({ length: count }, () => Math.floor(Math.random() * (max - min + 1)) + min)
}
const timeData = generateTimeData(60)
const baseOption = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#e5e6eb',
textStyle: {
color: '#1d2129',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: timeData,
axisLabel: {
fontSize: 10,
},
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 10,
},
},
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
],
}
const cpuOption = computed(() => ({
...baseOption,
series: [
{
name: '平均值',
type: 'line',
smooth: true,
data: generateRandomData(60, 20, 80),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(0, 180, 42, 0.3)' },
{ offset: 1, color: 'rgba(0, 180, 42, 0.05)' },
],
},
},
},
{
name: '最小值',
type: 'line',
smooth: true,
data: generateRandomData(60, 10, 40),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '最大值',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 95),
lineStyle: { color: '#722ed1' },
itemStyle: { color: '#722ed1' },
},
],
}))
const bandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '内网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 100000, 500000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
{
name: '内网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 300000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const publicBandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 200000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '公网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 30000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const bandwidthUsageOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流出带宽使用率',
type: 'line',
smooth: true,
data: generateRandomData(60, 5, 30),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(22, 93, 255, 0.3)' },
{ offset: 1, color: 'rgba(22, 93, 255, 0.05)' },
],
},
},
},
],
}))
const diskBpsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 10000, 100000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 20000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const diskIopsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 800),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const connectionOption = computed(() => ({
...baseOption,
series: [
{
name: '同时连接数',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const rdmaOption = computed(() => ({
...baseOption,
series: [],
}))
const rdmaResourceOption = computed(() => ({
...baseOption,
series: [],
}))
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
online: 'green',
offline: 'red',
maintenance: 'orange',
retired: 'gray',
}
return colorMap[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
online: '在线',
offline: '离线',
maintenance: '维护中',
retired: '已退役',
}
return textMap[status || ''] || '-'
}
const getServerTypeText = (type?: string) => {
const typeMap: Record<string, string> = {
physical: '物理服务器',
virtual: '虚拟服务器',
cloud: '云服务器',
}
return typeMap[type || ''] || '-'
}
const handleRemoteControl = () => {
emit('remote-control')
}
const handleRestart = () => {
Message.info('正在发送重启指令...')
emit('restart')
}
</script>
<style scoped lang="less">
.server-detail {
padding: 20px;
background: #fff;
border-radius: 4px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #e5e6eb;
.server-info {
h2 {
margin: 0 0 12px 0;
font-size: 20px;
font-weight: 600;
color: #1d2129;
}
.info-tags {
display: flex;
gap: 8px;
}
}
.header-actions {
display: flex;
gap: 12px;
}
}
.detail-tabs {
:deep(.arco-tabs-header) {
margin-bottom: 20px;
}
}
.info-descriptions {
:deep(.arco-descriptions-item-label) {
width: 140px;
background: #f7f8fa;
}
}
.monitor-section {
.time-selector {
margin-bottom: 20px;
}
.charts-row {
margin-bottom: 20px;
}
.chart-col {
margin-bottom: 20px;
}
.chart-card {
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 4px;
padding: 16px;
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.chart-title {
font-size: 14px;
font-weight: 500;
color: #1d2129;
}
.chart-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.chart {
height: 200px;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5e6eb;
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #86909c;
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.green {
background: #00b42a;
}
&.blue {
background: #165dff;
}
&.purple {
background: #722ed1;
}
}
}
}
}
}
.operation-records {
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,235 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑服务器/PC' : '新增服务器/PC'"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleUpdateVisible"
:confirm-loading="confirmLoading"
width="800px"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="unique_id" label="唯一标识">
<a-input
v-model="formData.unique_id"
placeholder="输入为空系统自动生成UUID"
:disabled="isEdit"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="name" label="服务器名称">
<a-input v-model="formData.name" placeholder="请输入服务器名称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="server_type" label="服务器类型">
<a-select v-model="formData.server_type" placeholder="请选择服务器类型">
<a-option value="physical">物理服务器</a-option>
<a-option value="virtual">虚拟服务器</a-option>
<a-option value="cloud">云服务器</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="os" label="操作系统">
<a-select v-model="formData.os" placeholder="请选择操作系统">
<a-option value="windows">Windows</a-option>
<a-option value="linux">Linux</a-option>
<a-option value="other">其它</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="location" label="位置/机房信息">
<a-input-group>
<a-input
v-model="formData.location"
placeholder="请输入或选择位置信息"
/>
<a-select
v-model="selectedLocation"
placeholder="选择位置"
style="width: 200px"
@change="handleLocationSelect"
>
<a-option
v-for="location in locationOptions"
:key="location.value"
:value="location.value"
>
{{ location.label }}
</a-option>
</a-select>
</a-input-group>
</a-form-item>
<a-form-item field="tags" label="服务器标签">
<a-input v-model="formData.tags" placeholder="多个标签,逗号间隔" />
</a-form-item>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="ip" label="IP地址">
<a-input v-model="formData.ip" placeholder="可以输入多个IP逗号做分隔" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="remote_port" label="远程访问端口">
<a-input v-model="formData.remote_port" placeholder="为空则不可远程访问" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="agent_url" label="Agent URL配置">
<a-input v-model="formData.agent_url" placeholder="请输入Agent URL配置" />
</a-form-item>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="data_collection" label="数据采集">
<a-switch v-model="formData.data_collection" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item v-if="formData.data_collection" field="collection_interval" label="采集时间">
<a-select v-model="formData.collection_interval" placeholder="请选择采集时间">
<a-option :value="1">1分钟</a-option>
<a-option :value="5">5分钟</a-option>
<a-option :value="10">10分钟</a-option>
<a-option :value="30">30分钟</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="remark" label="备注信息">
<a-textarea
v-model="formData.remark"
placeholder="请输入备注信息"
:rows="4"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { v4 as uuidv4 } from 'uuid'
import type { FormInstance } from '@arco-design/web-vue'
interface Props {
visible: boolean
record?: any
}
const props = withDefaults(defineProps<Props>(), {
record: () => ({}),
})
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref<FormInstance>()
const confirmLoading = ref(false)
const selectedLocation = ref('')
const isEdit = computed(() => !!props.record?.id)
const formData = reactive({
unique_id: '',
name: '',
server_type: '',
os: '',
location: '',
tags: '',
ip: '',
remote_port: '',
agent_url: '',
data_collection: false,
collection_interval: 5,
remark: '',
})
const rules = {
name: [{ required: true, message: '请输入服务器名称' }],
server_type: [{ required: true, message: '请选择服务器类型' }],
os: [{ required: true, message: '请选择操作系统' }],
}
const locationOptions = ref([
{ label: 'A数据中心-3层-24机柜-5U位', value: 'A数据中心-3层-24机柜-5U位' },
{ label: 'A数据中心-3层-24机柜-6U位', value: 'A数据中心-3层-24机柜-6U位' },
{ label: 'B数据中心-1层-12机柜-1U位', value: 'B数据中心-1层-12机柜-1U位' },
{ label: 'B数据中心-1层-12机柜-2U位', value: 'B数据中心-1层-12机柜-2U位' },
{ label: 'C数据中心-2层-8机柜-3U位', value: 'C数据中心-2层-8机柜-3U位' },
])
watch(
() => props.visible,
(val) => {
if (val) {
if (isEdit.value && props.record) {
Object.assign(formData, props.record)
} else {
Object.assign(formData, {
unique_id: '',
name: '',
server_type: '',
os: '',
location: '',
tags: '',
ip: '',
remote_port: '',
agent_url: '',
data_collection: false,
collection_interval: 5,
remark: '',
})
}
}
}
)
const handleLocationSelect = (value: string) => {
formData.location = value
}
const handleOk = async () => {
try {
await formRef.value?.validate()
if (!formData.unique_id) {
formData.unique_id = uuidv4()
}
confirmLoading.value = true
await new Promise(resolve => setTimeout(resolve, 1000))
Message.success(isEdit.value ? '更新成功' : '创建成功')
emit('success')
handleCancel()
} catch (error) {
console.error('验证失败:', error)
} finally {
confirmLoading.value = false
}
}
const handleUpdateVisible = (value: boolean) => {
emit('update:visible', value)
}
const handleCancel = () => {
emit('update:visible', false)
formRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,81 @@
export const columns = [
{
dataIndex: 'id',
title: 'ID',
width: 80,
slotName: 'id',
},
{
dataIndex: 'unique_id',
title: '唯一标识',
width: 150,
},
{
dataIndex: 'name',
title: '名称',
width: 150,
},
{
dataIndex: 'os',
title: '操作系统',
width: 150,
},
{
dataIndex: 'location',
title: '位置信息',
width: 150,
},
{
dataIndex: 'tags',
title: '标签',
width: 120,
},
{
dataIndex: 'ip',
title: 'IP地址',
width: 150,
},
{
dataIndex: 'remote_access',
title: '远程访问',
width: 100,
slotName: 'remote_access',
},
{
dataIndex: 'agent_config',
title: 'Agent配置',
width: 150,
slotName: 'agent_config',
},
{
dataIndex: 'cpu',
title: 'CPU使用率',
width: 150,
slotName: 'cpu',
},
{
dataIndex: 'memory',
title: '内存使用率',
width: 150,
slotName: 'memory',
},
{
dataIndex: 'disk',
title: '硬盘使用率',
width: 150,
slotName: 'disk',
},
{
dataIndex: 'status',
title: '状态',
width: 100,
slotName: 'status',
},
{
dataIndex: 'actions',
title: '操作',
width: 100,
fixed: 'right' as const,
slotName: 'actions',
},
]

View File

@@ -0,0 +1,40 @@
import type { FormItem } from '@/components/search-form/types'
export const searchFormConfig: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入PC名称、编码或IP',
span: 6,
},
{
field: 'datacenter_id',
label: '数据中心',
type: 'select',
placeholder: '请选择数据中心',
options: [], // 需要动态加载
span: 6,
},
{
field: 'rack_id',
label: '机柜',
type: 'select',
placeholder: '请选择机柜',
options: [], // 需要动态加载
span: 6,
},
{
field: 'status',
label: '状态',
type: 'select',
placeholder: '请选择状态',
options: [
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '维护中', value: 'maintenance' },
{ label: '已退役', value: 'retired' },
],
span: 6,
},
]

View File

@@ -0,0 +1,562 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="服务器及PC管理"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #toolbar-left>
<a-button type="primary" @click="handleAdd">
<template #icon>
<icon-plus />
</template>
新增
</a-button>
</template>
<!-- ID -->
<template #id="{ record }">
{{ record.id }}
</template>
<!-- 远程访问 -->
<template #remote_access="{ record }">
<a-tag :color="record.remote_access ? 'green' : 'gray'">
{{ record.remote_access ? '已开启' : '未开启' }}
</a-tag>
</template>
<!-- Agent配置 -->
<template #agent_config="{ record }">
<a-tag :color="record.agent_config ? 'green' : 'gray'">
{{ record.agent_config ? '已配置' : '未配置' }}
</a-tag>
</template>
<!-- CPU -->
<template #cpu="{ record }">
<div class="resource-display">
<div class="resource-info">
<span class="resource-label">CPU</span>
<span class="resource-value">{{ record.cpu_info?.value || 0 }}%</span>
</div>
<a-progress
:percent="(record.cpu_info?.value || 0) / 100"
:color="getProgressColor(record.cpu_info?.value || 0)"
size="small"
:show-text="false"
/>
</div>
</template>
<!-- 内存 -->
<template #memory="{ record }">
<div class="resource-display">
<div class="resource-info">
<span class="resource-label">内存</span>
<span class="resource-value">{{ record.memory_info?.value || 0 }}%</span>
</div>
<a-progress
:percent="(record.memory_info?.value || 0) / 100"
:color="getProgressColor(record.memory_info?.value || 0)"
size="small"
:show-text="false"
/>
</div>
</template>
<!-- 硬盘 -->
<template #disk="{ record }">
<div class="resource-display">
<div class="resource-info">
<span class="resource-label">硬盘</span>
<span class="resource-value">{{ record.disk_info?.value || 0 }}%</span>
</div>
<a-progress
:percent="(record.disk_info?.value || 0) / 100"
:color="getProgressColor(record.disk_info?.value || 0)"
size="small"
:show-text="false"
/>
</div>
</template>
<!-- 状态 -->
<template #status="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<!-- 操作栏 - 下拉菜单 -->
<template #actions="{ record }">
<a-dropdown trigger="hover">
<a-button type="primary" size="small">
管理
<icon-down />
</a-button>
<template #content>
<a-doption @click="handleRestart(record)">
<template #icon>
<icon-refresh />
</template>
重启
</a-doption>
<a-doption @click="handleDetail(record)">
<template #icon>
<icon-eye />
</template>
详情
</a-doption>
<a-doption @click="handleEdit(record)">
<template #icon>
<icon-edit />
</template>
编辑
</a-doption>
<a-doption @click="handleRemoteControl(record)">
<template #icon>
<icon-desktop />
</template>
远程控制
</a-doption>
<a-doption @click="handleDelete(record)" style="color: rgb(var(--danger-6))">
<template #icon>
<icon-delete />
</template>
删除
</a-doption>
</template>
</a-dropdown>
</template>
</search-table>
<!-- 新增/编辑对话框 -->
<ServerFormDialog
v-model:visible="formDialogVisible"
:record="currentRecord"
@success="handleFormSuccess"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { Message, Modal } from '@arco-design/web-vue'
import {
IconPlus,
IconDown,
IconEdit,
IconDesktop,
IconDelete,
IconRefresh,
IconEye
} from '@arco-design/web-vue/es/icon'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import { columns as columnsConfig } from './config/columns'
import {
fetchPCList,
deletePC,
} from '@/api/ops/pc'
import ServerFormDialog from './components/ServerFormDialog.vue'
import ServerDetail from './components/ServerDetail.vue'
const router = useRouter()
// Mock 假数据
const mockPCData = [
{
id: 1,
unique_id: 'PC-2024-0001',
name: '开发PC-01',
os: 'Windows 11',
location: '数据中心A-1楼-办公区01',
tags: '开发,前端',
ip: '192.168.1.201',
remote_access: true,
agent_config: true,
cpu_info: { value: 35, total: '8核', used: '2.8核' },
memory_info: { value: 52, total: '16GB', used: '8.3GB' },
disk_info: { value: 65, total: '512GB', used: '333GB' },
status: 'online',
},
{
id: 2,
unique_id: 'PC-2024-0002',
name: '测试PC-01',
os: 'Windows 10',
location: '数据中心A-1楼-办公区02',
tags: '测试,自动化',
ip: '192.168.1.202',
remote_access: true,
agent_config: true,
cpu_info: { value: 28, total: '4核', used: '1.1核' },
memory_info: { value: 45, total: '8GB', used: '3.6GB' },
disk_info: { value: 72, total: '256GB', used: '184GB' },
status: 'online',
},
{
id: 3,
unique_id: 'PC-2024-0003',
name: '设计PC-01',
os: 'macOS Sonoma',
location: '数据中心A-2楼-设计室01',
tags: '设计,创意',
ip: '192.168.1.203',
remote_access: false,
agent_config: false,
cpu_info: { value: 0, total: '12核', used: '0核' },
memory_info: { value: 0, total: '32GB', used: '0GB' },
disk_info: { value: 0, total: '1TB', used: '0GB' },
status: 'offline',
},
{
id: 4,
unique_id: 'PC-2024-0004',
name: '运维PC-01',
os: 'Windows 11',
location: '数据中心B-1楼-运维室01',
tags: '运维,监控',
ip: '192.168.2.201',
remote_access: true,
agent_config: true,
cpu_info: { value: 42, total: '6核', used: '2.5核' },
memory_info: { value: 58, total: '16GB', used: '9.3GB' },
disk_info: { value: 68, total: '512GB', used: '348GB' },
status: 'online',
},
{
id: 5,
unique_id: 'PC-2024-0005',
name: '财务PC-01',
os: 'Windows 10',
location: '数据中心B-2楼-财务室01',
tags: '财务,报表',
ip: '192.168.2.202',
remote_access: true,
agent_config: true,
cpu_info: { value: 22, total: '4核', used: '0.9核' },
memory_info: { value: 38, total: '8GB', used: '3.0GB' },
disk_info: { value: 55, total: '256GB', used: '141GB' },
status: 'online',
},
{
id: 6,
unique_id: 'PC-2024-0006',
name: '备用PC-01',
os: 'Windows 11',
location: '数据中心A-1楼-备用室01',
tags: '备用,测试',
ip: '192.168.1.204',
remote_access: false,
agent_config: false,
cpu_info: { value: 0, total: '4核', used: '0核' },
memory_info: { value: 0, total: '8GB', used: '0GB' },
disk_info: { value: 0, total: '256GB', used: '0GB' },
status: 'maintenance',
},
{
id: 7,
unique_id: 'PC-2024-0007',
name: '开发PC-02',
os: 'Ubuntu 22.04',
location: '数据中心A-1楼-办公区03',
tags: '开发,后端',
ip: '192.168.1.205',
remote_access: true,
agent_config: true,
cpu_info: { value: 48, total: '8核', used: '3.8核' },
memory_info: { value: 62, total: '16GB', used: '9.9GB' },
disk_info: { value: 58, total: '512GB', used: '297GB' },
status: 'online',
},
{
id: 8,
unique_id: 'PC-2024-0008',
name: '产品PC-01',
os: 'Windows 11',
location: '数据中心B-1楼-产品室01',
tags: '产品,设计',
ip: '192.168.2.203',
remote_access: true,
agent_config: true,
cpu_info: { value: 32, total: '6核', used: '1.9核' },
memory_info: { value: 45, total: '16GB', used: '7.2GB' },
disk_info: { value: 62, total: '512GB', used: '317GB' },
status: 'online',
},
]
// 状态管理
const loading = ref(false)
const tableData = ref<any[]>([])
const formModel = ref({
keyword: '',
datacenter_id: undefined,
rack_id: undefined,
status: undefined,
})
const formDialogVisible = ref(false)
const currentRecord = ref<any>(null)
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => searchFormConfig)
// 表格列配置
const columns = computed(() => columnsConfig)
// 获取状态颜色
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
online: 'green',
offline: 'red',
maintenance: 'orange',
retired: 'gray',
}
return colorMap[status || ''] || 'gray'
}
// 获取状态文本
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
online: '在线',
offline: '离线',
maintenance: '维护中',
retired: '已退役',
}
return textMap[status || ''] || '-'
}
// 获取进度条颜色
const getProgressColor = (value: number) => {
if (value >= 90) return '#F53F3F' // 红色
if (value >= 70) return '#FF7D00' // 橙色
if (value >= 50) return '#FFD00B' // 黄色
return '#00B42A' // 绿色
}
// 获取PC列表使用 Mock 数据)
const fetchPCs = async () => {
loading.value = true
try {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
// 使用 Mock 数据
tableData.value = mockPCData
pagination.total = mockPCData.length
// 如果有搜索条件,进行过滤
if (formModel.value.keyword || formModel.value.status) {
let filteredData = [...mockPCData]
if (formModel.value.keyword) {
const keyword = formModel.value.keyword.toLowerCase()
filteredData = filteredData.filter(item =>
item.name.toLowerCase().includes(keyword) ||
item.unique_id.toLowerCase().includes(keyword) ||
item.ip.toLowerCase().includes(keyword)
)
}
if (formModel.value.status) {
filteredData = filteredData.filter(item => item.status === formModel.value.status)
}
tableData.value = filteredData
pagination.total = filteredData.length
}
} catch (error) {
console.error('获取PC列表失败:', error)
Message.error('获取PC列表失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchPCs()
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = value
}
// 重置
const handleReset = () => {
formModel.value = {
keyword: '',
datacenter_id: undefined,
rack_id: undefined,
status: undefined,
}
pagination.current = 1
fetchPCs()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchPCs()
}
// 刷新
const handleRefresh = () => {
fetchPCs()
Message.success('数据已刷新')
}
// 新增PC
const handleAdd = () => {
currentRecord.value = null
formDialogVisible.value = true
}
// 编辑PC
const handleEdit = (record: any) => {
currentRecord.value = record
formDialogVisible.value = true
}
// 详情 - 跳转到独立页面
const handleDetail = (record: any) => {
router.push({
path: '/dc/detail',
query: {
id: record.id,
name: record.name,
ip: record.ip,
status: record.status,
},
})
}
// 重启
const handleRestart = (record: any) => {
Modal.confirm({
title: '确认重启',
content: `确认重启服务器/PC ${record.name} 吗?`,
onOk: () => {
Message.info('正在发送重启指令...')
},
})
}
// 远程控制 - 跳转到独立页面
const handleRemoteControl = (record: any) => {
router.push({
path: '/dc/remote',
query: {
id: record.id,
name: record.name,
ip: record.ip,
status: record.status,
},
})
}
// 表单提交成功
const handleFormSuccess = () => {
fetchPCs()
}
// 删除PC
const handleDelete = async (record: any) => {
try {
Modal.confirm({
title: '确认删除',
content: `确认删除PC ${record.name} 吗?`,
onOk: async () => {
// Mock 删除操作
const index = mockPCData.findIndex(item => item.id === record.id)
if (index > -1) {
mockPCData.splice(index, 1)
Message.success('删除成功')
fetchPCs()
} else {
Message.error('删除失败')
}
},
})
} catch (error) {
console.error('删除PC失败:', error)
}
}
// 初始化加载数据
fetchPCs()
</script>
<script lang="ts">
export default {
name: 'DataCenterPC',
}
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
.resource-display {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0;
.resource-info {
display: flex;
align-items: center;
justify-content: space-between;
> div {
display: inline-block;
}
.resource-value {
font-size: 12px;
font-weight: 500;
color: rgb(var(--text-1));
}
}
:deep(.arco-progress) {
margin: 0;
.arco-progress-bar-bg {
border-radius: 2px;
}
.arco-progress-bar {
border-radius: 2px;
transition: all 0.3s ease;
}
}
}
</style>

View File

@@ -0,0 +1,481 @@
<template>
<div class="remote-control-page">
<!-- 顶部导航栏 -->
<div class="page-header">
<div class="header-left">
<a-button type="text" @click="goBack">
<template #icon><icon-left /></template>
返回
</a-button>
<a-divider direction="vertical" />
<span class="server-name">{{ serverName }}</span>
<a-tag :color="getStatusColor(serverStatus)" size="small">{{ getStatusText(serverStatus) }}</a-tag>
</div>
<div class="header-right">
<a-button v-if="isConnected" size="small" @click="handleDisconnect">
<template #icon><icon-poweroff /></template>
断开连接
</a-button>
</div>
</div>
<!-- 主体内容 -->
<div class="page-body">
<!-- 左侧连接信息 -->
<div v-if="!isConnected" class="connection-panel">
<a-card title="SSH 远程连接" class="connection-card">
<a-form :model="connectionForm" layout="vertical">
<a-form-item label="连接协议">
<a-radio-group v-model="connectionForm.protocol" type="button">
<a-radio value="ssh">SSH</a-radio>
<a-radio value="tat">免密连接</a-radio>
</a-radio-group>
</a-form-item>
<a-row :gutter="16">
<a-col :span="16">
<a-form-item label="主机地址">
<a-input v-model="connectionForm.host" placeholder="IP或域名" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="端口">
<a-input-number v-model="connectionForm.port" :min="1" :max="65535" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="用户名">
<a-input v-model="connectionForm.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="密码">
<a-input-password v-model="connectionForm.password" placeholder="请输入密码" />
</a-form-item>
<a-form-item>
<a-button type="primary" long @click="handleConnect" :loading="connectLoading">
<template #icon><icon-play-circle /></template>
开始连接
</a-button>
</a-form-item>
</a-form>
</a-card>
<!-- 连接历史 -->
<a-card title="连接历史" class="history-card">
<a-list :bordered="false">
<a-list-item v-for="(item, index) in connectionHistory" :key="index" @click="loadConnection(item)">
<a-list-item-meta :title="item.name" :description="`${item.username}@${item.host}:${item.port}`" />
<template #actions>
<a-button type="text" size="small" @click.stop="removeHistory(index)">
<icon-delete />
</a-button>
</template>
</a-list-item>
<a-empty v-if="connectionHistory.length === 0" description="暂无连接记录" />
</a-list>
</a-card>
</div>
<!-- 右侧终端 -->
<div class="terminal-panel" :class="{ 'full-width': isConnected }">
<div class="terminal-header">
<div class="terminal-info">
<icon-desktop v-if="isConnected" style="color: #00b42a; margin-right: 8px;" />
<icon-desktop v-else style="color: #86909c; margin-right: 8px;" />
<span>{{ isConnected ? `已连接: ${connectionForm.username}@${connectionForm.host}` : '未连接' }}</span>
</div>
<a-space v-if="isConnected">
<a-button size="small" @click="clearTerminal">
<template #icon><icon-eraser /></template>
清屏
</a-button>
<a-button size="small" @click="toggleFullscreen">
<template #icon><icon-fullscreen /></template>
{{ isFullscreen ? '退出全屏' : '全屏' }}
</a-button>
</a-space>
</div>
<div class="terminal-content" v-if="isConnected">
<Terminal
ref="terminalRef"
:name="terminalName"
:show-log-data="showLogData"
@execCmd="onExecCmd"
@onKeydown="onKeydown"
/>
</div>
<div v-else class="terminal-placeholder">
<a-empty description="请先配置连接信息">
<template #image>
<icon-desktop style="font-size: 48px; color: #86909c;" />
</template>
</a-empty>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, nextTick, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import { Terminal } from 'vue-web-terminal'
import {
IconLeft,
IconPlayCircle,
IconDelete,
IconDesktop,
IconPoweroff,
IconEraser,
IconFullscreen,
} from '@arco-design/web-vue/es/icon'
const route = useRoute()
const router = useRouter()
// 服务器信息
const serverId = computed(() => route.query.id as string)
const serverName = computed(() => route.query.name as string || '远程控制')
const serverStatus = computed(() => route.query.status as string || 'offline')
// 连接状态
const isConnected = ref(false)
const connectLoading = ref(false)
const isFullscreen = ref(false)
const terminalRef = ref()
// 终端配置
const terminalName = ref('ssh-terminal')
const showLogData = ref(false)
// 连接表单
const connectionForm = ref({
protocol: 'ssh',
host: (route.query.ip as string) || '',
port: 22,
username: 'root',
password: '',
})
// 连接历史(从 localStorage 读取)
const connectionHistory = ref<Array<{name: string, host: string, port: number, username: string}>>([])
onMounted(() => {
const history = localStorage.getItem('ssh_connection_history')
if (history) {
connectionHistory.value = JSON.parse(history)
}
})
const getStatusColor = (status?: string) => {
const map: Record<string, string> = { online: 'green', offline: 'red', maintenance: 'orange' }
return map[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const map: Record<string, string> = { online: '在线', offline: '离线', maintenance: '维护中' }
return map[status || ''] || '-'
}
const handleConnect = async () => {
if (!connectionForm.value.host || !connectionForm.value.username) {
Message.warning('请填写完整信息')
return
}
connectLoading.value = true
// 模拟连接
await new Promise(r => setTimeout(r, 1500))
isConnected.value = true
connectLoading.value = false
// 显示欢迎信息
if (terminalRef.value) {
terminalRef.value.pushMessage({
class: 'success',
content: `Welcome to ${serverName.value}!`
})
terminalRef.value.pushMessage({
class: 'success',
content: `Connected to ${connectionForm.value.host}`
})
terminalRef.value.pushMessage({
class: 'info',
content: `Last login: ${new Date().toLocaleString()}`
})
terminalRef.value.pushMessage({
class: 'info',
content: ''
})
terminalRef.value.pushMessage({
class: 'info',
content: 'Type "help" for available commands.'
})
}
// 保存连接历史
saveConnectionHistory()
Message.success('连接成功')
}
const saveConnectionHistory = () => {
const newItem = {
name: serverName.value,
host: connectionForm.value.host,
port: connectionForm.value.port,
username: connectionForm.value.username,
}
// 去重并添加到开头
connectionHistory.value = connectionHistory.value.filter(
item => !(item.host === newItem.host && item.username === newItem.username)
)
connectionHistory.value.unshift(newItem)
// 最多保存10条
if (connectionHistory.value.length > 10) {
connectionHistory.value = connectionHistory.value.slice(0, 10)
}
localStorage.setItem('ssh_connection_history', JSON.stringify(connectionHistory.value))
}
const loadConnection = (item: any) => {
connectionForm.value.host = item.host
connectionForm.value.port = item.port
connectionForm.value.username = item.username
}
const removeHistory = (index: number) => {
connectionHistory.value.splice(index, 1)
localStorage.setItem('ssh_connection_history', JSON.stringify(connectionHistory.value))
}
// 终端命令处理
const onExecCmd = (key: string, command: string, success: boolean, terminalName: string) => {
const cmd = command.trim()
if (!cmd) return
// 执行命令
const commands: Record<string, () => void> = {
help: () => {
terminalRef.value.pushMessage({
class: 'info',
content: 'Available commands:'
})
terminalRef.value.pushMessage({
class: 'info',
content: ' help - Show this help message'
})
terminalRef.value.pushMessage({
class: 'info',
content: ' ls - List directory contents'
})
terminalRef.value.pushMessage({
class: 'info',
content: ' pwd - Print working directory'
})
terminalRef.value.pushMessage({
class: 'info',
content: ' whoami - Print current user'
})
terminalRef.value.pushMessage({
class: 'info',
content: ' date - Print system date'
})
terminalRef.value.pushMessage({
class: 'info',
content: ' clear - Clear terminal screen'
})
terminalRef.value.pushMessage({
class: 'info',
content: ' exit - Disconnect from server'
})
},
ls: () => {
terminalRef.value.pushMessage({
class: 'success',
content: 'bin boot dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var'
})
},
pwd: () => {
terminalRef.value.pushMessage({
class: 'success',
content: '/root'
})
},
whoami: () => {
terminalRef.value.pushMessage({
class: 'success',
content: connectionForm.value.username
})
},
date: () => {
terminalRef.value.pushMessage({
class: 'success',
content: new Date().toString()
})
},
clear: () => {
terminalRef.value.clear()
},
exit: () => {
handleDisconnect()
}
}
if (commands[cmd]) {
commands[cmd]()
} else {
terminalRef.value.pushMessage({
class: 'error',
content: `bash: ${cmd}: command not found`
})
}
}
const onKeydown = (key: string, event: KeyboardEvent) => {
// 可以在这里处理特殊按键事件
}
const handleDisconnect = () => {
isConnected.value = false
Message.info('已断开连接')
}
const clearTerminal = () => {
if (terminalRef.value) {
terminalRef.value.clear()
}
}
const toggleFullscreen = () => {
const terminalContent = document.querySelector('.terminal-content')
if (!document.fullscreenElement) {
terminalContent?.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen()
isFullscreen.value = false
}
}
const goBack = () => {
router.back()
}
</script>
<style scoped lang="less">
.remote-control-page {
height: 100vh;
display: flex;
flex-direction: column;
background: #f0f2f5;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e5e6eb;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.server-name {
font-size: 16px;
font-weight: 500;
}
}
}
.page-body {
flex: 1;
display: flex;
overflow: hidden;
padding: 16px;
gap: 16px;
.connection-panel {
width: 400px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
.connection-card {
:deep(.arco-card-body) {
padding: 20px;
}
}
.history-card {
flex: 1;
:deep(.arco-list-item) {
cursor: pointer;
&:hover {
background: #f7f8fa;
}
}
}
}
.terminal-panel {
flex: 1;
display: flex;
flex-direction: column;
background: #1e1e1e;
border-radius: 4px;
overflow: hidden;
&.full-width {
width: 100%;
}
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #2d2d2d;
border-bottom: 1px solid #3a3a3a;
.terminal-info {
display: flex;
align-items: center;
color: #c9cdd4;
font-size: 13px;
}
}
.terminal-content {
flex: 1;
overflow: hidden;
:deep(.t-window) {
height: 100%;
}
.terminal-placeholder {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,92 @@
export const columns = [
{
dataIndex: 'id',
title: 'ID',
width: 80,
slotName: 'id',
},
{
dataIndex: 'unique_id',
title: '唯一标识',
width: 150,
},
{
dataIndex: 'name',
title: '名称',
width: 150,
},
{
dataIndex: 'type',
title: '类型',
width: 120,
},
{
dataIndex: 'os',
title: '操作系统',
width: 150,
},
{
dataIndex: 'location',
title: '位置信息',
width: 150,
},
{
dataIndex: 'tags',
title: '标签',
width: 120,
},
{
dataIndex: 'ip',
title: 'IP地址',
width: 150,
},
{
dataIndex: 'remote_access',
title: '远程访问',
width: 100,
slotName: 'remote_access',
},
{
dataIndex: 'agent_config',
title: 'Agent配置',
width: 150,
slotName: 'agent_config',
},
{
dataIndex: 'cpu',
title: 'CPU使用率',
width: 150,
slotName: 'cpu',
},
{
dataIndex: 'memory',
title: '内存使用率',
width: 150,
slotName: 'memory',
},
{
dataIndex: 'disk',
title: '硬盘使用率',
width: 150,
slotName: 'disk',
},
{
dataIndex: 'data_collection',
title: '数据采集',
width: 100,
slotName: 'data_collection',
},
{
dataIndex: 'status',
title: '状态',
width: 100,
slotName: 'status',
},
{
dataIndex: 'actions',
title: '操作',
width: 100,
fixed: 'right' as const,
slotName: 'actions',
},
]

View File

@@ -0,0 +1,40 @@
import type { FormItem } from '@/components/search-form/types'
export const searchFormConfig: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入服务器名称、编码或IP',
span: 6,
},
{
field: 'datacenter_id',
label: '数据中心',
type: 'select',
placeholder: '请选择数据中心',
options: [], // 需要动态加载
span: 6,
},
{
field: 'rack_id',
label: '机柜',
type: 'select',
placeholder: '请选择机柜',
options: [], // 需要动态加载
span: 6,
},
{
field: 'status',
label: '状态',
type: 'select',
placeholder: '请选择状态',
options: [
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '维护中', value: 'maintenance' },
{ label: '已退役', value: 'retired' },
],
span: 6,
},
]

View File

@@ -0,0 +1,631 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="服务器管理"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #toolbar-left>
<a-button type="primary" @click="handleAdd">
<template #icon>
<icon-plus />
</template>
新增服务器
</a-button>
</template>
<!-- ID -->
<template #id="{ record }">
{{ record.id }}
</template>
<!-- 远程访问 -->
<template #remote_access="{ record }">
<a-tag :color="record.remote_access ? 'green' : 'gray'">
{{ record.remote_access ? '已开启' : '未开启' }}
</a-tag>
</template>
<!-- Agent配置 -->
<template #agent_config="{ record }">
<a-tag :color="record.agent_config ? 'green' : 'gray'">
{{ record.agent_config ? '已配置' : '未配置' }}
</a-tag>
</template>
<!-- CPU -->
<template #cpu="{ record }">
<div class="resource-display">
<div class="resource-info">
<span class="resource-label">CPU</span>
<span class="resource-value">{{ record.cpu_info?.value || 0 }}%</span>
</div>
<a-progress
:percent="(record.cpu_info?.value || 0) / 100"
:color="getProgressColor(record.cpu_info?.value || 0)"
size="small"
:show-text="false"
/>
</div>
</template>
<!-- 内存 -->
<template #memory="{ record }">
<div class="resource-display">
<div class="resource-info">
<span class="resource-laebl">内存</span>
<span class="resource-value">{{ record.memory_info?.value || 0 }}%</span>
</div>
<a-progress
:percent="(record.memory_info?.value || 0) / 100"
:color="getProgressColor(record.memory_info?.value || 0)"
size="small"
:show-text="false"
/>
</div>
</template>
<!-- 硬盘 -->
<template #disk="{ record }">
<div class="resource-display">
<div class="resource-info">
<span class="resource-label">硬盘</span>
<span class="resource-value">{{ record.disk_info?.value || 0 }}%</span>
</div>
<a-progress
:percent="(record.disk_info?.value || 0) / 100"
:color="getProgressColor(record.disk_info?.value || 0)"
size="small"
:show-text="false"
/>
</div>
</template>
<!-- 数据采集 -->
<template #data_collection="{ record }">
<a-tag :color="record.data_collection ? 'green' : 'gray'">
{{ record.data_collection ? '已启用' : '未启用' }}
</a-tag>
</template>
<!-- 状态 -->
<template #status="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<!-- 操作栏 - 下拉菜单 -->
<template #actions="{ record }">
<a-dropdown trigger="hover">
<a-button type="primary" size="small">
管理
<icon-down />
</a-button>
<template #content>
<a-doption @click="handleRestart(record)">
<template #icon>
<icon-refresh />
</template>
重启
</a-doption>
<a-doption @click="handleDetail(record)">
<template #icon>
<icon-eye />
</template>
详情
</a-doption>
<a-doption @click="handleEdit(record)">
<template #icon>
<icon-edit />
</template>
编辑
</a-doption>
<a-doption @click="handleRemoteControl(record)">
<template #icon>
<icon-desktop />
</template>
远程控制
</a-doption>
<a-doption @click="handleDelete(record)" style="color: rgb(var(--danger-6))">
<template #icon>
<icon-delete />
</template>
删除
</a-doption>
</template>
</a-dropdown>
</template>
</search-table>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { Message, Modal } from '@arco-design/web-vue'
import {
IconPlus,
IconDown,
IconEdit,
IconDesktop,
IconDelete,
IconRefresh,
IconEye
} from '@arco-design/web-vue/es/icon'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import { columns as columnsConfig } from './config/columns'
import {
fetchServerList,
deleteServer,
} from '@/api/ops/server'
const router = useRouter()
// Mock 假数据
const mockServerData = [
{
id: 1,
unique_id: 'SRV-2024-0001',
name: 'Web服务器-01',
type: 'Web服务器',
os: 'CentOS 7.9',
location: '数据中心A-1楼-机柜01-U1',
tags: 'Web,应用',
ip: '192.168.1.101',
remote_access: true,
agent_config: true,
cpu: '8核 Intel Xeon',
memory: '32GB',
disk: '1TB SSD',
cpu_info: { value: 45, total: '8核', used: '3.6核' },
memory_info: { value: 62, total: '32GB', used: '19.8GB' },
disk_info: { value: 78, total: '1TB', used: '780GB' },
data_collection: true,
status: 'online',
},
{
id: 2,
unique_id: 'SRV-2024-0002',
name: '数据库服务器-01',
type: '数据库服务器',
os: 'Ubuntu 22.04',
location: '数据中心A-1楼-机柜02-U1',
tags: '数据库,MySQL',
ip: '192.168.1.102',
remote_access: true,
agent_config: true,
cpu: '16核 AMD EPYC',
memory: '64GB',
disk: '2TB NVMe',
cpu_info: { value: 78, total: '16核', used: '12.5核' },
memory_info: { value: 85, total: '64GB', used: '54.4GB' },
disk_info: { value: 92, total: '2TB', used: '1.84TB' },
data_collection: true,
status: 'online',
},
{
id: 3,
unique_id: 'SRV-2024-0003',
name: '应用服务器-01',
type: '应用服务器',
os: 'Windows Server 2019',
location: '数据中心A-2楼-机柜05-U2',
tags: '应用,.NET',
ip: '192.168.1.103',
remote_access: false,
agent_config: false,
cpu: '4核 Intel Xeon',
memory: '16GB',
disk: '500GB SSD',
cpu_info: { value: 0, total: '4核', used: '0核' },
memory_info: { value: 0, total: '16GB', used: '0GB' },
disk_info: { value: 0, total: '500GB', used: '0GB' },
data_collection: false,
status: 'offline',
},
{
id: 4,
unique_id: 'SRV-2024-0004',
name: '缓存服务器-01',
type: '缓存服务器',
os: 'CentOS 8.5',
location: '数据中心A-2楼-机柜06-U1',
tags: '缓存,Redis',
ip: '192.168.1.104',
remote_access: true,
agent_config: true,
cpu: '8核 Intel Xeon',
memory: '32GB',
disk: '1TB SSD',
cpu_info: { value: 35, total: '8核', used: '2.8核' },
memory_info: { value: 68, total: '32GB', used: '21.8GB' },
disk_info: { value: 42, total: '1TB', used: '420GB' },
data_collection: true,
status: 'online',
},
{
id: 5,
unique_id: 'SRV-2024-0005',
name: '文件服务器-01',
type: '文件服务器',
os: 'Debian 11',
location: '数据中心B-1楼-机柜03-U1',
tags: '文件,NFS',
ip: '192.168.2.101',
remote_access: true,
agent_config: true,
cpu: '12核 Intel Xeon',
memory: '48GB',
disk: '10TB HDD',
cpu_info: { value: 28, total: '12核', used: '3.4核' },
memory_info: { value: 45, total: '48GB', used: '21.6GB' },
disk_info: { value: 88, total: '10TB', used: '8.8TB' },
data_collection: true,
status: 'maintenance',
},
{
id: 6,
unique_id: 'SRV-2024-0006',
name: '测试服务器-01',
type: '测试服务器',
os: 'CentOS 7.9',
location: '数据中心B-2楼-机柜10-U1',
tags: '测试,开发',
ip: '192.168.2.102',
remote_access: false,
agent_config: false,
cpu: '4核 Intel Xeon',
memory: '8GB',
disk: '256GB SSD',
cpu_info: { value: 0, total: '4核', used: '0核' },
memory_info: { value: 0, total: '8GB', used: '0GB' },
disk_info: { value: 0, total: '256GB', used: '0GB' },
data_collection: false,
status: 'retired',
},
{
id: 7,
unique_id: 'SRV-2024-0007',
name: '监控服务器-01',
type: '监控服务器',
os: 'Ubuntu 20.04',
location: '数据中心A-1楼-机柜08-U1',
tags: '监控,Prometheus',
ip: '192.168.1.105',
remote_access: true,
agent_config: true,
cpu: '8核 Intel Xeon',
memory: '32GB',
disk: '1TB SSD',
cpu_info: { value: 55, total: '8核', used: '4.4核' },
memory_info: { value: 72, total: '32GB', used: '23.0GB' },
disk_info: { value: 65, total: '1TB', used: '650GB' },
data_collection: true,
status: 'online',
},
{
id: 8,
unique_id: 'SRV-2024-0008',
name: '备份服务器-01',
type: '备份服务器',
os: 'Rocky Linux 9',
location: '数据中心B-1楼-机柜04-U1',
tags: '备份,存储',
ip: '192.168.2.103',
remote_access: true,
agent_config: true,
cpu: '16核 AMD EPYC',
memory: '64GB',
disk: '20TB HDD',
cpu_info: { value: 42, total: '16核', used: '6.7核' },
memory_info: { value: 38, total: '64GB', used: '24.3GB' },
disk_info: { value: 75, total: '20TB', used: '15TB' },
data_collection: true,
status: 'online',
},
{
id: 9,
unique_id: 'SRV-2024-0009',
name: 'CI/CD服务器-01',
type: 'CI/CD服务器',
os: 'Ubuntu 22.04',
location: '数据中心A-2楼-机柜07-U1',
tags: 'CI/CD,Jenkins',
ip: '192.168.1.106',
remote_access: true,
agent_config: true,
cpu: '8核 Intel Xeon',
memory: '16GB',
disk: '500GB SSD',
cpu_info: { value: 68, total: '8核', used: '5.4核' },
memory_info: { value: 75, total: '16GB', used: '12GB' },
disk_info: { value: 55, total: '500GB', used: '275GB' },
data_collection: true,
status: 'online',
},
{
id: 10,
unique_id: 'SRV-2024-0010',
name: '日志服务器-01',
type: '日志服务器',
os: 'CentOS Stream 9',
location: '数据中心B-2楼-机柜12-U1',
tags: '日志,ELK',
ip: '192.168.2.104',
remote_access: true,
agent_config: true,
cpu: '12核 Intel Xeon',
memory: '48GB',
disk: '2TB SSD',
cpu_info: { value: 0, total: '12核', used: '0核' },
memory_info: { value: 0, total: '48GB', used: '0GB' },
disk_info: { value: 0, total: '2TB', used: '0TB' },
data_collection: true,
status: 'offline',
},
]
// 状态管理
const loading = ref(false)
const tableData = ref<any[]>([])
const formModel = ref({
keyword: '',
datacenter_id: undefined,
rack_id: undefined,
status: undefined,
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => searchFormConfig)
// 表格列配置
const columns = computed(() => columnsConfig)
// 获取状态颜色
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
online: 'green',
offline: 'red',
maintenance: 'orange',
retired: 'gray',
}
return colorMap[status || ''] || 'gray'
}
// 获取状态文本
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
online: '在线',
offline: '离线',
maintenance: '维护中',
retired: '已退役',
}
return textMap[status || ''] || '-'
}
// 获取进度条颜色
const getProgressColor = (value: number) => {
if (value >= 90) return '#F53F3F' // 红色
if (value >= 70) return '#FF7D00' // 橙色
if (value >= 50) return '#FFD00B' // 黄色
return '#00B42A' // 绿色
}
// 获取服务器列表(使用 Mock 数据)
const fetchServers = async () => {
loading.value = true
try {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
// 使用 Mock 数据
tableData.value = mockServerData
pagination.total = mockServerData.length
// 如果有搜索条件,进行过滤
if (formModel.value.keyword || formModel.value.status) {
let filteredData = [...mockServerData]
if (formModel.value.keyword) {
const keyword = formModel.value.keyword.toLowerCase()
filteredData = filteredData.filter(item =>
item.name.toLowerCase().includes(keyword) ||
item.unique_id.toLowerCase().includes(keyword) ||
item.ip.toLowerCase().includes(keyword)
)
}
if (formModel.value.status) {
filteredData = filteredData.filter(item => item.status === formModel.value.status)
}
tableData.value = filteredData
pagination.total = filteredData.length
}
} catch (error) {
console.error('获取服务器列表失败:', error)
Message.error('获取服务器列表失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchServers()
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = value
}
// 重置
const handleReset = () => {
formModel.value = {
keyword: '',
datacenter_id: undefined,
rack_id: undefined,
status: undefined,
}
pagination.current = 1
fetchServers()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchServers()
}
// 刷新
const handleRefresh = () => {
fetchServers()
Message.success('数据已刷新')
}
// 新增服务器
const handleAdd = () => {
Message.info('新增服务器功能待实现')
// TODO: 实现新增服务器对话框
}
// 重启服务器
const handleRestart = (record: any) => {
Modal.confirm({
title: '确认重启',
content: `确认重启服务器 ${record.name} 吗?`,
onOk: () => {
Message.info('正在发送重启指令...')
},
})
}
// 查看详情 - 跳转到独立页面
const handleDetail = (record: any) => {
router.push({
path: '/dc/detail',
query: {
id: record.id,
name: record.name,
ip: record.ip,
status: record.status,
},
})
}
// 编辑服务器
const handleEdit = (record: any) => {
Message.info('编辑服务器功能待实现')
// TODO: 实现编辑服务器对话框
}
// 远程控制 - 跳转到独立页面
const handleRemoteControl = (record: any) => {
router.push({
path: '/dc/remote',
query: {
id: record.id,
name: record.name,
ip: record.ip,
status: record.status,
},
})
}
// 删除服务器
const handleDelete = async (record: any) => {
try {
Modal.confirm({
title: '确认删除',
content: `确认删除服务器 ${record.name} 吗?`,
onOk: async () => {
// Mock 删除操作
const index = mockServerData.findIndex(item => item.id === record.id)
if (index > -1) {
mockServerData.splice(index, 1)
Message.success('删除成功')
fetchServers()
} else {
Message.error('删除失败')
}
},
})
} catch (error) {
console.error('删除服务器失败:', error)
}
}
// 初始化加载数据
fetchServers()
</script>
<script lang="ts">
export default {
name: 'DataCenterServer',
}
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
.resource-display {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0;
.resource-info {
display: flex;
align-items: center;
justify-content: space-between;
> div {
display: inline-block;
}
.resource-value {
font-size: 12px;
font-weight: 500;
color: rgb(var(--text-1));
}
}
:deep(.arco-progress) {
margin: 0;
.arco-progress-bar-bg {
border-radius: 2px;
}
.arco-progress-bar {
border-radius: 2px;
transition: all 0.3s ease;
}
}
}
</style>

View File

@@ -0,0 +1,315 @@
<template>
<div class="metrics-summary-panel">
<!-- 查询表单 -->
<a-form :model="formModel" layout="inline" class="search-form">
<a-form-item label="数据源" field="data_source">
<a-select
v-model="formModel.data_source"
placeholder="请选择数据源"
style="width: 180px"
@change="handleDataSourceChange"
>
<a-option value="dc-host">主机</a-option>
<a-option value="dc-network">网络设备</a-option>
<a-option value="dc-database">数据库</a-option>
<a-option value="dc-middleware">中间件</a-option>
</a-select>
</a-form-item>
<a-form-item label="指标名称" field="metric_names">
<a-input
v-model="formModel.metric_names"
placeholder="多个指标名称,逗号分隔"
style="width: 250px"
/>
</a-form-item>
<a-form-item label="标识" field="identities">
<a-input
v-model="formModel.identities"
:placeholder="identityPlaceholder"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="聚合方式" field="aggregation">
<a-select
v-model="formModel.aggregation"
placeholder="请选择聚合方式"
style="width: 120px"
>
<a-option value="avg">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
<a-option value="sum">求和</a-option>
<a-option value="count">计数</a-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围" field="timeRange">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" :loading="loading" @click="handleSearch">
查询
</a-button>
<a-button @click="handleReset">
重置
</a-button>
<a-button :loading="exporting" @click="handleExport">
导出 CSV
</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 结果表格 -->
<a-table
:data="tableData"
:loading="loading"
:pagination="pagination"
:bordered="false"
stripe
class="result-table"
@page-change="handlePageChange"
>
<template #columns>
<a-table-column title="标识" data-index="group_key" width="200" />
<a-table-column title="指标名称" data-index="metric_name" width="180" />
<a-table-column title="指标单位" data-index="metric_unit" width="100" />
<a-table-column title="聚合值" data-index="value" width="120">
<template #cell="{ record }">
<span>{{ formatValue(record.value) }}</span>
</template>
</a-table-column>
<a-table-column title="样本数" data-index="sample_count" width="100" />
<a-table-column title="聚合方式" data-index="aggregation" width="100" />
<a-table-column title="数据源" data-index="data_source" width="120" />
</template>
</a-table>
<!-- 空状态 -->
<a-empty v-if="!loading && tableData.length === 0" description="暂无数据" />
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchMetricsSummary, exportMetricsSummary } from '@/api/ops/report'
// 表单模型
const formModel = reactive({
data_source: 'dc-host',
metric_names: '',
identities: '',
aggregation: 'avg',
timeRange: [],
})
// 加载状态
const loading = ref(false)
const exporting = ref(false)
// 表格数据
const tableData = ref<any[]>([])
// 分页
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 标识字段占位符
const identityPlaceholder = computed(() => {
if (formModel.data_source === 'dc-host') {
return '服务器标识,必填'
}
return '服务标识,必填'
})
// 处理数据源变化
const handleDataSourceChange = () => {
formModel.identities = ''
}
// 格式化数值
const formatValue = (value: number) => {
if (value === null || value === undefined) return '-'
return Number(value).toFixed(2)
}
// 构建查询参数
const buildParams = () => {
const params: any = {
data_source: formModel.data_source,
metric_names: formModel.metric_names,
aggregation: formModel.aggregation,
start_time: formModel.timeRange[0],
end_time: formModel.timeRange[1],
}
// 根据数据源添加标识
if (formModel.data_source === 'dc-host') {
params.server_identities = formModel.identities
} else {
params.service_identities = formModel.identities
}
return params
}
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.data_source) {
Message.warning('请选择数据源')
return
}
if (!formModel.metric_names) {
Message.warning('请输入指标名称')
return
}
if (!formModel.identities) {
Message.warning('请输入标识')
return
}
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
loading.value = true
try {
const params = buildParams()
const res = await fetchMetricsSummary(params)
if (res.code === 0) {
const data = res.data?.data || []
tableData.value = data
pagination.total = res.data?.count || 0
pagination.current = 1
if (tableData.value.length === 0) {
Message.info('未查询到数据')
}
} else {
Message.error(res.message || '查询失败')
tableData.value = []
pagination.total = 0
}
} catch (error: any) {
console.error('查询失败:', error)
Message.error(error.message || '查询失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 重置
const handleReset = () => {
formModel.data_source = 'dc-host'
formModel.metric_names = ''
formModel.identities = ''
formModel.aggregation = 'avg'
formModel.timeRange = []
tableData.value = []
pagination.total = 0
pagination.current = 1
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
// 如果需要分页查询,可以在这里实现
}
// 导出
const handleExport = async () => {
// 验证必填项
if (!formModel.data_source) {
Message.warning('请选择数据源')
return
}
if (!formModel.metric_names) {
Message.warning('请输入指标名称')
return
}
if (!formModel.identities) {
Message.warning('请输入标识')
return
}
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
exporting.value = true
try {
const params = buildParams()
params.utf8_bom = 'true'
const blob = await exportMetricsSummary(params)
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
// 生成文件名
const now = new Date()
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`
link.download = `metrics_summary_${timestamp}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
Message.success('导出成功')
} catch (error: any) {
console.error('导出失败:', error)
Message.error(error.message || '导出失败')
} finally {
exporting.value = false
}
}
</script>
<script lang="ts">
export default {
name: 'MetricsSummaryPanel',
}
</script>
<style scoped lang="less">
.metrics-summary-panel {
.search-form {
margin-bottom: 20px;
padding: 16px;
background-color: var(--color-fill-2);
border-radius: 4px;
:deep(.arco-form-item) {
margin-bottom: 12px;
}
}
.result-table {
background-color: #fff;
}
}
</style>

View File

@@ -0,0 +1,255 @@
<template>
<div class="metrics-topn-panel">
<!-- 查询表单 -->
<a-form :model="formModel" layout="inline" class="search-form">
<a-form-item label="数据源" field="data_source">
<a-select
v-model="formModel.data_source"
placeholder="请选择数据源"
style="width: 180px"
@change="handleDataSourceChange"
>
<a-option value="dc-host">主机</a-option>
<a-option value="dc-network">网络设备</a-option>
<a-option value="dc-database">数据库</a-option>
<a-option value="dc-middleware">中间件</a-option>
</a-select>
</a-form-item>
<a-form-item label="指标名称" field="metric_name">
<a-input
v-model="formModel.metric_name"
placeholder="请输入指标名称"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="聚合方式" field="aggregation">
<a-select
v-model="formModel.aggregation"
placeholder="请选择聚合方式"
style="width: 120px"
>
<a-option value="avg">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
<a-option value="sum">求和</a-option>
<a-option value="count">计数</a-option>
</a-select>
</a-form-item>
<a-form-item label="排序" field="order">
<a-select
v-model="formModel.order"
style="width: 100px"
>
<a-option value="desc">降序</a-option>
<a-option value="asc">升序</a-option>
</a-select>
</a-form-item>
<a-form-item label="数量" field="limit">
<a-input-number
v-model="formModel.limit"
:min="1"
:max="1000"
style="width: 120px"
/>
</a-form-item>
<a-form-item label="标识" field="identities">
<a-input
v-model="formModel.identities"
:placeholder="identityPlaceholder"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="时间范围" field="timeRange">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" :loading="loading" @click="handleSearch">
查询
</a-button>
<a-button @click="handleReset">
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 结果表格 -->
<a-table
:data="tableData"
:loading="loading"
:pagination="false"
:bordered="false"
stripe
class="result-table"
>
<template #columns>
<a-table-column title="排名" data-index="rank" width="80" />
<a-table-column title="标识" data-index="group_key" width="200" />
<a-table-column title="指标名称" data-index="metric_name" width="180" />
<a-table-column title="指标单位" data-index="metric_unit" width="100" />
<a-table-column title="聚合值" data-index="value" width="120">
<template #cell="{ record }">
<span>{{ formatValue(record.value) }}</span>
</template>
</a-table-column>
<a-table-column title="样本数" data-index="sample_count" width="100" />
<a-table-column title="聚合方式" data-index="aggregation" width="100" />
<a-table-column title="数据源" data-index="data_source" width="120" />
</template>
</a-table>
<!-- 空状态 -->
<a-empty v-if="!loading && tableData.length === 0" description="暂无数据" />
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchMetricsTopN } from '@/api/ops/report'
// 表单模型
const formModel = reactive({
data_source: 'dc-host',
metric_name: '',
aggregation: 'avg',
order: 'desc',
limit: 10,
identities: '',
timeRange: [],
})
// 加载状态
const loading = ref(false)
// 表格数据
const tableData = ref<any[]>([])
// 标识字段占位符
const identityPlaceholder = computed(() => {
if (formModel.data_source === 'dc-host') {
return '多个服务器标识,逗号分隔'
}
return '多个服务标识,逗号分隔'
})
// 处理数据源变化
const handleDataSourceChange = () => {
formModel.identities = ''
}
// 格式化数值
const formatValue = (value: number) => {
if (value === null || value === undefined) return '-'
return Number(value).toFixed(2)
}
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.data_source) {
Message.warning('请选择数据源')
return
}
if (!formModel.metric_name) {
Message.warning('请输入指标名称')
return
}
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
loading.value = true
try {
const params: any = {
data_source: formModel.data_source,
metric_name: formModel.metric_name,
aggregation: formModel.aggregation,
order: formModel.order,
limit: formModel.limit,
start_time: formModel.timeRange[0],
end_time: formModel.timeRange[1],
}
// 根据数据源添加标识过滤
if (formModel.identities) {
if (formModel.data_source === 'dc-host') {
params.server_identities = formModel.identities
} else {
params.service_identities = formModel.identities
}
}
const res = await fetchMetricsTopN(params)
if (res.code === 0) {
tableData.value = res.data?.items || []
if (tableData.value.length === 0) {
Message.info('未查询到数据')
}
} else {
Message.error(res.message || '查询失败')
tableData.value = []
}
} catch (error: any) {
console.error('查询失败:', error)
Message.error(error.message || '查询失败')
tableData.value = []
} finally {
loading.value = false
}
}
// 重置
const handleReset = () => {
formModel.data_source = 'dc-host'
formModel.metric_name = ''
formModel.aggregation = 'avg'
formModel.order = 'desc'
formModel.limit = 10
formModel.identities = ''
formModel.timeRange = []
tableData.value = []
}
</script>
<script lang="ts">
export default {
name: 'MetricsTopNPanel',
}
</script>
<style scoped lang="less">
.metrics-topn-panel {
.search-form {
margin-bottom: 20px;
padding: 16px;
background-color: var(--color-fill-2);
border-radius: 4px;
:deep(.arco-form-item) {
margin-bottom: 12px;
}
}
.result-table {
background-color: #fff;
}
}
</style>

View File

@@ -0,0 +1,300 @@
<template>
<div class="network-device-status-panel">
<!-- 查询表单 -->
<a-form :model="formModel" layout="inline" class="search-form">
<a-form-item label="服务标识" field="service_identities">
<a-input
v-model="formModel.service_identities"
placeholder="多个标识,逗号分隔"
style="width: 250px"
/>
</a-form-item>
<a-form-item label="指标名称" field="metric_names">
<a-input
v-model="formModel.metric_names"
placeholder="多个指标名称,逗号分隔(可选)"
style="width: 250px"
/>
</a-form-item>
<a-form-item label="聚合方式" field="aggregation">
<a-select
v-model="formModel.aggregation"
placeholder="请选择聚合方式"
style="width: 120px"
>
<a-option value="avg">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
<a-option value="sum">求和</a-option>
<a-option value="count">计数</a-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围" field="timeRange">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" :loading="loading" @click="handleSearch">
查询
</a-button>
<a-button @click="handleReset">
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 结果表格 -->
<a-table
:data="tableData"
:loading="loading"
:pagination="false"
:bordered="false"
stripe
class="result-table"
>
<template #columns>
<a-table-column title="服务标识" data-index="service_identity" width="200" fixed="left" />
<a-table-column title="服务器标识" data-index="server_identity" width="180" />
<a-table-column title="名称" data-index="name" width="150" />
<a-table-column title="类型" data-index="type" width="120" />
<a-table-column title="主机" data-index="host" width="150" />
<a-table-column title="状态" data-index="status" width="100">
<template #cell="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ record.status || '-' }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="响应时间" data-index="response_time" width="120">
<template #cell="{ record }">
<span>{{ formatResponseTime(record.response_time) }}</span>
</template>
</a-table-column>
<a-table-column title="运行时间" data-index="uptime" width="120">
<template #cell="{ record }">
<span>{{ formatUptime(record.uptime) }}</span>
</template>
</a-table-column>
<a-table-column title="启用" data-index="enabled" width="80">
<template #cell="{ record }">
<span>{{ record.enabled ? '是' : '否' }}</span>
</template>
</a-table-column>
<a-table-column title="最后检查时间" data-index="last_check_time" width="180">
<template #cell="{ record }">
<span>{{ formatTime(record.last_check_time) }}</span>
</template>
</a-table-column>
<a-table-column title="指标数据" width="300">
<template #cell="{ record }">
<div v-if="record.metrics && Object.keys(record.metrics).length > 0" class="metrics-cell">
<div v-for="(value, key) in record.metrics" :key="key" class="metric-item">
<span class="metric-name">{{ key }}:</span>
<span class="metric-value">{{ formatMetricValue(value) }}</span>
</div>
</div>
<span v-else>-</span>
</template>
</a-table-column>
</template>
</a-table>
<!-- 空状态 -->
<a-empty v-if="!loading && tableData.length === 0" description="暂无数据" />
</div>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchNetworkDeviceStatus } from '@/api/ops/report'
// 表单模型
const formModel = reactive({
service_identities: '',
metric_names: '',
aggregation: 'avg',
timeRange: [],
})
// 加载状态
const loading = ref(false)
// 表格数据
const tableData = ref<any[]>([])
// 获取状态颜色
const getStatusColor = (status: string) => {
if (!status) return 'gray'
const statusMap: Record<string, string> = {
online: 'green',
offline: 'red',
warning: 'orange',
unknown: 'gray',
}
return statusMap[status.toLowerCase()] || 'gray'
}
// 格式化响应时间
const formatResponseTime = (time: number | null) => {
if (time === null || time === undefined) return '-'
return `${time.toFixed(2)} ms`
}
// 格式化运行时间
const formatUptime = (seconds: number | null) => {
if (seconds === null || seconds === undefined) return '-'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) {
return `${days}${hours}小时`
} else if (hours > 0) {
return `${hours}小时 ${minutes}分钟`
} else {
return `${minutes}分钟`
}
}
// 格式化时间
const formatTime = (time: string | null) => {
if (!time) return '-'
try {
const date = new Date(time)
return date.toLocaleString('zh-CN')
} catch {
return time
}
}
// 格式化指标值
const formatMetricValue = (metric: any) => {
if (!metric) return '-'
const { value, metric_unit } = metric
if (value === null || value === undefined) return '-'
const formattedValue = typeof value === 'number' ? value.toFixed(2) : value
return `${formattedValue} ${metric_unit || ''}`.trim()
}
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.service_identities) {
Message.warning('请输入服务标识')
return
}
// 如果填写了指标名称,必须同时填写时间范围
if (formModel.metric_names && (!formModel.timeRange || formModel.timeRange.length !== 2)) {
Message.warning('填写指标名称时必须选择时间范围')
return
}
// 如果填写了时间范围,必须同时填写指标名称
if (formModel.timeRange && formModel.timeRange.length === 2 && !formModel.metric_names) {
Message.warning('选择时间范围时必须填写指标名称')
return
}
loading.value = true
try {
const params: any = {
service_identities: formModel.service_identities,
}
if (formModel.metric_names) {
params.metric_names = formModel.metric_names
params.aggregation = formModel.aggregation
params.start_time = formModel.timeRange[0]
params.end_time = formModel.timeRange[1]
}
const res = await fetchNetworkDeviceStatus(params)
if (res.code === 0) {
tableData.value = res.data?.data || []
if (tableData.value.length === 0) {
Message.info('未查询到数据')
}
} else {
Message.error(res.message || '查询失败')
tableData.value = []
}
} catch (error: any) {
console.error('查询失败:', error)
Message.error(error.message || '查询失败')
tableData.value = []
} finally {
loading.value = false
}
}
// 重置
const handleReset = () => {
formModel.service_identities = ''
formModel.metric_names = ''
formModel.aggregation = 'avg'
formModel.timeRange = []
tableData.value = []
}
</script>
<script lang="ts">
export default {
name: 'NetworkDeviceStatusPanel',
}
</script>
<style scoped lang="less">
.network-device-status-panel {
.search-form {
margin-bottom: 20px;
padding: 16px;
background-color: var(--color-fill-2);
border-radius: 4px;
:deep(.arco-form-item) {
margin-bottom: 12px;
}
}
.result-table {
background-color: #fff;
.metrics-cell {
max-height: 200px;
overflow-y: auto;
.metric-item {
margin-bottom: 4px;
font-size: 12px;
.metric-name {
color: var(--color-text-2);
margin-right: 4px;
}
.metric-value {
color: var(--color-text-1);
font-weight: 500;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,266 @@
<template>
<div class="server-status-panel">
<!-- 查询表单 -->
<a-form :model="formModel" layout="inline" class="search-form">
<a-form-item label="服务器标识" field="server_identities">
<a-input
v-model="formModel.server_identities"
placeholder="多个标识,逗号分隔"
style="width: 250px"
/>
</a-form-item>
<a-form-item label="指标名称" field="metric_names">
<a-input
v-model="formModel.metric_names"
placeholder="多个指标名称,逗号分隔(可选)"
style="width: 250px"
/>
</a-form-item>
<a-form-item label="聚合方式" field="aggregation">
<a-select
v-model="formModel.aggregation"
placeholder="请选择聚合方式"
style="width: 120px"
>
<a-option value="avg">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
<a-option value="sum">求和</a-option>
<a-option value="count">计数</a-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围" field="timeRange">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" :loading="loading" @click="handleSearch">
查询
</a-button>
<a-button @click="handleReset">
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 结果表格 -->
<a-table
:data="tableData"
:loading="loading"
:pagination="false"
:bordered="false"
stripe
class="result-table"
>
<template #columns>
<a-table-column title="服务器标识" data-index="server_identity" width="180" fixed="left" />
<a-table-column title="名称" data-index="name" width="150" />
<a-table-column title="主机" data-index="host" width="150" />
<a-table-column title="IP 地址" data-index="ip_address" width="150" />
<a-table-column title="状态" data-index="status" width="100">
<template #cell="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ record.status || '-' }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="启用" data-index="enable" width="80">
<template #cell="{ record }">
<span>{{ record.enable ? '是' : '否' }}</span>
</template>
</a-table-column>
<a-table-column title="最后检查时间" data-index="last_check_time" width="180">
<template #cell="{ record }">
<span>{{ formatTime(record.last_check_time) }}</span>
</template>
</a-table-column>
<a-table-column title="指标数据" width="300">
<template #cell="{ record }">
<div v-if="record.metrics && Object.keys(record.metrics).length > 0" class="metrics-cell">
<div v-for="(value, key) in record.metrics" :key="key" class="metric-item">
<span class="metric-name">{{ key }}:</span>
<span class="metric-value">{{ formatMetricValue(value) }}</span>
</div>
</div>
<span v-else>-</span>
</template>
</a-table-column>
</template>
</a-table>
<!-- 空状态 -->
<a-empty v-if="!loading && tableData.length === 0" description="暂无数据" />
</div>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchServerStatus } from '@/api/ops/report'
// 表单模型
const formModel = reactive({
server_identities: '',
metric_names: '',
aggregation: 'avg',
timeRange: [],
})
// 加载状态
const loading = ref(false)
// 表格数据
const tableData = ref<any[]>([])
// 获取状态颜色
const getStatusColor = (status: string) => {
if (!status) return 'gray'
const statusMap: Record<string, string> = {
online: 'green',
offline: 'red',
warning: 'orange',
unknown: 'gray',
}
return statusMap[status.toLowerCase()] || 'gray'
}
// 格式化时间
const formatTime = (time: string | null) => {
if (!time) return '-'
try {
const date = new Date(time)
return date.toLocaleString('zh-CN')
} catch {
return time
}
}
// 格式化指标值
const formatMetricValue = (metric: any) => {
if (!metric) return '-'
const { value, metric_unit } = metric
if (value === null || value === undefined) return '-'
const formattedValue = typeof value === 'number' ? value.toFixed(2) : value
return `${formattedValue} ${metric_unit || ''}`.trim()
}
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.server_identities) {
Message.warning('请输入服务器标识')
return
}
// 如果填写了指标名称,必须同时填写时间范围
if (formModel.metric_names && (!formModel.timeRange || formModel.timeRange.length !== 2)) {
Message.warning('填写指标名称时必须选择时间范围')
return
}
// 如果填写了时间范围,必须同时填写指标名称
if (formModel.timeRange && formModel.timeRange.length === 2 && !formModel.metric_names) {
Message.warning('选择时间范围时必须填写指标名称')
return
}
loading.value = true
try {
const params: any = {
server_identities: formModel.server_identities,
}
if (formModel.metric_names) {
params.metric_names = formModel.metric_names
params.aggregation = formModel.aggregation
params.start_time = formModel.timeRange[0]
params.end_time = formModel.timeRange[1]
}
const res = await fetchServerStatus(params)
if (res.code === 0) {
tableData.value = res.data?.data || []
if (tableData.value.length === 0) {
Message.info('未查询到数据')
}
} else {
Message.error(res.message || '查询失败')
tableData.value = []
}
} catch (error: any) {
console.error('查询失败:', error)
Message.error(error.message || '查询失败')
tableData.value = []
} finally {
loading.value = false
}
}
// 重置
const handleReset = () => {
formModel.server_identities = ''
formModel.metric_names = ''
formModel.aggregation = 'avg'
formModel.timeRange = []
tableData.value = []
}
</script>
<script lang="ts">
export default {
name: 'ServerStatusPanel',
}
</script>
<style scoped lang="less">
.server-status-panel {
.search-form {
margin-bottom: 20px;
padding: 16px;
background-color: var(--color-fill-2);
border-radius: 4px;
:deep(.arco-form-item) {
margin-bottom: 12px;
}
}
.result-table {
background-color: #fff;
.metrics-cell {
max-height: 200px;
overflow-y: auto;
.metric-item {
margin-bottom: 4px;
font-size: 12px;
.metric-name {
color: var(--color-text-2);
margin-right: 4px;
}
.metric-value {
color: var(--color-text-1);
font-weight: 500;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,350 @@
<template>
<div class="traffic-summary-panel">
<!-- 查询表单 -->
<a-form :model="formModel" layout="inline" class="search-form">
<a-form-item label="拓扑 ID" field="topology_id">
<a-input-number
v-model="formModel.topology_id"
:min="0"
placeholder="拓扑 ID"
style="width: 150px"
/>
</a-form-item>
<a-form-item label="链路 ID" field="link_id">
<a-input-number
v-model="formModel.link_id"
:min="0"
placeholder="链路 ID"
style="width: 150px"
/>
</a-form-item>
<a-form-item label="节点 ID" field="node_ids">
<a-input
v-model="formModel.node_ids"
placeholder="多个节点 ID逗号分隔"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="时间范围" field="timeRange">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" :loading="loading" @click="handleSearch">
查询
</a-button>
<a-button @click="handleReset">
重置
</a-button>
<a-button :loading="exporting" @click="handleExport">
导出 CSV
</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 汇总信息卡片 -->
<div v-if="summaryData.totals" class="summary-cards">
<a-card title="流量汇总" :bordered="false" class="summary-card">
<a-descriptions :column="3" bordered>
<a-descriptions-item v-for="(value, key) in displayTotals" :key="key" :label="formatLabel(key)">
{{ formatValue(key, value) }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</div>
<!-- 节点流量表格 -->
<a-divider v-if="summaryData.by_node && summaryData.by_node.length > 0">节点流量明细</a-divider>
<a-table
v-if="summaryData.by_node && summaryData.by_node.length > 0"
:data="summaryData.by_node"
:loading="loading"
:pagination="false"
:bordered="false"
stripe
class="result-table"
>
<template #columns>
<a-table-column title="节点 ID" data-index="node_id" width="200" fixed="left" />
<a-table-column
v-for="column in dynamicColumns"
:key="String(column.key)"
:title="column.title"
:data-index="String(column.key)"
:width="150"
>
<template #cell="{ record }">
<span>{{ formatValue(column.key, record[column.key]) }}</span>
</template>
</a-table-column>
</template>
</a-table>
<!-- 空状态 -->
<a-empty v-if="!loading && !summaryData.totals" description="暂无数据" />
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchTrafficSummary, exportTrafficReport } from '@/api/ops/report'
// 表单模型
const formModel = reactive({
topology_id: 0,
link_id: 0,
node_ids: '',
timeRange: [],
})
// 加载状态
const loading = ref(false)
const exporting = ref(false)
// 汇总数据
const summaryData = reactive<any>({
totals: null,
by_node: [],
})
// 显示的汇总字段(过滤掉元数据)
const displayTotals = computed(() => {
if (!summaryData.totals) return {}
const totals: any = { ...summaryData.totals }
delete totals.topology_id
delete totals.link_id
delete totals.node_ids
return totals
})
// 动态列(从第一条记录中提取所有字段,排除 node_id
const dynamicColumns = computed(() => {
if (!summaryData.by_node || summaryData.by_node.length === 0) return []
const firstRecord = summaryData.by_node[0]
const columns = []
for (const key in firstRecord) {
if (key !== 'node_id') {
columns.push({
key,
title: formatLabel(key),
})
}
}
return columns
})
// 格式化标签
const formatLabel = (key: string) => {
const labelMap: Record<string, string> = {
total_in_bytes: '总入流量(字节)',
total_out_bytes: '总出流量(字节)',
total_bytes: '总流量(字节)',
avg_latency: '平均延迟(ms)',
max_latency: '最大延迟(ms)',
min_latency: '最小延迟(ms)',
peak_bandwidth: '峰值带宽(Mbps)',
avg_bandwidth: '平均带宽(Mbps)',
total_packets: '总包数',
packet_loss_rate: '丢包率(%)',
total_connections: '总连接数',
avg_connections: '平均连接数',
peak_connections: '峰值连接数',
}
return labelMap[key] || key
}
// 格式化数值
const formatValue = (key: string, value: any) => {
if (value === null || value === undefined) return '-'
// 带宽转换为 Mbps
if (key.includes('bandwidth') && typeof value === 'number') {
return (value / 1024 / 1024).toFixed(2)
}
// 丢包率转换为百分比
if (key === 'packet_loss_rate' && typeof value === 'number') {
return (value * 100).toFixed(2) + '%'
}
// 字节数转换为更易读的单位
if (key.includes('bytes') && typeof value === 'number') {
if (value > 1024 * 1024 * 1024) {
return (value / 1024 / 1024 / 1024).toFixed(2) + ' GB'
} else if (value > 1024 * 1024) {
return (value / 1024 / 1024).toFixed(2) + ' MB'
} else if (value > 1024) {
return (value / 1024).toFixed(2) + ' KB'
}
return value + ' B'
}
// 数字类型保留两位小数
if (typeof value === 'number') {
return value.toFixed(2)
}
return value
}
// 构建查询参数
const buildParams = () => {
const params: any = {
start_time: formModel.timeRange[0],
end_time: formModel.timeRange[1],
kind: 'summary',
}
if (formModel.topology_id > 0) {
params.topology_id = formModel.topology_id
}
if (formModel.link_id > 0) {
params.link_id = formModel.link_id
}
if (formModel.node_ids) {
params.node_ids = formModel.node_ids
}
return params
}
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
loading.value = true
try {
const params = buildParams()
const res = await fetchTrafficSummary(params)
if (res.code === 0) {
summaryData.totals = res.data?.totals || null
summaryData.by_node = res.data?.by_node || []
if (!summaryData.totals && summaryData.by_node.length === 0) {
Message.info('未查询到数据')
}
} else {
Message.error(res.message || '查询失败')
summaryData.totals = null
summaryData.by_node = []
}
} catch (error: any) {
console.error('查询失败:', error)
Message.error(error.message || '查询失败')
summaryData.totals = null
summaryData.by_node = []
} finally {
loading.value = false
}
}
// 重置
const handleReset = () => {
formModel.topology_id = 0
formModel.link_id = 0
formModel.node_ids = ''
formModel.timeRange = []
summaryData.totals = null
summaryData.by_node = []
}
// 导出
const handleExport = async () => {
// 验证必填项
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
exporting.value = true
try {
const params = buildParams()
params.utf8_bom = 'true'
const blob = await exportTrafficReport(params)
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
// 生成文件名
const now = new Date()
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`
link.download = `traffic_summary_${timestamp}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
Message.success('导出成功')
} catch (error: any) {
console.error('导出失败:', error)
Message.error(error.message || '导出失败')
} finally {
exporting.value = false
}
}
</script>
<script lang="ts">
export default {
name: 'TrafficSummaryPanel',
}
</script>
<style scoped lang="less">
.traffic-summary-panel {
.search-form {
margin-bottom: 20px;
padding: 16px;
background-color: var(--color-fill-2);
border-radius: 4px;
:deep(.arco-form-item) {
margin-bottom: 12px;
}
}
.summary-cards {
margin-bottom: 20px;
.summary-card {
:deep(.arco-card-body) {
padding: 16px;
}
}
}
.result-table {
background-color: #fff;
}
}
</style>

View File

@@ -0,0 +1,530 @@
<template>
<div class="traffic-trend-panel">
<!-- 查询表单 -->
<a-form :model="formModel" layout="inline" class="search-form">
<a-form-item label="拓扑 ID" field="topology_id">
<a-input-number
v-model="formModel.topology_id"
:min="0"
placeholder="拓扑 ID"
style="width: 150px"
/>
</a-form-item>
<a-form-item label="链路 ID" field="link_id">
<a-input-number
v-model="formModel.link_id"
:min="0"
placeholder="链路 ID"
style="width: 150px"
/>
</a-form-item>
<a-form-item label="节点 ID" field="node_ids">
<a-input
v-model="formModel.node_ids"
placeholder="多个节点 ID逗号分隔"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="时间粒度" field="granularity">
<a-select
v-model="formModel.granularity"
placeholder="请选择时间粒度"
style="width: 120px"
>
<a-option value="minute">分钟</a-option>
<a-option value="hour">小时</a-option>
<a-option value="day"></a-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围" field="timeRange">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" :loading="loading" @click="handleSearch">
查询
</a-button>
<a-button @click="handleReset">
重置
</a-button>
<a-button :loading="exporting" @click="handleExport">
导出 CSV
</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 趋势图表 -->
<div v-if="trendData.length > 0" class="chart-section">
<a-card title="流量趋势图" :bordered="false">
<div ref="chartRef" class="chart-container" />
</a-card>
</div>
<!-- 趋势数据表格 -->
<a-divider v-if="trendData.length > 0">趋势数据明细</a-divider>
<a-table
v-if="trendData.length > 0"
:data="tableData"
:loading="loading"
:pagination="pagination"
:bordered="false"
stripe
class="result-table"
@page-change="handlePageChange"
>
<template #columns>
<a-table-column title="时间" data-index="time" width="180" fixed="left" />
<a-table-column title="节点 ID" data-index="node_id" width="180" />
<a-table-column
v-for="column in dynamicColumns"
:key="String(column.key)"
:title="column.title"
:data-index="String(column.key)"
:width="150"
>
<template #cell="{ record }">
<span>{{ formatValue(column.key, record[column.key]) }}</span>
</template>
</a-table-column>
</template>
</a-table>
<!-- 空状态 -->
<a-empty v-if="!loading && trendData.length === 0" description="暂无数据" />
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Message } from '@arco-design/web-vue'
import * as echarts from 'echarts'
import { fetchTrafficTrend, exportTrafficReport } from '@/api/ops/report'
// 表单模型
const formModel = reactive({
topology_id: 0,
link_id: 0,
node_ids: '',
granularity: 'hour',
timeRange: [],
})
// 加载状态
const loading = ref(false)
const exporting = ref(false)
// 趋势数据
const trendData = ref<any[]>([])
// 表格数据(分页)
const tableData = ref<any[]>([])
// 分页
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 图表引用
const chartRef = ref<HTMLElement>()
let chartInstance: echarts.ECharts | null = null
// 动态列(从第一条记录中提取所有字段,排除 time、timestamp、node_id
const dynamicColumns = computed(() => {
if (trendData.value.length === 0) return []
const firstRecord = trendData.value[0]
const columns = []
for (const key in firstRecord) {
if (key !== 'time' && key !== 'timestamp' && key !== 'node_id') {
columns.push({
key,
title: formatLabel(key),
})
}
}
return columns
})
// 格式化标签
const formatLabel = (key: string) => {
const labelMap: Record<string, string> = {
in_bytes: '入流量(字节)',
out_bytes: '出流量(字节)',
total_bytes: '总流量(字节)',
latency: '延迟(ms)',
packet_loss: '丢包率(%)',
bandwidth: '带宽(Mbps)',
connections: '连接数',
}
return labelMap[key] || key
}
// 格式化数值
const formatValue = (key: string, value: any) => {
if (value === null || value === undefined) return '-'
// 带宽转换为 Mbps
if (key.includes('bandwidth') && typeof value === 'number') {
return (value / 1024 / 1024).toFixed(2)
}
// 丢包率转换为百分比
if (key.includes('packet_loss') && typeof value === 'number') {
return (value * 100).toFixed(2) + '%'
}
// 字节数转换为更易读的单位
if (key.includes('bytes') && typeof value === 'number') {
if (value > 1024 * 1024 * 1024) {
return (value / 1024 / 1024 / 1024).toFixed(2) + ' GB'
} else if (value > 1024 * 1024) {
return (value / 1024 / 1024).toFixed(2) + ' MB'
} else if (value > 1024) {
return (value / 1024).toFixed(2) + ' KB'
}
return value + ' B'
}
// 数字类型保留两位小数
if (typeof value === 'number') {
return value.toFixed(2)
}
return value
}
// 初始化图表
const initChart = () => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
renderChart()
window.addEventListener('resize', handleResize)
}
// 渲染图表
const renderChart = () => {
if (!chartInstance || trendData.value.length === 0) return
// 提取时间轴
const timeAxis = [...new Set(trendData.value.map(item => item.time))].sort()
// 按节点分组数据
const nodeGroups: Record<string, any[]> = {}
trendData.value.forEach(item => {
if (!nodeGroups[item.node_id]) {
nodeGroups[item.node_id] = []
}
nodeGroups[item.node_id].push(item)
})
// 构建系列数据
const series: any[] = []
const legend: string[] = []
// 为每个节点创建系列
for (const nodeId in nodeGroups) {
const nodeData = nodeGroups[nodeId]
// 创建时间索引映射
const timeIndexMap: Record<string, any> = {}
nodeData.forEach(item => {
timeIndexMap[item.time] = item
})
// 按时间轴顺序填充数据
const totalBytesData = timeAxis.map(time => {
const item = timeIndexMap[time]
return item ? item.total_bytes : null
})
const latencyData = timeAxis.map(time => {
const item = timeIndexMap[time]
return item ? item.latency : null
})
series.push({
name: `${nodeId} - 总流量`,
type: 'line',
data: totalBytesData,
yAxisIndex: 0,
smooth: true,
})
series.push({
name: `${nodeId} - 延迟`,
type: 'line',
data: latencyData,
yAxisIndex: 1,
smooth: true,
})
legend.push(`${nodeId} - 总流量`)
legend.push(`${nodeId} - 延迟`)
}
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
},
legend: {
data: legend,
top: 10,
},
grid: {
left: '3%',
right: '3%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
data: timeAxis,
boundaryGap: false,
},
yAxis: [
{
type: 'value',
name: '流量(字节)',
position: 'left',
axisLabel: {
formatter: (value: number) => {
if (value > 1024 * 1024 * 1024) {
return (value / 1024 / 1024 / 1024).toFixed(2) + ' GB'
} else if (value > 1024 * 1024) {
return (value / 1024 / 1024).toFixed(2) + ' MB'
}
return value
},
},
},
{
type: 'value',
name: '延迟(ms)',
position: 'right',
},
],
series,
}
chartInstance.setOption(option)
}
// 处理窗口大小变化
const handleResize = () => {
if (chartInstance) {
chartInstance.resize()
}
}
// 构建查询参数
const buildParams = () => {
const params: any = {
start_time: formModel.timeRange[0],
end_time: formModel.timeRange[1],
kind: 'trend',
granularity: formModel.granularity,
}
if (formModel.topology_id > 0) {
params.topology_id = formModel.topology_id
}
if (formModel.link_id > 0) {
params.link_id = formModel.link_id
}
if (formModel.node_ids) {
params.node_ids = formModel.node_ids
}
return params
}
// 更新表格数据
const updateTableData = () => {
const start = (pagination.current - 1) * pagination.pageSize
const end = start + pagination.pageSize
tableData.value = trendData.value.slice(start, end)
pagination.total = trendData.value.length
}
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
loading.value = true
try {
const params = buildParams()
const res = await fetchTrafficTrend(params)
if (res.code === 0) {
trendData.value = res.data?.data || []
pagination.total = trendData.value.length
pagination.current = 1
if (trendData.value.length > 0) {
updateTableData()
await nextTick()
if (!chartInstance) {
initChart()
} else {
renderChart()
}
}
if (trendData.value.length === 0) {
Message.info('未查询到数据')
}
} else {
Message.error(res.message || '查询失败')
trendData.value = []
tableData.value = []
pagination.total = 0
}
} catch (error: any) {
console.error('查询失败:', error)
Message.error(error.message || '查询失败')
trendData.value = []
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 重置
const handleReset = () => {
formModel.topology_id = 0
formModel.link_id = 0
formModel.node_ids = ''
formModel.granularity = 'hour'
formModel.timeRange = []
trendData.value = []
tableData.value = []
pagination.total = 0
pagination.current = 1
if (chartInstance) {
chartInstance.clear()
}
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
updateTableData()
}
// 导出
const handleExport = async () => {
// 验证必填项
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
exporting.value = true
try {
const params = buildParams()
params.utf8_bom = 'true'
const blob = await exportTrafficReport(params)
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
// 生成文件名
const now = new Date()
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`
link.download = `traffic_trend_${timestamp}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
Message.success('导出成功')
} catch (error: any) {
console.error('导出失败:', error)
Message.error(error.message || '导出失败')
} finally {
exporting.value = false
}
}
// 生命周期钩子
onMounted(() => {
// 可选:自动加载默认数据
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
})
</script>
<script lang="ts">
export default {
name: 'TrafficTrendPanel',
}
</script>
<style scoped lang="less">
.traffic-trend-panel {
.search-form {
margin-bottom: 20px;
padding: 16px;
background-color: var(--color-fill-2);
border-radius: 4px;
:deep(.arco-form-item) {
margin-bottom: 12px;
}
}
.chart-section {
margin-bottom: 20px;
.chart-container {
width: 100%;
height: 400px;
}
}
.result-table {
background-color: #fff;
}
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div class="container">
<a-card :bordered="false">
<template #title>
<div class="page-title">报表中心</div>
</template>
<a-tabs v-model:active-key="activeTab" @change="handleTabChange">
<!-- 监测指标 TOPN -->
<a-tab-pane key="metrics-topn" title="监测指标 TOPN">
<metrics-topn-panel />
</a-tab-pane>
<!-- 监测指标汇总 -->
<a-tab-pane key="metrics-summary" title="监测指标汇总">
<metrics-summary-panel />
</a-tab-pane>
<!-- 流量报表汇总 -->
<a-tab-pane key="traffic-summary" title="流量报表汇总">
<traffic-summary-panel />
</a-tab-pane>
<!-- 流量报表趋势 -->
<a-tab-pane key="traffic-trend" title="流量报表趋势">
<traffic-trend-panel />
</a-tab-pane>
<!-- 服务器状态 -->
<a-tab-pane key="server-status" title="服务器状态">
<server-status-panel />
</a-tab-pane>
<!-- 网络设备状态 -->
<a-tab-pane key="network-status" title="网络设备状态">
<network-status-panel />
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import MetricsTopNPanel from './components/MetricsTopNPanel.vue'
import MetricsSummaryPanel from './components/MetricsSummaryPanel.vue'
import TrafficSummaryPanel from './components/TrafficSummaryPanel.vue'
import TrafficTrendPanel from './components/TrafficTrendPanel.vue'
import ServerStatusPanel from './components/ServerStatusPanel.vue'
import NetworkStatusPanel from './components/NetworkStatusPanel.vue'
// 当前激活的标签页
const activeTab = ref('metrics-topn')
// 标签页切换
const handleTabChange = (key: string) => {
console.log('切换到标签页:', key)
}
</script>
<script lang="ts">
export default {
name: 'ReportHistory',
}
</script>
<style scoped lang="less">
.container {
padding: 20px;
.page-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
}
}
:deep(.arco-tabs-content) {
padding-top: 16px;
}
:deep(.arco-tabs-nav) {
padding: 0 8px;
}
</style>

View File

@@ -98,6 +98,20 @@
<span class="switch-label">嵌入网页</span>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="is_full">
<a-switch v-model="formData.is_full" />
<span class="switch-label">单独页面不包含菜单</span>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="hide_menu">
<a-switch v-model="formData.hide_menu" />
<span class="switch-label">隐藏菜单栏</span>
</a-form-item>
</a-col>
</a-row>
<!-- 网页嵌入配置 -->
@@ -190,6 +204,8 @@ const formData = ref<MenuRouteRequest>({
is_web_page: false,
web_url: '',
is_new_tab: false,
is_full: false,
hide_menu: false,
// 编辑时需要的额外字段
id: undefined,
identity: undefined,
@@ -232,6 +248,8 @@ const resetForm = () => {
is_web_page: false,
web_url: '',
is_new_tab: false,
is_full: false,
hide_menu: false,
id: undefined,
identity: undefined,
app_id: undefined,
@@ -267,6 +285,8 @@ watch(
is_web_page: initialValues.is_web_page || false,
web_url: initialValues.web_url || '',
is_new_tab: initialValues.is_new_tab || false,
is_full: initialValues.is_full || false,
hide_menu: initialValues.hide_menu || false,
}
}
},