This commit is contained in:
ygx
2026-03-22 19:23:34 +08:00
parent 05dd1fed63
commit 45e9765d40
14 changed files with 5473 additions and 2542 deletions

View File

@@ -0,0 +1,840 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="tableColumns"
:loading="loading"
:pagination="pagination"
:title="pageTitle"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<!-- 自定义表单项创建时间范围 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="创建时间" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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: 100%"
/>
</a-form-item>
</a-col>
</template>
<!-- 工具栏左侧生成报表按钮 -->
<template #toolbar-left>
<a-space>
<a-button type="primary" @click="handleOpenGenerateModal">
<template #icon><icon-file-add /></template>
生成报表
</a-button>
</a-space>
</template>
<!-- 工具栏右侧查看和导出按钮 -->
<template #toolbar-right>
<a-space>
<a-button v-if="selectedRecord" type="outline" @click="handleViewContent()">
<template #icon><icon-eye /></template>
查看内容
</a-button>
<a-dropdown v-if="selectedRecord">
<a-button type="outline">
<template #icon><icon-download /></template>
导出
</a-button>
<template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
<!-- 操作列 -->
<template #operations="{ record }">
<a-space>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleViewContent(record)"
>
查看
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('csv', record)"
>
CSV
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('xlsx', record)"
>
Excel
</a-button>
</a-space>
</template>
</search-table>
<!-- 生成报表弹窗 -->
<a-modal
v-model:visible="generateModalVisible"
title="生成网络设备报表"
:width="600"
:ok-loading="generating"
@ok="handleGenerate"
@cancel="handleCloseGenerateModal"
>
<a-form :model="generateForm" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="拓扑 ID" field="topology_id" :rules="[{ required: true, message: '请输入拓扑 ID' }]">
<a-input-number
v-model="generateForm.topology_id"
placeholder="请输入拓扑 ID"
:min="1"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="链路 ID" field="link_id">
<a-input-number
v-model="generateForm.link_id"
placeholder="可选0 表示整拓扑"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="节点 ID" field="node_id">
<a-input v-model="generateForm.node_id" placeholder="可选" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="时间粒度" field="granularity">
<a-select v-model="generateForm.granularity" placeholder="请选择" style="width: 100%">
<a-option value="minute">分钟</a-option>
<a-option value="hour">小时</a-option>
<a-option value="day"></a-option>
<a-option value="month"></a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
<a-range-picker
v-model="generateForm.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="报表形态" field="report_shape" :rules="[{ required: true, message: '请选择报表形态' }]">
<a-select v-model="generateForm.report_shape" placeholder="请选择" style="width: 100%">
<a-option value="summary">汇总</a-option>
<a-option value="detail">明细</a-option>
<a-option value="trend">趋势</a-option>
<a-option value="top">Top 排名</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="报表标题" field="title">
<a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<!-- Top 排名额外参数 -->
<template v-if="generateForm.report_shape === 'top'">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="排序字段" field="top_order_by">
<a-select v-model="generateForm.top_order_by" placeholder="请选择" style="width: 100%">
<a-option value="total_bytes">总流量</a-option>
<a-option value="total_in_bytes">总入流量</a-option>
<a-option value="total_out_bytes">总出流量</a-option>
<a-option value="total_packets">总包数</a-option>
<a-option value="avg_latency">平均延迟</a-option>
<a-option value="avg_packet_loss">平均丢包率</a-option>
<a-option value="total_connections">总连接数</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="Top 数量" field="top_limit">
<a-input-number
v-model="generateForm.top_limit"
placeholder="1-100"
:min="1"
:max="100"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</template>
<!-- 明细额外参数 -->
<template v-if="generateForm.report_shape === 'detail'">
<a-form-item label="明细条数限制" field="detail_limit">
<a-input-number
v-model="generateForm.detail_limit"
placeholder="1-50000"
:min="1"
:max="50000"
style="width: 100%"
/>
</a-form-item>
</template>
<!-- 趋势额外参数 -->
<template v-if="generateForm.report_shape === 'trend'">
<a-form-item label="趋势粒度" field="trend_granularity">
<a-select v-model="generateForm.trend_granularity" placeholder="请选择" style="width: 100%">
<a-option value="minute">分钟</a-option>
<a-option value="hour">小时</a-option>
<a-option value="day"></a-option>
<a-option value="month"></a-option>
</a-select>
</a-form-item>
</template>
</a-form>
</a-modal>
<!-- 查看内容弹窗 -->
<a-modal
v-model:visible="contentModalVisible"
:title="contentModalTitle"
:width="900"
:footer="false"
>
<div v-if="contentLoading" class="loading-container">
<a-spin />
</div>
<div v-else-if="reportContent">
<!-- 汇总数据 -->
<template v-if="reportContentType === 'summary'">
<a-card v-if="reportContent.totals" title="设备汇总" :bordered="false" class="summary-card">
<a-descriptions :column="3" bordered>
<a-descriptions-item
v-for="(value, key) in reportContent.totals"
:key="key"
:label="formatLabel(String(key))"
>
{{ formatValue(String(key), value) }}
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-table
:data="reportContent.by_node || []"
:columns="contentTableColumns"
:pagination="{ pageSize: 10 }"
stripe
/>
</template>
<!-- 明细数据 -->
<template v-else-if="reportContentType === 'detail'">
<a-table
:data="reportContent.items || []"
:columns="contentTableColumns"
:pagination="{ pageSize: 10 }"
stripe
/>
</template>
<!-- 趋势数据 -->
<template v-else-if="reportContentType === 'trend'">
<div ref="chartRef" class="chart-container"></div>
</template>
<!-- Top 排名数据 -->
<template v-else-if="reportContentType === 'top'">
<a-table
:data="reportContent.ranking || []"
:columns="contentTableColumns"
:pagination="false"
stripe
/>
</template>
</div>
<a-empty v-else description="暂无数据" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, nextTick } from 'vue'
import { Message } from '@arco-design/web-vue'
import SearchTable from '@/components/search-table/index.vue'
import type { FormItem } from '@/components/search-form/types'
import {
fetchReportList,
generateReport,
fetchReportContent,
exportReport,
ReportType,
type ReportRecord,
type TrafficReportParams,
} from '@/api/ops/report'
import * as echarts from 'echarts'
// 页面标题
const pageTitle = '网络设备报表'
// 列表筛选表单模型
const formModel = ref<{
report_type: string
status: string
keyword: string
timeRange: string[]
}>({
report_type: ReportType.NETWORK_DEVICE,
status: '',
keyword: '',
timeRange: [],
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'status',
label: '状态',
type: 'select',
span: 8,
placeholder: '请选择',
options: [
{ value: '', label: '全部' },
{ value: 'pending', label: '等待中' },
{ value: 'running', label: '生成中' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
],
},
{
field: 'keyword',
label: '标题',
type: 'input',
span: 8,
placeholder: '请输入标题关键字',
},
])
// 状态
const loading = ref(false)
const generating = ref(false)
const contentLoading = ref(false)
const exporting = ref(false)
const tableData = ref<ReportRecord[]>([])
const selectedRecord = ref<ReportRecord | null>(null)
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: true,
showJumper: true,
showPageSize: true,
})
// 表格列配置
const tableColumns = computed(() => [
{
title: 'ID',
dataIndex: 'id',
width: 80,
},
{
title: '标题',
dataIndex: 'title',
width: 200,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
slotName: 'status',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 180,
},
{
title: '操作',
dataIndex: 'operations',
width: 180,
slotName: 'operations',
fixed: 'right' as const,
},
])
// 生成报表弹窗
const generateModalVisible = ref(false)
const generateForm = ref<{
topology_id: number | undefined
link_id: number | undefined
node_id: string
granularity: string
timeRange: string[]
report_shape: string
title: string
top_order_by: string
top_limit: number | undefined
detail_limit: number | undefined
trend_granularity: string
}>({
topology_id: undefined,
link_id: undefined,
node_id: '',
granularity: 'hour',
timeRange: [],
report_shape: 'summary',
title: '',
top_order_by: 'total_bytes',
top_limit: undefined,
detail_limit: undefined,
trend_granularity: 'hour',
})
// 查看内容弹窗
const contentModalVisible = ref(false)
const contentModalTitle = ref('')
const reportContent = ref<Record<string, any> | null>(null)
const reportContentType = ref<string>('')
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
// 内容表格列配置
const contentTableColumns = computed(() => {
if (!reportContent.value) return []
const data =
reportContent.value.by_node ||
reportContent.value.items ||
reportContent.value.ranking ||
[]
if (data.length === 0) return []
const firstRecord = data[0]
return Object.keys(firstRecord).map((key) => ({
title: formatLabel(key),
dataIndex: key,
width: 150,
}))
})
// 获取报表列表
const fetchList = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
size: pagination.pageSize,
report_type: ReportType.NETWORK_DEVICE,
}
if (formModel.value.status) {
params.status = formModel.value.status
}
if (formModel.value.keyword) {
params.keyword = formModel.value.keyword
}
if (formModel.value.timeRange && formModel.value.timeRange.length === 2) {
params.created_from = formModel.value.timeRange[0]
params.created_to = formModel.value.timeRange[1]
}
const res = await fetchReportList(params)
if (res.code === 0 && res.details) {
tableData.value = res.details.data || []
pagination.total = res.details.total || 0
} else {
Message.error(res.message || '获取报表列表失败')
}
} catch (error: any) {
console.error('获取报表列表失败:', error)
Message.error(error.message || '获取报表列表失败')
} finally {
loading.value = false
}
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 查询
const handleSearch = () => {
pagination.current = 1
fetchList()
}
// 重置
const handleReset = () => {
formModel.value = {
report_type: ReportType.NETWORK_DEVICE,
status: '',
keyword: '',
timeRange: [],
}
pagination.current = 1
fetchList()
}
// 刷新
const handleRefresh = () => {
fetchList()
Message.success('数据已刷新')
}
// 打开生成报表弹窗
const handleOpenGenerateModal = () => {
generateForm.value = {
topology_id: undefined,
link_id: undefined,
node_id: '',
granularity: 'hour',
timeRange: [],
report_shape: 'summary',
title: '',
top_order_by: 'total_bytes',
top_limit: undefined,
detail_limit: undefined,
trend_granularity: 'hour',
}
generateModalVisible.value = true
}
// 关闭生成报表弹窗
const handleCloseGenerateModal = () => {
generateModalVisible.value = false
}
// 生成报表
const handleGenerate = async () => {
// 验证必填项
if (!generateForm.value.topology_id) {
Message.warning('请输入拓扑 ID')
return
}
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
generating.value = true
try {
const params: TrafficReportParams = {
topology_id: generateForm.value.topology_id,
start_time: generateForm.value.timeRange[0],
end_time: generateForm.value.timeRange[1],
report_shape: generateForm.value.report_shape as any,
}
if (generateForm.value.link_id) {
params.link_id = generateForm.value.link_id
}
if (generateForm.value.node_id) {
params.node_id = generateForm.value.node_id
}
if (generateForm.value.granularity) {
params.granularity = generateForm.value.granularity as any
}
if (generateForm.value.report_shape === 'detail' && generateForm.value.detail_limit) {
params.detail_limit = generateForm.value.detail_limit
}
if (generateForm.value.report_shape === 'trend' && generateForm.value.trend_granularity) {
params.trend_granularity = generateForm.value.trend_granularity as any
}
if (generateForm.value.report_shape === 'top') {
if (generateForm.value.top_order_by) {
params.top_order_by = generateForm.value.top_order_by as any
}
if (generateForm.value.top_limit) {
params.top_limit = generateForm.value.top_limit
}
}
const res = await generateReport({
report_type: ReportType.NETWORK_DEVICE,
title: generateForm.value.title || undefined,
params,
})
if (res.code === 0 && res.details) {
if (res.details.status === 'success') {
Message.success('报表生成成功')
generateModalVisible.value = false
fetchList()
} else if (res.details.status === 'failed') {
Message.error(res.details.error_message || '报表生成失败')
} else {
Message.info('报表正在生成中,请稍后刷新查看')
generateModalVisible.value = false
fetchList()
}
} else {
Message.error(res.message || '报表生成失败')
}
} catch (error: any) {
console.error('生成报表失败:', error)
Message.error(error.message || '生成报表失败')
} finally {
generating.value = false
}
}
// 查看内容
const handleViewContent = async (record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) return
contentLoading.value = true
contentModalVisible.value = true
contentModalTitle.value = targetRecord.title
reportContent.value = null
try {
const res = await fetchReportContent(targetRecord.id)
if (res.code === 0 && res.details) {
reportContent.value = res.details
reportContentType.value = targetRecord.params_json?.report_shape || 'summary'
// 如果是趋势报表,渲染图表
if (reportContentType.value === 'trend' && res.details.series) {
await nextTick()
renderChart(res.details.series)
}
} else {
Message.error(res.message || '获取报表内容失败')
}
} catch (error: any) {
console.error('获取报表内容失败:', error)
Message.error(error.message || '获取报表内容失败')
} finally {
contentLoading.value = false
}
}
// 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) {
Message.warning('请选择要导出的报表')
return
}
if (targetRecord.status !== 'success') {
Message.warning('只能导出生成成功的报表')
return
}
exporting.value = true
try {
const blob = await exportReport(targetRecord.id, format)
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `report_${targetRecord.id}.${format}`
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
}
}
// 格式化标签
const formatLabel = (key: string) => {
const labelMap: Record<string, string> = {
node_id: '节点 ID',
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: '峰值连接数',
rank: '排名',
timestamp: '时间',
value: '值',
}
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 renderChart = (series: any[]) => {
if (!chartRef.value || series.length === 0) return
if (chartInstance) {
chartInstance.dispose()
}
chartInstance = echarts.init(chartRef.value)
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
},
legend: {
data: series.map((s) => s.name),
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: series[0]?.data?.map((item: any) => item.timestamp) || [],
},
yAxis: {
type: 'value',
},
series: series.map((s) => ({
name: s.name,
type: 'line',
smooth: true,
data: s.data?.map((item: any) => item.value) || [],
})),
}
chartInstance.setOption(option)
}
// 初始化
fetchList()
</script>
<script lang="ts">
export default {
name: 'NetworkDeviceReport',
}
</script>
<style scoped lang="less">
.container {
padding: 20px;
.summary-card {
margin-bottom: 20px;
:deep(.arco-card-body) {
padding: 16px;
}
}
.chart-container {
height: 400px;
width: 100%;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
}
</style>

View File

@@ -0,0 +1,724 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="tableColumns"
:loading="loading"
:pagination="pagination"
:title="pageTitle"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<!-- 自定义表单项创建时间范围 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="创建时间" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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: 100%"
/>
</a-form-item>
</a-col>
</template>
<!-- 工具栏左侧生成报表按钮 -->
<template #toolbar-left>
<a-space>
<a-button type="primary" @click="handleOpenGenerateModal">
<template #icon><icon-file-add /></template>
生成报表
</a-button>
</a-space>
</template>
<!-- 工具栏右侧查看和导出按钮 -->
<template #toolbar-right>
<a-space>
<a-button v-if="selectedRecord" type="outline" @click="handleViewContent()">
<template #icon><icon-eye /></template>
查看内容
</a-button>
<a-dropdown v-if="selectedRecord">
<a-button type="outline">
<template #icon><icon-download /></template>
导出
</a-button>
<template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
<!-- 操作列 -->
<template #operations="{ record }">
<a-space>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleViewContent(record)"
>
查看
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('csv', record)"
>
CSV
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('xlsx', record)"
>
Excel
</a-button>
</a-space>
</template>
</search-table>
<!-- 生成报表弹窗 -->
<a-modal
v-model:visible="generateModalVisible"
title="生成故障报表"
:width="600"
:ok-loading="generating"
@ok="handleGenerate"
@cancel="handleCloseGenerateModal"
>
<a-form :model="generateForm" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="数据源" field="fault_data_source">
<a-select v-model="generateForm.fault_data_source" placeholder="请选择" style="width: 100%">
<a-option value="collector_log">采集器日志</a-option>
<a-option value="alert">告警</a-option>
<a-option value="hybrid">混合</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="分组方式" field="group_by">
<a-select v-model="generateForm.group_by" placeholder="请选择" style="width: 100%">
<a-option value="none">不分组</a-option>
<a-option value="service_identity">按服务</a-option>
<a-option value="day">按日期</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
<a-range-picker
v-model="generateForm.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="服务标识" field="service_identities">
<a-input-tag
v-model="generateForm.service_identities"
placeholder="输入后按回车添加"
style="width: 100%"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="状态过滤" field="status">
<a-select v-model="generateForm.status" placeholder="请选择" style="width: 100%" allow-clear>
<a-option value="success">成功</a-option>
<a-option value="failed">失败</a-option>
<a-option value="timeout">超时</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="关键字" field="keyword">
<a-input v-model="generateForm.keyword" placeholder="消息/错误信息模糊匹配" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="报表标题" field="title">
<a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="明细条数限制" field="detail_limit">
<a-input-number
v-model="generateForm.detail_limit"
placeholder="不分组时限制条数"
:min="1"
:max="50000"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<!-- 告警相关参数 -->
<template v-if="generateForm.fault_data_source === 'alert' || generateForm.fault_data_source === 'hybrid'">
<a-divider orientation="left">告警过滤</a-divider>
<a-form-item label="告警级别" field="alert_severities">
<a-select
v-model="generateForm.alert_severities"
placeholder="请选择告警级别"
style="width: 100%"
multiple
>
<a-option value="critical">严重</a-option>
<a-option value="warning">警告</a-option>
<a-option value="info">信息</a-option>
</a-select>
</a-form-item>
<a-form-item label="包含原始消息" field="include_raw_messages">
<a-switch v-model="generateForm.include_raw_messages" />
</a-form-item>
</template>
</a-form>
</a-modal>
<!-- 查看内容弹窗 -->
<a-modal
v-model:visible="contentModalVisible"
:title="contentModalTitle"
:width="900"
:footer="false"
>
<div v-if="contentLoading" class="loading-container">
<a-spin />
</div>
<div v-else-if="reportContent">
<!-- 分组统计 -->
<template v-if="reportContent.grouped">
<a-card v-if="reportContent.summary" title="故障汇总" :bordered="false" class="summary-card">
<a-descriptions :column="3" bordered>
<a-descriptions-item
v-for="(value, key) in reportContent.summary"
:key="key"
:label="formatLabel(String(key))"
>
{{ formatValue(String(key), value) }}
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-table
:data="reportContent.groups || []"
:columns="groupTableColumns"
:pagination="{ pageSize: 10 }"
stripe
/>
</template>
<!-- 明细列表 -->
<template v-else>
<a-table
:data="reportContent.items || []"
:columns="contentTableColumns"
:pagination="{ pageSize: 10 }"
stripe
/>
</template>
</div>
<a-empty v-else description="暂无数据" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import SearchTable from '@/components/search-table/index.vue'
import type { FormItem } from '@/components/search-form/types'
import {
fetchReportList,
generateReport,
fetchReportContent,
exportReport,
ReportType,
type ReportRecord,
type FaultReportParams,
} from '@/api/ops/report'
// 页面标题
const pageTitle = '故障报表'
// 列表筛选表单模型
const formModel = ref<{
report_type: string
status: string
keyword: string
timeRange: string[]
}>({
report_type: ReportType.FAULT,
status: '',
keyword: '',
timeRange: [],
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'status',
label: '状态',
type: 'select',
span: 8,
placeholder: '请选择',
options: [
{ value: '', label: '全部' },
{ value: 'pending', label: '等待中' },
{ value: 'running', label: '生成中' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
],
},
{
field: 'keyword',
label: '标题',
type: 'input',
span: 8,
placeholder: '请输入标题关键字',
},
])
// 状态
const loading = ref(false)
const generating = ref(false)
const contentLoading = ref(false)
const exporting = ref(false)
const tableData = ref<ReportRecord[]>([])
const selectedRecord = ref<ReportRecord | null>(null)
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: true,
showJumper: true,
showPageSize: true,
})
// 表格列配置
const tableColumns = computed(() => [
{
title: 'ID',
dataIndex: 'id',
width: 80,
},
{
title: '标题',
dataIndex: 'title',
width: 200,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
slotName: 'status',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 180,
},
{
title: '操作',
dataIndex: 'operations',
width: 180,
slotName: 'operations',
fixed: 'right' as const,
},
])
// 生成报表弹窗
const generateModalVisible = ref(false)
const generateForm = ref<{
fault_data_source: string
group_by: string
timeRange: string[]
service_identities: string[]
status: string
keyword: string
title: string
detail_limit: number | undefined
alert_severities: string[]
include_raw_messages: boolean
}>({
fault_data_source: 'collector_log',
group_by: 'none',
timeRange: [],
service_identities: [],
status: '',
keyword: '',
title: '',
detail_limit: undefined,
alert_severities: [],
include_raw_messages: false,
})
// 查看内容弹窗
const contentModalVisible = ref(false)
const contentModalTitle = ref('')
const reportContent = ref<Record<string, any> | null>(null)
// 内容表格列配置
const contentTableColumns = computed(() => {
if (!reportContent.value) return []
const data = reportContent.value.items || []
if (data.length === 0) return []
const firstRecord = data[0]
return Object.keys(firstRecord).map((key) => ({
title: formatLabel(key),
dataIndex: key,
width: 150,
}))
})
// 分组表格列配置
const groupTableColumns = computed(() => {
if (!reportContent.value) return []
const data = reportContent.value.groups || []
if (data.length === 0) return []
const firstRecord = data[0]
return Object.keys(firstRecord).map((key) => ({
title: formatLabel(key),
dataIndex: key,
width: 150,
}))
})
// 获取报表列表
const fetchList = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
size: pagination.pageSize,
report_type: ReportType.FAULT,
}
if (formModel.value.status) {
params.status = formModel.value.status
}
if (formModel.value.keyword) {
params.keyword = formModel.value.keyword
}
if (formModel.value.timeRange && formModel.value.timeRange.length === 2) {
params.created_from = formModel.value.timeRange[0]
params.created_to = formModel.value.timeRange[1]
}
const res = await fetchReportList(params)
if (res.code === 0 && res.details) {
tableData.value = res.details.data || []
pagination.total = res.details.total || 0
} else {
Message.error(res.message || '获取报表列表失败')
}
} catch (error: any) {
console.error('获取报表列表失败:', error)
Message.error(error.message || '获取报表列表失败')
} finally {
loading.value = false
}
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 查询
const handleSearch = () => {
pagination.current = 1
fetchList()
}
// 重置
const handleReset = () => {
formModel.value = {
report_type: ReportType.FAULT,
status: '',
keyword: '',
timeRange: [],
}
pagination.current = 1
fetchList()
}
// 刷新
const handleRefresh = () => {
fetchList()
Message.success('数据已刷新')
}
// 打开生成报表弹窗
const handleOpenGenerateModal = () => {
generateForm.value = {
fault_data_source: 'collector_log',
group_by: 'none',
timeRange: [],
service_identities: [],
status: '',
keyword: '',
title: '',
detail_limit: undefined,
alert_severities: [],
include_raw_messages: false,
}
generateModalVisible.value = true
}
// 关闭生成报表弹窗
const handleCloseGenerateModal = () => {
generateModalVisible.value = false
}
// 生成报表
const handleGenerate = async () => {
// 验证必填项
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
generating.value = true
try {
const params: FaultReportParams = {
start_time: generateForm.value.timeRange[0],
end_time: generateForm.value.timeRange[1],
}
if (generateForm.value.fault_data_source) {
params.fault_data_source = generateForm.value.fault_data_source as any
}
if (generateForm.value.group_by) {
params.group_by = generateForm.value.group_by as any
}
if (generateForm.value.service_identities && generateForm.value.service_identities.length > 0) {
params.service_identities = generateForm.value.service_identities
}
if (generateForm.value.status) {
params.status = generateForm.value.status as any
}
if (generateForm.value.keyword) {
params.keyword = generateForm.value.keyword
}
if (generateForm.value.detail_limit) {
params.detail_limit = generateForm.value.detail_limit
}
if (generateForm.value.alert_severities && generateForm.value.alert_severities.length > 0) {
params.alert_severities = generateForm.value.alert_severities
}
if (generateForm.value.include_raw_messages) {
params.include_raw_messages = generateForm.value.include_raw_messages
}
const res = await generateReport({
report_type: ReportType.FAULT,
title: generateForm.value.title || undefined,
params,
})
if (res.code === 0 && res.details) {
if (res.details.status === 'success') {
Message.success('报表生成成功')
generateModalVisible.value = false
fetchList()
} else if (res.details.status === 'failed') {
Message.error(res.details.error_message || '报表生成失败')
} else {
Message.info('报表正在生成中,请稍后刷新查看')
generateModalVisible.value = false
fetchList()
}
} else {
Message.error(res.message || '报表生成失败')
}
} catch (error: any) {
console.error('生成报表失败:', error)
Message.error(error.message || '生成报表失败')
} finally {
generating.value = false
}
}
// 查看内容
const handleViewContent = async (record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) return
contentLoading.value = true
contentModalVisible.value = true
contentModalTitle.value = targetRecord.title
reportContent.value = null
try {
const res = await fetchReportContent(targetRecord.id)
if (res.code === 0 && res.details) {
reportContent.value = res.details
} else {
Message.error(res.message || '获取报表内容失败')
}
} catch (error: any) {
console.error('获取报表内容失败:', error)
Message.error(error.message || '获取报表内容失败')
} finally {
contentLoading.value = false
}
}
// 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) {
Message.warning('请选择要导出的报表')
return
}
if (targetRecord.status !== 'success') {
Message.warning('只能导出生成成功的报表')
return
}
exporting.value = true
try {
const blob = await exportReport(targetRecord.id, format)
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `report_${targetRecord.id}.${format}`
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
}
}
// 格式化标签
const formatLabel = (key: string) => {
const labelMap: Record<string, string> = {
service_identity: '服务标识',
total_count: '总次数',
success_count: '成功次数',
failed_count: '失败次数',
timeout_count: '超时次数',
success_rate: '成功率(%)',
avg_duration: '平均耗时(ms)',
max_duration: '最大耗时(ms)',
min_duration: '最小耗时(ms)',
timestamp: '时间',
message: '消息',
error_message: '错误信息',
status: '状态',
day: '日期',
rank: '排名',
}
return labelMap[key] || key
}
// 格式化数值
const formatValue = (key: string, value: any) => {
if (value === null || value === undefined) return '-'
// 百分比
if (key.includes('rate') && typeof value === 'number') {
return value.toFixed(2) + '%'
}
// 数字类型保留两位小数
if (typeof value === 'number') {
return value.toFixed(2)
}
return value
}
// 初始化
fetchList()
</script>
<script lang="ts">
export default {
name: 'FaultReport',
}
</script>
<style scoped lang="less">
.container {
padding: 20px;
.summary-card {
margin-bottom: 20px;
:deep(.arco-card-body) {
padding: 16px;
}
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
}
</style>

View File

@@ -1,373 +0,0 @@
<template>
<div>
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<!-- 时间范围选择器插槽 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="时间范围" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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-col>
</template>
<!-- 导出按钮插槽 -->
<template #actions>
<a-button :loading="exporting" @click="handleExport">
导出 CSV
</a-button>
</template>
<!-- 聚合值 -->
<template #value="{ record }">
<span>{{ formatValue(record.value) }}</span>
</template>
</search-table>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { fetchMetricsSummary, exportMetricsSummary } from '@/api/ops/report'
// 表单模型
const formModel = ref<{
data_source: string
metric_names: string
identities: string
aggregation: string
timeRange: string[]
}>({
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.value.data_source === 'dc-host') {
return '服务器标识,必填'
}
return '服务标识,必填'
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'data_source',
label: '数据源',
type: 'select',
placeholder: '请选择数据源',
options: [
{ label: '主机', value: 'dc-host' },
{ label: '网络设备', value: 'dc-network' },
{ label: '数据库', value: 'dc-database' },
{ label: '中间件', value: 'dc-middleware' },
],
colProps: { span: 8 },
},
{
field: 'metric_names',
label: '指标名称',
type: 'input',
placeholder: '多个指标名称,逗号分隔',
colProps: { span: 8 },
},
{
field: 'identities',
label: '标识',
type: 'input',
placeholder: identityPlaceholder.value,
colProps: { span: 8 },
},
{
field: 'aggregation',
label: '聚合方式',
type: 'select',
placeholder: '请选择聚合方式',
options: [
{ label: '平均值', value: 'avg' },
{ label: '最大值', value: 'max' },
{ label: '最小值', value: 'min' },
{ label: '求和', value: 'sum' },
{ label: '计数', value: 'count' },
],
colProps: { span: 8 },
},
])
// 表格列配置
const columns = computed(() => [
{
title: '标识',
dataIndex: 'group_key',
width: 200,
fixed: 'left' as const,
},
{
title: '指标名称',
dataIndex: 'metric_name',
width: 180,
},
{
title: '指标单位',
dataIndex: 'metric_unit',
width: 100,
},
{
title: '聚合值',
dataIndex: 'value',
width: 120,
slotName: 'value',
},
{
title: '样本数',
dataIndex: 'sample_count',
width: 100,
},
{
title: '聚合方式',
dataIndex: 'aggregation',
width: 100,
},
{
title: '数据源',
dataIndex: 'data_source',
width: 120,
},
])
// 格式化数值
const formatValue = (value: number) => {
if (value === null || value === undefined) return '-'
return Number(value).toFixed(2)
}
// 构建查询参数
const buildParams = () => {
const params: any = {
data_source: formModel.value.data_source,
metric_names: formModel.value.metric_names,
aggregation: formModel.value.aggregation,
start_time: formModel.value.timeRange[0],
end_time: formModel.value.timeRange[1],
}
// 根据数据源添加标识
if (formModel.value.data_source === 'dc-host') {
params.server_identities = formModel.value.identities
} else {
params.service_identities = formModel.value.identities
}
return params
}
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.value.data_source) {
Message.warning('请选择数据源')
return
}
if (!formModel.value.metric_names) {
Message.warning('请输入指标名称')
return
}
if (!formModel.value.identities) {
Message.warning('请输入标识')
return
}
if (!formModel.value.timeRange || formModel.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
pagination.current = 1
await fetchTableData()
}
// 获取表格数据
const fetchTableData = async () => {
loading.value = true
try {
const params = buildParams()
const res = await fetchMetricsSummary(params)
if (res.code === 0) {
tableData.value = res.data?.data || []
pagination.total = res.data?.count || 0
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 handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 重置
const handleReset = () => {
formModel.value = {
data_source: 'dc-host',
metric_names: '',
identities: '',
aggregation: 'avg',
timeRange: [],
}
pagination.current = 1
tableData.value = []
pagination.total = 0
}
// 刷新
const handleRefresh = () => {
fetchTableData()
Message.success('数据已刷新')
}
// 导出
const handleExport = async () => {
// 验证必填项
if (!formModel.value.data_source) {
Message.warning('请选择数据源')
return
}
if (!formModel.value.metric_names) {
Message.warning('请输入指标名称')
return
}
if (!formModel.value.identities) {
Message.warning('请输入标识')
return
}
if (!formModel.value.timeRange || formModel.value.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
}
}
// 初始化默认时间范围最近24小时
const initDefaultTimeRange = () => {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
// 格式化为 YYYY-MM-DD HH:mm:ss
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
formModel.value.timeRange = [
formatDateTime(start),
formatDateTime(end),
]
}
// 组件挂载时初始化时间范围并自动查询
onMounted(() => {
initDefaultTimeRange()
// 自动查询(使用默认参数)
formModel.value.metric_names = 'cpu_usage_percent'
formModel.value.identities = '*'
handleSearch()
})
</script>
<script lang="ts">
export default {
name: 'MetricsSummaryPanel',
}
</script>
<style scoped lang="less">
</style>

View File

@@ -1,341 +0,0 @@
<template>
<div>
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<!-- 时间范围选择器插槽 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="时间范围" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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-col>
</template>
<!-- 聚合值 -->
<template #value="{ record }">
<span>{{ formatValue(record.value) }}</span>
</template>
</search-table>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { fetchMetricsTopN } from '@/api/ops/report'
// 表单模型
const formModel = ref<{
data_source: string
metric_name: string
aggregation: string
order: string
limit: number
identities: string
timeRange: string[]
}>({
data_source: 'dc-host',
metric_name: '',
aggregation: 'avg',
order: 'desc',
limit: 10,
identities: '',
timeRange: [],
})
// 加载状态
const loading = ref(false)
// 表格数据
const tableData = ref<any[]>([])
// 分页配置TOPN 查询不使用分页,设置为 false
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: false,
showJumper: false,
showPageSize: false,
})
// 标识字段占位符
const identityPlaceholder = computed(() => {
if (formModel.value.data_source === 'dc-host') {
return '多个服务器标识,逗号分隔'
}
return '多个服务标识,逗号分隔'
})
// 表单项配置
const formItems = computed(() => [
{
field: 'data_source',
label: '数据源',
type: 'select' as const,
placeholder: '请选择数据源',
options: [
{ label: '主机', value: 'dc-host' },
{ label: '网络设备', value: 'dc-network' },
{ label: '数据库', value: 'dc-database' },
{ label: '中间件', value: 'dc-middleware' },
],
colProps: { span: 8 },
},
{
field: 'metric_name',
label: '指标名称',
type: 'input' as const,
placeholder: '请输入指标名称',
colProps: { span: 8 },
},
{
field: 'aggregation',
label: '聚合方式',
type: 'select' as const,
placeholder: '请选择聚合方式',
options: [
{ label: '平均值', value: 'avg' },
{ label: '最大值', value: 'max' },
{ label: '最小值', value: 'min' },
{ label: '求和', value: 'sum' },
{ label: '计数', value: 'count' },
],
colProps: { span: 8 },
},
{
field: 'order',
label: '排序',
type: 'select' as const,
options: [
{ label: '降序', value: 'desc' },
{ label: '升序', value: 'asc' },
],
colProps: { span: 8 },
},
{
field: 'limit',
label: '数量',
type: 'input' as const,
props: {
min: 1,
max: 1000,
},
colProps: { span: 8 },
},
{
field: 'identities',
label: '标识',
type: 'input' as const,
placeholder: identityPlaceholder.value,
colProps: { span: 8 },
},
])
// 表格列配置
const columns = computed(() => [
{
title: '排名',
dataIndex: 'rank',
width: 80,
fixed: 'left' as const,
},
{
title: '标识',
dataIndex: 'group_key',
width: 200,
},
{
title: '指标名称',
dataIndex: 'metric_name',
width: 180,
},
{
title: '指标单位',
dataIndex: 'metric_unit',
width: 100,
},
{
title: '聚合值',
dataIndex: 'value',
width: 120,
slotName: 'value',
},
{
title: '样本数',
dataIndex: 'sample_count',
width: 100,
},
{
title: '聚合方式',
dataIndex: 'aggregation',
width: 100,
},
{
title: '数据源',
dataIndex: 'data_source',
width: 120,
},
])
// 格式化数值
const formatValue = (value: number) => {
if (value === null || value === undefined) return '-'
return Number(value).toFixed(2)
}
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.value.data_source) {
Message.warning('请选择数据源')
return
}
if (!formModel.value.metric_name) {
Message.warning('请输入指标名称')
return
}
if (!formModel.value.timeRange || formModel.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
pagination.current = 1
await fetchTableData()
}
// 获取表格数据
const fetchTableData = async () => {
loading.value = true
try {
const params: any = {
data_source: formModel.value.data_source,
metric_name: formModel.value.metric_name,
aggregation: formModel.value.aggregation,
order: formModel.value.order,
limit: formModel.value.limit,
start_time: formModel.value.timeRange[0],
end_time: formModel.value.timeRange[1],
}
// 根据数据源添加标识过滤
if (formModel.value.identities) {
if (formModel.value.data_source === 'dc-host') {
params.server_identities = formModel.value.identities
} else {
params.service_identities = formModel.value.identities
}
}
const res = await fetchMetricsTopN(params)
if (res.code === 0) {
tableData.value = res.data?.items || []
pagination.total = tableData.value.length
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 handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 重置
const handleReset = () => {
formModel.value = {
data_source: 'dc-host',
metric_name: '',
aggregation: 'avg',
order: 'desc',
limit: 10,
identities: '',
timeRange: [],
}
pagination.current = 1
tableData.value = []
pagination.total = 0
}
// 刷新
const handleRefresh = () => {
fetchTableData()
Message.success('数据已刷新')
}
// 初始化默认时间范围最近24小时
const initDefaultTimeRange = () => {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
// 格式化为 YYYY-MM-DD HH:mm:ss
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
formModel.value.timeRange = [
formatDateTime(start),
formatDateTime(end),
]
}
// 组件挂载时自动加载
onMounted(() => {
initDefaultTimeRange()
// 自动查询(使用默认参数)
formModel.value.metric_name = 'cpu_usage_percent'
formModel.value.data_source = 'dc-host'
handleSearch()
})
</script>
<script lang="ts">
export default {
name: 'MetricsTopNPanel',
}
</script>
<style scoped lang="less">
</style>

View File

@@ -1,399 +0,0 @@
<template>
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<!-- 时间范围选择器插槽 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="时间范围" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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-col>
</template>
<!-- 状态 -->
<template #status="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ record.status || '-' }}
</a-tag>
</template>
<!-- 响应时间 -->
<template #response_time="{ record }">
{{ formatResponseTime(record.response_time) }}
</template>
<!-- 运行时间 -->
<template #uptime="{ record }">
{{ formatUptime(record.uptime) }}
</template>
<!-- 启用 -->
<template #enabled="{ record }">
<span>{{ record.enabled ? '是' : '否' }}</span>
</template>
<!-- 最后检查时间 -->
<template #last_check_time="{ record }">
{{ formatTime(record.last_check_time) }}
</template>
<!-- 指标数据 -->
<template #metrics="{ 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>
</search-table>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { fetchNetworkDeviceStatus } from '@/api/ops/report'
// 表单模型
const formModel = ref({
service_identities: '',
metric_names: '',
aggregation: 'avg',
timeRange: [],
})
// 加载状态
const loading = ref(false)
// 表格数据
const tableData = ref<any[]>([])
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'service_identities',
label: '服务标识',
type: 'input',
placeholder: '多个标识,逗号分隔',
colProps: { span: 8 },
},
{
field: 'metric_names',
label: '指标名称',
type: 'input',
placeholder: '多个指标名称,逗号分隔(可选)',
colProps: { span: 8 },
},
{
field: 'aggregation',
label: '聚合方式',
type: 'select',
placeholder: '请选择聚合方式',
options: [
{ label: '平均值', value: 'avg' },
{ label: '最大值', value: 'max' },
{ label: '最小值', value: 'min' },
{ label: '求和', value: 'sum' },
{ label: '计数', value: 'count' },
],
colProps: { span: 8 },
},
])
// 表格列配置
const columns = computed(() => [
{
title: '服务标识',
dataIndex: 'service_identity',
width: 200,
fixed: 'left' as const,
},
{
title: '服务器标识',
dataIndex: 'server_identity',
width: 180,
},
{
title: '名称',
dataIndex: 'name',
width: 150,
},
{
title: '类型',
dataIndex: 'type',
width: 120,
},
{
title: '主机',
dataIndex: 'host',
width: 150,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
slotName: 'status',
},
{
title: '响应时间',
dataIndex: 'response_time',
width: 120,
slotName: 'response_time',
},
{
title: '运行时间',
dataIndex: 'uptime',
width: 120,
slotName: 'uptime',
},
{
title: '启用',
dataIndex: 'enabled',
width: 80,
slotName: 'enabled',
},
{
title: '最后检查时间',
dataIndex: 'last_check_time',
width: 180,
slotName: 'last_check_time',
},
{
title: '指标数据',
dataIndex: 'metrics',
width: 300,
slotName: 'metrics',
},
])
// 获取状态颜色
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.value.service_identities) {
Message.warning('请输入服务标识')
return
}
// 如果填写了指标名称,必须同时填写时间范围
if (formModel.value.metric_names && (!formModel.value.timeRange || formModel.value.timeRange.length !== 2)) {
Message.warning('填写指标名称时必须选择时间范围')
return
}
// 如果填写了时间范围,必须同时填写指标名称
if (formModel.value.timeRange && formModel.value.timeRange.length === 2 && !formModel.value.metric_names) {
Message.warning('选择时间范围时必须填写指标名称')
return
}
pagination.current = 1
await fetchTableData()
}
// 获取表格数据
const fetchTableData = async () => {
loading.value = true
try {
const params: any = {
service_identities: formModel.value.service_identities,
}
if (formModel.value.metric_names) {
params.metric_names = formModel.value.metric_names
params.aggregation = formModel.value.aggregation
params.start_time = formModel.value.timeRange[0]
params.end_time = formModel.value.timeRange[1]
}
const res = await fetchNetworkDeviceStatus(params)
if (res.code === 0) {
tableData.value = res.data?.data || []
pagination.total = res.data?.total || 0
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 handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 重置
const handleReset = () => {
formModel.value = {
service_identities: '',
metric_names: '',
aggregation: 'avg',
timeRange: [],
}
pagination.current = 1
tableData.value = []
pagination.total = 0
}
// 刷新
const handleRefresh = () => {
fetchTableData()
Message.success('数据已刷新')
}
// 初始化默认时间范围最近24小时
const initDefaultTimeRange = () => {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
// 格式化为 YYYY-MM-DD HH:mm:ss
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
formModel.value.timeRange = [
formatDateTime(start),
formatDateTime(end),
]
}
// 组件挂载时初始化并自动查询
onMounted(() => {
initDefaultTimeRange()
// 自动查询(使用默认参数)
formModel.value.service_identities = '*'
formModel.value.metric_names = 'cpu_usage_percent'
handleSearch()
})
</script>
<script lang="ts">
export default {
name: 'NetworkDeviceStatusPanel',
}
</script>
<style scoped lang="less">
.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

@@ -1,349 +0,0 @@
<template>
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<!-- 时间范围选择器插槽 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="时间范围" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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-col>
</template>
<!-- 状态 -->
<template #status="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ record.status || '-' }}
</a-tag>
</template>
<!-- 启用 -->
<template #enable="{ record }">
<span>{{ record.enable ? '是' : '否' }}</span>
</template>
<!-- 最后检查时间 -->
<template #last_check_time="{ record }">
{{ formatTime(record.last_check_time) }}
</template>
<!-- 指标数据 -->
<template #metrics="{ 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>
</search-table>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { fetchServerStatus } from '@/api/ops/report'
// 表单模型
const formModel = ref({
server_identities: '',
metric_names: '',
aggregation: 'avg',
timeRange: [],
})
// 加载状态
const loading = ref(false)
// 表格数据
const tableData = ref<any[]>([])
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'server_identities',
label: '服务器标识',
type: 'input',
placeholder: '多个标识,逗号分隔',
colProps: { span: 8 },
},
{
field: 'metric_names',
label: '指标名称',
type: 'input',
placeholder: '多个指标名称,逗号分隔(可选)',
colProps: { span: 8 },
},
{
field: 'aggregation',
label: '聚合方式',
type: 'select',
placeholder: '请选择聚合方式',
options: [
{ label: '平均值', value: 'avg' },
{ label: '最大值', value: 'max' },
{ label: '最小值', value: 'min' },
{ label: '求和', value: 'sum' },
{ label: '计数', value: 'count' },
],
colProps: { span: 8 },
},
])
// 表格列配置
const columns = computed(() => [
{
title: '服务器标识',
dataIndex: 'server_identity',
width: 180,
fixed: 'left' as const,
},
{
title: '名称',
dataIndex: 'name',
width: 150,
},
{
title: '主机',
dataIndex: 'host',
width: 150,
},
{
title: 'IP 地址',
dataIndex: 'ip_address',
width: 150,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
slotName: 'status',
},
{
title: '启用',
dataIndex: 'enable',
width: 80,
slotName: 'enable',
},
{
title: '最后检查时间',
dataIndex: 'last_check_time',
width: 180,
slotName: 'last_check_time',
},
{
title: '指标数据',
dataIndex: 'metrics',
width: 300,
slotName: 'metrics',
},
])
// 获取状态颜色
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.value.server_identities) {
Message.warning('请输入服务器标识')
return
}
// 如果填写了指标名称,必须同时填写时间范围
if (formModel.value.metric_names && (!formModel.value.timeRange || formModel.value.timeRange.length !== 2)) {
Message.warning('填写指标名称时必须选择时间范围')
return
}
// 如果填写了时间范围,必须同时填写指标名称
if (formModel.value.timeRange && formModel.value.timeRange.length === 2 && !formModel.value.metric_names) {
Message.warning('选择时间范围时必须填写指标名称')
return
}
pagination.current = 1
await fetchTableData()
}
// 获取表格数据
const fetchTableData = async () => {
loading.value = true
try {
const params: any = {
server_identities: formModel.value.server_identities,
}
if (formModel.value.metric_names) {
params.metric_names = formModel.value.metric_names
params.aggregation = formModel.value.aggregation
params.start_time = formModel.value.timeRange[0]
params.end_time = formModel.value.timeRange[1]
}
const res = await fetchServerStatus(params)
if (res.code === 0) {
tableData.value = res.data?.data || []
pagination.total = res.data?.total || 0
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 handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 重置
const handleReset = () => {
formModel.value = {
server_identities: '',
metric_names: '',
aggregation: 'avg',
timeRange: [],
}
pagination.current = 1
tableData.value = []
pagination.total = 0
}
// 刷新
const handleRefresh = () => {
fetchTableData()
Message.success('数据已刷新')
}
// 初始化默认时间范围最近24小时
const initDefaultTimeRange = () => {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
// 格式化为 YYYY-MM-DD HH:mm:ss
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
formModel.value.timeRange = [
formatDateTime(start),
formatDateTime(end),
]
}
// 组件挂载时初始化并自动查询
onMounted(() => {
initDefaultTimeRange()
// 自动查询(使用默认参数)
formModel.value.server_identities = '*'
formModel.value.metric_names = 'cpu_usage_percent'
handleSearch()
})
</script>
<script lang="ts">
export default {
name: 'ServerStatusPanel',
}
</script>
<style scoped lang="less">
.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

@@ -1,416 +0,0 @@
<template>
<div>
<search-table
:form-model="formModel"
:form-items="formItems"
:data="summaryData.by_node"
:columns="tableColumns"
:loading="loading"
:pagination="pagination"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<!-- 时间范围选择器插槽 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="时间范围" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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-col>
</template>
<!-- 汇总信息卡片插槽 -->
<template #summary>
<a-card v-if="summaryData.totals" 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>
</template>
<!-- 导出按钮插槽 -->
<template #actions>
<a-button :loading="exporting" @click="handleExport">
导出 CSV
</a-button>
</template>
<!-- 动态列的单元格渲染 -->
<template v-for="column in dynamicColumns" :key="String(column.key)" #[column.slotName]="{ record }">
<span>{{ formatValue(column.key, record[column.key]) }}</span>
</template>
</search-table>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import SearchTable from '@/components/search-table/index.vue'
import { fetchTrafficSummary, exportTrafficReport } from '@/api/ops/report'
// 表单模型
const formModel = ref<{
topology_id: number
link_id: number
node_ids: string
timeRange: string[]
}>({
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 pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: false,
showJumper: false,
showPageSize: false,
})
// 表单项配置
const formItems = computed(() => [
{
field: 'topology_id',
label: '拓扑 ID',
type: 'input' as const,
placeholder: '拓扑 ID',
colProps: { span: 8 },
props: {
type: 'number',
min: 0,
},
},
{
field: 'link_id',
label: '链路 ID',
type: 'input' as const,
placeholder: '链路 ID',
colProps: { span: 8 },
props: {
type: 'number',
min: 0,
},
},
{
field: 'node_ids',
label: '节点 ID',
type: 'input' as const,
placeholder: '多个节点 ID逗号分隔',
colProps: { span: 8 },
},
])
// 显示的汇总字段(过滤掉元数据)
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,
slotName: String(key),
})
}
}
return columns
})
// 表格列配置
const tableColumns = computed(() => {
const columns: any[] = [
{
title: '节点 ID',
dataIndex: 'node_id',
width: 200,
fixed: 'left' as const,
},
]
// 添加动态列
dynamicColumns.value.forEach((column) => {
columns.push({
title: formatLabel(column.key),
dataIndex: String(column.key),
width: 150,
slotName: column.slotName,
})
})
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.value.timeRange[0],
end_time: formModel.value.timeRange[1],
kind: 'summary',
}
if (formModel.value.topology_id > 0) {
params.topology_id = formModel.value.topology_id
}
if (formModel.value.link_id > 0) {
params.link_id = formModel.value.link_id
}
if (formModel.value.node_ids) {
params.node_ids = formModel.value.node_ids
}
return params
}
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.value.timeRange || formModel.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
pagination.current = 1
await fetchTableData()
}
// 获取表格数据
const fetchTableData = async () => {
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 || []
pagination.total = summaryData.by_node.length
if (!summaryData.totals && summaryData.by_node.length === 0) {
Message.info('未查询到数据')
}
} else {
Message.error(res.message || '查询失败')
summaryData.totals = null
summaryData.by_node = []
pagination.total = 0
}
} catch (error: any) {
console.error('查询失败:', error)
Message.error(error.message || '查询失败')
summaryData.totals = null
summaryData.by_node = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 重置
const handleReset = () => {
formModel.value = {
topology_id: 0,
link_id: 0,
node_ids: '',
timeRange: [],
}
pagination.current = 1
summaryData.totals = null
summaryData.by_node = []
pagination.total = 0
}
// 刷新
const handleRefresh = () => {
fetchTableData()
Message.success('数据已刷新')
}
// 导出
const handleExport = async () => {
// 验证必填项
if (!formModel.value.timeRange || formModel.value.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
}
}
// 初始化默认时间范围最近24小时
const initDefaultTimeRange = () => {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
// 格式化为 YYYY-MM-DD HH:mm:ss
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
formModel.value.timeRange = [
formatDateTime(start),
formatDateTime(end),
]
}
// 组件挂载时初始化时间范围并自动查询
onMounted(() => {
initDefaultTimeRange()
// 自动查询
handleSearch()
})
</script>
<script lang="ts">
export default {
name: 'TrafficSummaryPanel',
}
</script>
<style scoped lang="less">
.summary-card {
margin-bottom: 20px;
:deep(.arco-card-body) {
padding: 16px;
}
}
</style>

View File

@@ -1,581 +0,0 @@
<template>
<div>
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="tableColumns"
:loading="loading"
:pagination="pagination"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
@page-change="handlePageChange"
>
<!-- 时间粒度选择器插槽 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="时间粒度" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<a-select
v-model="formModel.granularity"
placeholder="请选择时间粒度"
style="width: 100%"
>
<a-option value="minute">分钟</a-option>
<a-option value="hour">小时</a-option>
<a-option value="day"></a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="时间范围" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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: 100%"
/>
</a-form-item>
</a-col>
</template>
<!-- 趋势图表插槽 -->
<template #chart>
<a-card v-if="trendData.length > 0" title="流量趋势图" :bordered="false">
<div ref="chartRef" class="chart-container" />
</a-card>
</template>
<!-- 导出按钮插槽 -->
<template #actions>
<a-button :loading="exporting" @click="handleExport">
导出 CSV
</a-button>
</template>
<!-- 动态列的单元格渲染 -->
<template v-for="column in dynamicColumns" :key="String(column.key)" #[column.slotName]="{ record }">
<span>{{ formatValue(column.key, record[column.key]) }}</span>
</template>
</search-table>
</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 SearchTable from '@/components/search-table/index.vue'
import { fetchTrafficTrend, exportTrafficReport } from '@/api/ops/report'
// 表单模型
const formModel = ref<{
topology_id: number
link_id: number
node_ids: string
granularity: string
timeRange: string[]
}>({
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
// 表单项配置
const formItems = computed(() => [
{
field: 'topology_id',
label: '拓扑 ID',
type: 'input' as const,
placeholder: '拓扑 ID',
colProps: { span: 8 },
props: {
type: 'number',
min: 0,
},
},
{
field: 'link_id',
label: '链路 ID',
type: 'input' as const,
placeholder: '链路 ID',
colProps: { span: 8 },
props: {
type: 'number',
min: 0,
},
},
{
field: 'node_ids',
label: '节点 ID',
type: 'input' as const,
placeholder: '多个节点 ID逗号分隔',
colProps: { span: 8 },
},
])
// 动态列(从第一条记录中提取所有字段,排除 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,
slotName: String(key),
})
}
}
return columns
})
// 表格列配置
const tableColumns = computed(() => {
const columns: any[] = [
{
title: '时间',
dataIndex: 'time',
width: 180,
fixed: 'left' as const,
},
{
title: '节点 ID',
dataIndex: 'node_id',
width: 180,
},
]
// 添加动态列
dynamicColumns.value.forEach((column) => {
columns.push({
title: formatLabel(column.key),
dataIndex: String(column.key),
width: 150,
slotName: column.slotName,
})
})
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.value.timeRange[0],
end_time: formModel.value.timeRange[1],
kind: 'trend',
granularity: formModel.value.granularity,
}
if (formModel.value.topology_id > 0) {
params.topology_id = formModel.value.topology_id
}
if (formModel.value.link_id > 0) {
params.link_id = formModel.value.link_id
}
if (formModel.value.node_ids) {
params.node_ids = formModel.value.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.value.timeRange || formModel.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
pagination.current = 1
await fetchTableData()
}
// 获取表格数据
const fetchTableData = async () => {
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
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 handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 重置
const handleReset = () => {
formModel.value = {
topology_id: 0,
link_id: 0,
node_ids: '',
granularity: 'hour',
timeRange: [],
}
pagination.current = 1
trendData.value = []
tableData.value = []
pagination.total = 0
if (chartInstance) {
chartInstance.clear()
}
}
// 刷新
const handleRefresh = () => {
fetchTableData()
Message.success('数据已刷新')
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
updateTableData()
}
// 导出
const handleExport = async () => {
// 验证必填项
if (!formModel.value.timeRange || formModel.value.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
}
}
// 初始化默认时间范围最近24小时
const initDefaultTimeRange = () => {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
// 格式化为 YYYY-MM-DD HH:mm:ss
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
formModel.value.timeRange = [
formatDateTime(start),
formatDateTime(end),
]
}
// 生命周期钩子
onMounted(() => {
// 初始化默认时间范围
initDefaultTimeRange()
// 自动查询
handleSearch()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
})
</script>
<script lang="ts">
export default {
name: 'TrafficTrendPanel',
}
</script>
<style scoped lang="less">
.chart-container {
width: 100%;
height: 400px;
}
</style>

View File

@@ -1,85 +1,699 @@
<template>
<div class="container">
<a-card :bordered="false">
<template #title>
<div class="page-title">报表中心</div>
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="tableColumns"
:loading="loading"
:pagination="pagination"
:title="pageTitle"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<!-- 自定义表单项创建时间范围 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="创建时间" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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: 100%"
/>
</a-form-item>
</a-col>
</template>
<a-tabs v-model:active-key="activeTab" @change="handleTabChange">
<!-- 监测指标 TOPN -->
<a-tab-pane key="metrics-topn" title="监测指标 TOPN">
<MetricsTopNPanel />
</a-tab-pane>
<!-- 监测指标汇总 -->
<a-tab-pane key="metrics-summary" title="监测指标汇总">
<MetricsSummaryPanel />
</a-tab-pane>
<!-- 流量报表汇总 -->
<a-tab-pane key="traffic-summary" title="流量报表汇总">
<TrafficSummaryPanel />
</a-tab-pane>
<!-- 流量报表趋势 -->
<a-tab-pane key="traffic-trend" title="流量报表趋势">
<TrafficTrendPanel />
</a-tab-pane>
<!-- 服务器状态 -->
<a-tab-pane key="server-status" title="服务器状态">
<ServerStatusPanel />
</a-tab-pane>
<!-- 网络设备状态 -->
<a-tab-pane key="network-status" title="网络设备状态">
<NetworkStatusPanel />
</a-tab-pane>
</a-tabs>
</a-card>
<!-- 工具栏左侧生成报表按钮 -->
<template #toolbar-left>
<a-space>
<a-button type="primary" @click="handleOpenGenerateModal">
<template #icon><icon-file-add /></template>
生成报表
</a-button>
</a-space>
</template>
<!-- 工具栏右侧查看和导出按钮 -->
<template #toolbar-right>
<a-space>
<a-button v-if="selectedRecord" type="outline" @click="handleViewContent()">
<template #icon><icon-eye /></template>
查看内容
</a-button>
<a-dropdown v-if="selectedRecord">
<a-button type="outline">
<template #icon><icon-download /></template>
导出
</a-button>
<template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
<!-- 操作列 -->
<template #operations="{ record }">
<a-space>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleViewContent(record)"
>
查看
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('csv', record)"
>
CSV
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('xlsx', record)"
>
Excel
</a-button>
</a-space>
</template>
</search-table>
<!-- 生成报表弹窗 -->
<a-modal
v-model:visible="generateModalVisible"
title="生成历史报表"
:width="600"
:ok-loading="generating"
@ok="handleGenerate"
@cancel="handleCloseGenerateModal"
>
<a-form :model="generateForm" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="数据源"
field="data_source"
:rules="[{ required: true, message: '请选择数据源' }]"
>
<a-select v-model="generateForm.data_source" placeholder="请选择" style="width: 100%">
<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-col>
<a-col :span="12">
<a-form-item label="报表标题" field="title">
<a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item
label="指标名称"
field="metric_names"
:rules="[{ required: true, message: '请输入指标名称' }]"
>
<a-input-tag
v-model="generateForm.metric_names"
placeholder="输入后按回车添加1-20个"
style="width: 100%"
/>
</a-form-item>
<a-form-item
label="目标标识"
field="target_identities"
:rules="[{ required: true, message: '请输入目标标识' }]"
>
<a-input-tag
v-model="generateForm.target_identities"
placeholder="输入后按回车添加"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
<a-range-picker
v-model="generateForm.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="时间间隔"
field="interval"
:rules="[{ required: true, message: '请输入时间间隔' }]"
>
<a-input v-model="generateForm.interval" placeholder="如: 1 hour, 5 minutes" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="桶聚合"
field="bucket_aggregation"
:rules="[{ required: true, message: '请选择桶聚合' }]"
>
<a-select v-model="generateForm.bucket_aggregation" placeholder="请选择" style="width: 100%">
<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-select>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
<!-- 查看内容弹窗 -->
<a-modal
v-model:visible="contentModalVisible"
:title="contentModalTitle"
:width="900"
:footer="false"
>
<div v-if="contentLoading" class="loading-container">
<a-spin />
</div>
<div v-else-if="reportContent">
<!-- 图表展示 -->
<div ref="chartRef" class="chart-container"></div>
<!-- 数据表格 -->
<a-table
:data="reportContent.series || []"
:columns="seriesTableColumns"
:pagination="{ pageSize: 10 }"
stripe
/>
</div>
<a-empty v-else description="暂无数据" />
</a-modal>
</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/NetworkDeviceStatusPanel.vue'
import { ref, reactive, computed, nextTick } from 'vue'
import { Message } from '@arco-design/web-vue'
import SearchTable from '@/components/search-table/index.vue'
import type { FormItem } from '@/components/search-form/types'
import {
fetchReportList,
generateReport,
fetchReportContent,
exportReport,
ReportType,
type ReportRecord,
type HistoryReportParams,
} from '@/api/ops/report'
import * as echarts from 'echarts'
// 当前激活的标签
const activeTab = ref('metrics-topn')
// 页面标题
const pageTitle = '历史报表'
// 标签页切换
const handleTabChange = (key: string) => {
console.log('切换到标签页:', key)
// 列表筛选表单模型
const formModel = ref<{
report_type: string
status: string
keyword: string
timeRange: string[]
}>({
report_type: ReportType.HISTORY,
status: '',
keyword: '',
timeRange: [],
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'status',
label: '状态',
type: 'select',
span: 8,
placeholder: '请选择',
options: [
{ value: '', label: '全部' },
{ value: 'pending', label: '等待中' },
{ value: 'running', label: '生成中' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
],
},
{
field: 'keyword',
label: '标题',
type: 'input',
span: 8,
placeholder: '请输入标题关键字',
},
])
// 状态
const loading = ref(false)
const generating = ref(false)
const contentLoading = ref(false)
const exporting = ref(false)
const tableData = ref<ReportRecord[]>([])
const selectedRecord = ref<ReportRecord | null>(null)
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: true,
showJumper: true,
showPageSize: true,
})
// 表格列配置
const tableColumns = computed(() => [
{
title: 'ID',
dataIndex: 'id',
width: 80,
},
{
title: '标题',
dataIndex: 'title',
width: 200,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
slotName: 'status',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 180,
},
{
title: '操作',
dataIndex: 'operations',
width: 180,
slotName: 'operations',
fixed: 'right' as const,
},
])
// 生成报表弹窗
const generateModalVisible = ref(false)
const generateForm = ref<{
data_source: string
metric_names: string[]
target_identities: string[]
timeRange: string[]
interval: string
bucket_aggregation: string
title: string
}>({
data_source: '',
metric_names: [],
target_identities: [],
timeRange: [],
interval: '',
bucket_aggregation: 'avg',
title: '',
})
// 查看内容弹窗
const contentModalVisible = ref(false)
const contentModalTitle = ref('')
const reportContent = ref<Record<string, any> | null>(null)
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
// 时间序列表格列配置
const seriesTableColumns = computed(() => [
{
title: '指标名称',
dataIndex: 'metric_name',
width: 150,
},
{
title: '时间',
dataIndex: 'timestamp',
width: 180,
},
{
title: '值',
dataIndex: 'value',
width: 150,
},
])
// 获取报表列表
const fetchList = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
size: pagination.pageSize,
report_type: ReportType.HISTORY,
}
if (formModel.value.status) {
params.status = formModel.value.status
}
if (formModel.value.keyword) {
params.keyword = formModel.value.keyword
}
if (formModel.value.timeRange && formModel.value.timeRange.length === 2) {
params.created_from = formModel.value.timeRange[0]
params.created_to = formModel.value.timeRange[1]
}
const res = await fetchReportList(params)
if (res.code === 0 && res.details) {
tableData.value = res.details.data || []
pagination.total = res.details.total || 0
} else {
Message.error(res.message || '获取报表列表失败')
}
} catch (error: any) {
console.error('获取报表列表失败:', error)
Message.error(error.message || '获取报表列表失败')
} finally {
loading.value = false
}
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 查询
const handleSearch = () => {
pagination.current = 1
fetchList()
}
// 重置
const handleReset = () => {
formModel.value = {
report_type: ReportType.HISTORY,
status: '',
keyword: '',
timeRange: [],
}
pagination.current = 1
fetchList()
}
// 刷新
const handleRefresh = () => {
fetchList()
Message.success('数据已刷新')
}
// 打开生成报表弹窗
const handleOpenGenerateModal = () => {
generateForm.value = {
data_source: '',
metric_names: [],
target_identities: [],
timeRange: [],
interval: '',
bucket_aggregation: 'avg',
title: '',
}
generateModalVisible.value = true
}
// 关闭生成报表弹窗
const handleCloseGenerateModal = () => {
generateModalVisible.value = false
}
// 生成报表
const handleGenerate = async () => {
// 验证必填项
if (!generateForm.value.data_source) {
Message.warning('请选择数据源')
return
}
if (generateForm.value.metric_names.length === 0) {
Message.warning('请输入指标名称')
return
}
if (generateForm.value.metric_names.length > 20) {
Message.warning('指标名称最多20个')
return
}
if (generateForm.value.target_identities.length === 0) {
Message.warning('请输入目标标识')
return
}
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
if (!generateForm.value.interval) {
Message.warning('请输入时间间隔')
return
}
if (!generateForm.value.bucket_aggregation) {
Message.warning('请选择桶聚合')
return
}
generating.value = true
try {
const params: HistoryReportParams = {
data_source: generateForm.value.data_source as any,
metric_names: generateForm.value.metric_names,
target_identities: generateForm.value.target_identities,
start_time: generateForm.value.timeRange[0],
end_time: generateForm.value.timeRange[1],
interval: generateForm.value.interval,
bucket_aggregation: generateForm.value.bucket_aggregation as any,
}
const res = await generateReport({
report_type: ReportType.HISTORY,
title: generateForm.value.title || undefined,
params,
})
if (res.code === 0 && res.details) {
if (res.details.status === 'success') {
Message.success('报表生成成功')
generateModalVisible.value = false
fetchList()
} else if (res.details.status === 'failed') {
Message.error(res.details.error_message || '报表生成失败')
} else {
Message.info('报表正在生成中,请稍后刷新查看')
generateModalVisible.value = false
fetchList()
}
} else {
Message.error(res.message || '报表生成失败')
}
} catch (error: any) {
console.error('生成报表失败:', error)
Message.error(error.message || '生成报表失败')
} finally {
generating.value = false
}
}
// 查看内容
const handleViewContent = async (record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) return
contentLoading.value = true
contentModalVisible.value = true
contentModalTitle.value = targetRecord.title
reportContent.value = null
try {
const res = await fetchReportContent(targetRecord.id)
if (res.code === 0 && res.details) {
reportContent.value = res.details
// 渲染图表
if (res.details.series) {
await nextTick()
renderChart(res.details.series)
}
} else {
Message.error(res.message || '获取报表内容失败')
}
} catch (error: any) {
console.error('获取报表内容失败:', error)
Message.error(error.message || '获取报表内容失败')
} finally {
contentLoading.value = false
}
}
// 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) {
Message.warning('请选择要导出的报表')
return
}
if (targetRecord.status !== 'success') {
Message.warning('只能导出生成成功的报表')
return
}
exporting.value = true
try {
const blob = await exportReport(targetRecord.id, format)
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `report_${targetRecord.id}.${format}`
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
}
}
// 渲染图表
const renderChart = (series: any[]) => {
if (!chartRef.value || series.length === 0) return
if (chartInstance) {
chartInstance.dispose()
}
chartInstance = echarts.init(chartRef.value)
// 按指标名称分组
const groupedData: Record<string, any[]> = {}
series.forEach((item: any) => {
const name = item.metric_name || 'value'
if (!groupedData[name]) {
groupedData[name] = []
}
groupedData[name].push(item)
})
// 获取所有时间点
const timestamps = [...new Set(series.map((item: any) => item.timestamp))].sort()
const seriesData = Object.keys(groupedData).map((name) => ({
name,
type: 'line' as const,
smooth: true,
data: timestamps.map((t) => {
const item = groupedData[name].find((d: any) => d.timestamp === t)
return item ? item.value : null
}),
}))
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
},
legend: {
data: Object.keys(groupedData),
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: timestamps,
},
yAxis: {
type: 'value',
},
series: seriesData as any,
}
chartInstance.setOption(option)
}
// 初始化
fetchList()
</script>
<script lang="ts">
export default {
name: 'ReportHistory',
name: 'HistoryReport',
}
</script>
<style scoped lang="less">
.container {
padding: 20px;
.page-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
.chart-container {
height: 400px;
width: 100%;
margin-bottom: 20px;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
}
:deep(.arco-tabs-content) {
padding-top: 16px;
}
:deep(.arco-tabs-nav) {
padding: 0 8px;
}
</style>
</style>

View File

@@ -0,0 +1,706 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="tableColumns"
:loading="loading"
:pagination="pagination"
:title="pageTitle"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<!-- 自定义表单项创建时间范围 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="创建时间" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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: 100%"
/>
</a-form-item>
</a-col>
</template>
<!-- 工具栏左侧生成报表按钮 -->
<template #toolbar-left>
<a-space>
<a-button type="primary" @click="handleOpenGenerateModal">
<template #icon><icon-file-add /></template>
生成报表
</a-button>
</a-space>
</template>
<!-- 工具栏右侧查看和导出按钮 -->
<template #toolbar-right>
<a-space>
<a-button v-if="selectedRecord" type="outline" @click="handleViewContent()">
<template #icon><icon-eye /></template>
查看内容
</a-button>
<a-dropdown v-if="selectedRecord">
<a-button type="outline">
<template #icon><icon-download /></template>
导出
</a-button>
<template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
<!-- 操作列 -->
<template #operations="{ record }">
<a-space>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleViewContent(record)"
>
查看
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('csv', record)"
>
CSV
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('xlsx', record)"
>
Excel
</a-button>
</a-space>
</template>
</search-table>
<!-- 生成报表弹窗 -->
<a-modal
v-model:visible="generateModalVisible"
title="生成服务器报表"
:width="600"
:ok-loading="generating"
@ok="handleGenerate"
@cancel="handleCloseGenerateModal"
>
<a-form :model="generateForm" layout="vertical">
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
<a-range-picker
v-model="generateForm.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
<a-divider orientation="left">服务器选择二选一</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="服务器 ID" field="server_ids">
<a-input-tag
v-model="generateForm.server_ids_str"
placeholder="输入ID后按回车"
style="width: 100%"
@change="handleServerIdsChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="服务器标识" field="server_identities">
<a-input-tag
v-model="generateForm.server_identities"
placeholder="输入标识后按回车"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">报表配置</a-divider>
<a-form-item label="报表列" field="columns">
<a-checkbox-group v-model="generateForm.columns" style="width: 100%">
<a-row>
<a-col :span="8">
<a-checkbox value="availability">可用性</a-checkbox>
</a-col>
<a-col :span="8">
<a-checkbox value="last_check_time">最后检查时间</a-checkbox>
</a-col>
<a-col :span="8">
<a-checkbox value="cpu">CPU</a-checkbox>
</a-col>
<a-col :span="8">
<a-checkbox value="memory_phys">物理内存</a-checkbox>
</a-col>
<a-col :span="8">
<a-checkbox value="memory_virt">虚拟内存</a-checkbox>
</a-col>
<a-col :span="8">
<a-checkbox value="disk_io">磁盘 IO</a-checkbox>
</a-col>
<a-col :span="8">
<a-checkbox value="disk_usage">磁盘使用率</a-checkbox>
</a-col>
<a-col :span="8">
<a-checkbox value="daily_alerts">每日告警</a-checkbox>
</a-col>
</a-row>
</a-checkbox-group>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="可用性规则" field="availability_rule">
<a-select v-model="generateForm.availability_rule" placeholder="请选择" style="width: 100%">
<a-option value="metrics_presence">指标存在</a-option>
<a-option value="server_status">服务器状态</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="包含每日告警" field="include_daily_alerts">
<a-switch v-model="generateForm.include_daily_alerts" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="报表标题" field="title">
<a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
<!-- 查看内容弹窗 -->
<a-modal
v-model:visible="contentModalVisible"
:title="contentModalTitle"
:width="900"
:footer="false"
>
<div v-if="contentLoading" class="loading-container">
<a-spin />
</div>
<div v-else-if="reportContent">
<!-- 汇总信息 -->
<a-card v-if="reportContent.summary" title="服务器汇总" :bordered="false" class="summary-card">
<a-descriptions :column="3" bordered>
<a-descriptions-item
v-for="(value, key) in reportContent.summary"
:key="key"
:label="formatLabel(String(key))"
>
{{ formatValue(String(key), value) }}
</a-descriptions-item>
</a-descriptions>
</a-card>
<!-- 服务器列表 -->
<a-table
:data="reportContent.servers || []"
:columns="contentTableColumns"
:pagination="{ pageSize: 10 }"
stripe
/>
</div>
<a-empty v-else description="暂无数据" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import SearchTable from '@/components/search-table/index.vue'
import type { FormItem } from '@/components/search-form/types'
import {
fetchReportList,
generateReport,
fetchReportContent,
exportReport,
ReportType,
type ReportRecord,
type ServerReportParams,
} from '@/api/ops/report'
// 页面标题
const pageTitle = '服务器报表'
// 列表筛选表单模型
const formModel = ref<{
report_type: string
status: string
keyword: string
timeRange: string[]
}>({
report_type: ReportType.SERVER,
status: '',
keyword: '',
timeRange: [],
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'status',
label: '状态',
type: 'select',
span: 8,
placeholder: '请选择',
options: [
{ value: '', label: '全部' },
{ value: 'pending', label: '等待中' },
{ value: 'running', label: '生成中' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
],
},
{
field: 'keyword',
label: '标题',
type: 'input',
span: 8,
placeholder: '请输入标题关键字',
},
])
// 状态
const loading = ref(false)
const generating = ref(false)
const contentLoading = ref(false)
const exporting = ref(false)
const tableData = ref<ReportRecord[]>([])
const selectedRecord = ref<ReportRecord | null>(null)
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: true,
showJumper: true,
showPageSize: true,
})
// 表格列配置
const tableColumns = computed(() => [
{
title: 'ID',
dataIndex: 'id',
width: 80,
},
{
title: '标题',
dataIndex: 'title',
width: 200,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
slotName: 'status',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 180,
},
{
title: '操作',
dataIndex: 'operations',
width: 180,
slotName: 'operations',
fixed: 'right' as const,
},
])
// 生成报表弹窗
const generateModalVisible = ref(false)
const generateForm = ref<{
timeRange: string[]
server_ids_str: string[]
server_ids: number[]
server_identities: string[]
columns: string[]
availability_rule: string
include_daily_alerts: boolean
title: string
}>({
timeRange: [],
server_ids_str: [],
server_ids: [],
server_identities: [],
columns: ['availability', 'cpu', 'memory_phys', 'disk_usage'],
availability_rule: 'metrics_presence',
include_daily_alerts: false,
title: '',
})
// 查看内容弹窗
const contentModalVisible = ref(false)
const contentModalTitle = ref('')
const reportContent = ref<Record<string, any> | null>(null)
// 内容表格列配置
const contentTableColumns = computed(() => {
if (!reportContent.value) return []
const data = reportContent.value.servers || []
if (data.length === 0) return []
const firstRecord = data[0]
return Object.keys(firstRecord).map((key) => ({
title: formatLabel(key),
dataIndex: key,
width: 150,
}))
})
// 获取报表列表
const fetchList = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
size: pagination.pageSize,
report_type: ReportType.SERVER,
}
if (formModel.value.status) {
params.status = formModel.value.status
}
if (formModel.value.keyword) {
params.keyword = formModel.value.keyword
}
if (formModel.value.timeRange && formModel.value.timeRange.length === 2) {
params.created_from = formModel.value.timeRange[0]
params.created_to = formModel.value.timeRange[1]
}
const res = await fetchReportList(params)
if (res.code === 0 && res.details) {
tableData.value = res.details.data || []
pagination.total = res.details.total || 0
} else {
Message.error(res.message || '获取报表列表失败')
}
} catch (error: any) {
console.error('获取报表列表失败:', error)
Message.error(error.message || '获取报表列表失败')
} finally {
loading.value = false
}
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 处理服务器 ID 输入变化
const handleServerIdsChange = (value: string[]) => {
generateForm.value.server_ids = value
.map((v) => parseInt(v, 10))
.filter((n) => !isNaN(n))
}
// 查询
const handleSearch = () => {
pagination.current = 1
fetchList()
}
// 重置
const handleReset = () => {
formModel.value = {
report_type: ReportType.SERVER,
status: '',
keyword: '',
timeRange: [],
}
pagination.current = 1
fetchList()
}
// 刷新
const handleRefresh = () => {
fetchList()
Message.success('数据已刷新')
}
// 打开生成报表弹窗
const handleOpenGenerateModal = () => {
generateForm.value = {
timeRange: [],
server_ids_str: [],
server_ids: [],
server_identities: [],
columns: ['availability', 'cpu', 'memory_phys', 'disk_usage'],
availability_rule: 'metrics_presence',
include_daily_alerts: false,
title: '',
}
generateModalVisible.value = true
}
// 关闭生成报表弹窗
const handleCloseGenerateModal = () => {
generateModalVisible.value = false
}
// 生成报表
const handleGenerate = async () => {
// 验证必填项
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
// 验证服务器选择
if (
generateForm.value.server_ids.length === 0 &&
generateForm.value.server_identities.length === 0
) {
Message.warning('请选择服务器ID 或标识)')
return
}
// 验证不能同时选择
if (generateForm.value.server_ids.length > 0 && generateForm.value.server_identities.length > 0) {
Message.warning('服务器 ID 和标识不能同时填写')
return
}
generating.value = true
try {
const params: ServerReportParams = {
start_time: generateForm.value.timeRange[0],
end_time: generateForm.value.timeRange[1],
}
if (generateForm.value.server_ids.length > 0) {
params.server_ids = generateForm.value.server_ids
}
if (generateForm.value.server_identities.length > 0) {
params.server_identities = generateForm.value.server_identities
}
if (generateForm.value.columns.length > 0) {
params.columns = generateForm.value.columns as any
}
if (generateForm.value.availability_rule) {
params.availability_rule = generateForm.value.availability_rule as any
}
if (generateForm.value.include_daily_alerts) {
params.include_daily_alerts = generateForm.value.include_daily_alerts
}
const res = await generateReport({
report_type: ReportType.SERVER,
title: generateForm.value.title || undefined,
params,
})
if (res.code === 0 && res.details) {
if (res.details.status === 'success') {
Message.success('报表生成成功')
generateModalVisible.value = false
fetchList()
} else if (res.details.status === 'failed') {
Message.error(res.details.error_message || '报表生成失败')
} else {
Message.info('报表正在生成中,请稍后刷新查看')
generateModalVisible.value = false
fetchList()
}
} else {
Message.error(res.message || '报表生成失败')
}
} catch (error: any) {
console.error('生成报表失败:', error)
Message.error(error.message || '生成报表失败')
} finally {
generating.value = false
}
}
// 查看内容
const handleViewContent = async (record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) return
contentLoading.value = true
contentModalVisible.value = true
contentModalTitle.value = targetRecord.title
reportContent.value = null
try {
const res = await fetchReportContent(targetRecord.id)
if (res.code === 0 && res.details) {
reportContent.value = res.details
} else {
Message.error(res.message || '获取报表内容失败')
}
} catch (error: any) {
console.error('获取报表内容失败:', error)
Message.error(error.message || '获取报表内容失败')
} finally {
contentLoading.value = false
}
}
// 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) {
Message.warning('请选择要导出的报表')
return
}
if (targetRecord.status !== 'success') {
Message.warning('只能导出生成成功的报表')
return
}
exporting.value = true
try {
const blob = await exportReport(targetRecord.id, format)
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `report_${targetRecord.id}.${format}`
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
}
}
// 格式化标签
const formatLabel = (key: string) => {
const labelMap: Record<string, string> = {
server_id: '服务器 ID',
server_identity: '服务器标识',
server_name: '服务器名称',
ip_address: 'IP 地址',
availability: '可用性(%)',
last_check_time: '最后检查时间',
cpu: 'CPU使用率(%)',
memory_phys: '物理内存(%)',
memory_virt: '虚拟内存(%)',
disk_io: '磁盘IO',
disk_usage: '磁盘使用率(%)',
daily_alerts: '每日告警数',
avg_cpu: '平均CPU(%)',
max_cpu: '最大CPU(%)',
avg_memory: '平均内存(%)',
max_memory: '最大内存(%)',
total_servers: '服务器总数',
available_servers: '可用服务器数',
avg_availability: '平均可用性(%)',
}
return labelMap[key] || key
}
// 格式化数值
const formatValue = (key: string, value: any) => {
if (value === null || value === undefined) return '-'
// 百分比
if (
key.includes('rate') ||
key.includes('usage') ||
key.includes('cpu') ||
key.includes('memory') ||
key.includes('availability')
) {
if (typeof value === 'number') {
return value.toFixed(2) + '%'
}
}
// 数字类型保留两位小数
if (typeof value === 'number') {
return value.toFixed(2)
}
return value
}
// 初始化
fetchList()
</script>
<script lang="ts">
export default {
name: 'ServerReport',
}
</script>
<style scoped lang="less">
.container {
padding: 20px;
.summary-card {
margin-bottom: 20px;
:deep(.arco-card-body) {
padding: 16px;
}
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
}
</style>

View File

@@ -0,0 +1,771 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="tableColumns"
:loading="loading"
:pagination="pagination"
:title="pageTitle"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<!-- 自定义表单项创建时间范围 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="创建时间" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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: 100%"
/>
</a-form-item>
</a-col>
</template>
<!-- 工具栏左侧生成报表按钮 -->
<template #toolbar-left>
<a-space>
<a-button type="primary" @click="handleOpenGenerateModal">
<template #icon><icon-file-add /></template>
生成报表
</a-button>
</a-space>
</template>
<!-- 工具栏右侧查看和导出按钮 -->
<template #toolbar-right>
<a-space>
<a-button v-if="selectedRecord" type="outline" @click="handleViewContent()">
<template #icon><icon-eye /></template>
查看内容
</a-button>
<a-dropdown v-if="selectedRecord">
<a-button type="outline">
<template #icon><icon-download /></template>
导出
</a-button>
<template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
<!-- 操作列 -->
<template #operations="{ record }">
<a-space>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleViewContent(record)"
>
查看
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('csv', record)"
>
CSV
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('xlsx', record)"
>
Excel
</a-button>
</a-space>
</template>
</search-table>
<!-- 生成报表弹窗 -->
<a-modal
v-model:visible="generateModalVisible"
title="生成统计报表"
:width="600"
:ok-loading="generating"
@ok="handleGenerate"
@cancel="handleCloseGenerateModal"
>
<a-form :model="generateForm" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="数据源"
field="data_source"
:rules="[{ required: true, message: '请选择数据源' }]"
>
<a-select v-model="generateForm.data_source" placeholder="请选择" style="width: 100%">
<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-col>
<a-col :span="12">
<a-form-item
label="指标名称"
field="metric_name"
:rules="[{ required: true, message: '请输入指标名称' }]"
>
<a-input v-model="generateForm.metric_name" placeholder="请输入指标名称" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item
label="目标标识"
field="target_identities"
:rules="[{ required: true, message: '请输入目标标识' }]"
>
<a-input-tag
v-model="generateForm.target_identities"
placeholder="输入后按回车添加"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
<a-range-picker
v-model="generateForm.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="输出模式"
field="output_mode"
:rules="[{ required: true, message: '请选择输出模式' }]"
>
<a-select v-model="generateForm.output_mode" placeholder="请选择" style="width: 100%">
<a-option value="scalar">标量</a-option>
<a-option value="timeseries">时间序列</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="报表标题" field="title">
<a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<!-- 标量模式参数 -->
<template v-if="generateForm.output_mode === 'scalar'">
<a-form-item label="聚合函数" field="scalar_aggregations" :rules="[{ required: true, message: '请选择聚合函数' }]">
<a-checkbox-group v-model="generateForm.scalar_aggregations" style="width: 100%">
<a-row>
<a-col :span="6">
<a-checkbox value="avg">平均值</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox value="max">最大值</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox value="min">最小值</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox value="sum">求和</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox value="count">计数</a-checkbox>
</a-col>
</a-row>
</a-checkbox-group>
</a-form-item>
</template>
<!-- 时间序列模式参数 -->
<template v-if="generateForm.output_mode === 'timeseries'">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="时间间隔" field="interval" :rules="[{ required: true, message: '请输入时间间隔' }]">
<a-input v-model="generateForm.interval" placeholder="如: 1 hour, 5 minutes" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="桶聚合" field="bucket_aggregation" :rules="[{ required: true, message: '请选择桶聚合' }]">
<a-select v-model="generateForm.bucket_aggregation" placeholder="请选择" style="width: 100%">
<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-select>
</a-form-item>
</a-col>
</a-row>
</template>
</a-form>
</a-modal>
<!-- 查看内容弹窗 -->
<a-modal
v-model:visible="contentModalVisible"
:title="contentModalTitle"
:width="900"
:footer="false"
>
<div v-if="contentLoading" class="loading-container">
<a-spin />
</div>
<div v-else-if="reportContent">
<!-- 标量结果 -->
<template v-if="reportContent.output_mode === 'scalar'">
<a-card title="统计结果" :bordered="false" class="summary-card">
<a-descriptions :column="3" bordered>
<a-descriptions-item
v-for="(value, key) in reportContent.scalars"
:key="key"
:label="formatLabel(String(key))"
>
{{ formatValue(String(key), value) }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</template>
<!-- 时间序列结果 -->
<template v-else-if="reportContent.output_mode === 'timeseries'">
<div ref="chartRef" class="chart-container"></div>
<a-table
:data="reportContent.series || []"
:columns="seriesTableColumns"
:pagination="{ pageSize: 10 }"
stripe
/>
</template>
</div>
<a-empty v-else description="暂无数据" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, nextTick } from 'vue'
import { Message } from '@arco-design/web-vue'
import SearchTable from '@/components/search-table/index.vue'
import type { FormItem } from '@/components/search-form/types'
import {
fetchReportList,
generateReport,
fetchReportContent,
exportReport,
ReportType,
type ReportRecord,
type StatisticsReportParams,
} from '@/api/ops/report'
import * as echarts from 'echarts'
// 页面标题
const pageTitle = '统计报表'
// 列表筛选表单模型
const formModel = ref<{
report_type: string
status: string
keyword: string
timeRange: string[]
}>({
report_type: ReportType.STATISTICS,
status: '',
keyword: '',
timeRange: [],
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'status',
label: '状态',
type: 'select',
span: 8,
placeholder: '请选择',
options: [
{ value: '', label: '全部' },
{ value: 'pending', label: '等待中' },
{ value: 'running', label: '生成中' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
],
},
{
field: 'keyword',
label: '标题',
type: 'input',
span: 8,
placeholder: '请输入标题关键字',
},
])
// 状态
const loading = ref(false)
const generating = ref(false)
const contentLoading = ref(false)
const exporting = ref(false)
const tableData = ref<ReportRecord[]>([])
const selectedRecord = ref<ReportRecord | null>(null)
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: true,
showJumper: true,
showPageSize: true,
})
// 表格列配置
const tableColumns = computed(() => [
{
title: 'ID',
dataIndex: 'id',
width: 80,
},
{
title: '标题',
dataIndex: 'title',
width: 200,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
slotName: 'status',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 180,
},
{
title: '操作',
dataIndex: 'operations',
width: 180,
slotName: 'operations',
fixed: 'right' as const,
},
])
// 生成报表弹窗
const generateModalVisible = ref(false)
const generateForm = ref<{
data_source: string
metric_name: string
target_identities: string[]
timeRange: string[]
output_mode: string
scalar_aggregations: string[]
interval: string
bucket_aggregation: string
title: string
}>({
data_source: '',
metric_name: '',
target_identities: [],
timeRange: [],
output_mode: 'scalar',
scalar_aggregations: ['avg'],
interval: '',
bucket_aggregation: 'avg',
title: '',
})
// 查看内容弹窗
const contentModalVisible = ref(false)
const contentModalTitle = ref('')
const reportContent = ref<Record<string, any> | null>(null)
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
// 时间序列表格列配置
const seriesTableColumns = computed(() => [
{
title: '时间',
dataIndex: 'timestamp',
width: 180,
},
{
title: '值',
dataIndex: 'value',
width: 150,
},
])
// 获取报表列表
const fetchList = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
size: pagination.pageSize,
report_type: ReportType.STATISTICS,
}
if (formModel.value.status) {
params.status = formModel.value.status
}
if (formModel.value.keyword) {
params.keyword = formModel.value.keyword
}
if (formModel.value.timeRange && formModel.value.timeRange.length === 2) {
params.created_from = formModel.value.timeRange[0]
params.created_to = formModel.value.timeRange[1]
}
const res = await fetchReportList(params)
if (res.code === 0 && res.details) {
tableData.value = res.details.data || []
pagination.total = res.details.total || 0
} else {
Message.error(res.message || '获取报表列表失败')
}
} catch (error: any) {
console.error('获取报表列表失败:', error)
Message.error(error.message || '获取报表列表失败')
} finally {
loading.value = false
}
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 查询
const handleSearch = () => {
pagination.current = 1
fetchList()
}
// 重置
const handleReset = () => {
formModel.value = {
report_type: ReportType.STATISTICS,
status: '',
keyword: '',
timeRange: [],
}
pagination.current = 1
fetchList()
}
// 刷新
const handleRefresh = () => {
fetchList()
Message.success('数据已刷新')
}
// 打开生成报表弹窗
const handleOpenGenerateModal = () => {
generateForm.value = {
data_source: '',
metric_name: '',
target_identities: [],
timeRange: [],
output_mode: 'scalar',
scalar_aggregations: ['avg'],
interval: '',
bucket_aggregation: 'avg',
title: '',
}
generateModalVisible.value = true
}
// 关闭生成报表弹窗
const handleCloseGenerateModal = () => {
generateModalVisible.value = false
}
// 生成报表
const handleGenerate = async () => {
// 验证必填项
if (!generateForm.value.data_source) {
Message.warning('请选择数据源')
return
}
if (!generateForm.value.metric_name) {
Message.warning('请输入指标名称')
return
}
if (generateForm.value.target_identities.length === 0) {
Message.warning('请输入目标标识')
return
}
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
if (generateForm.value.output_mode === 'scalar' && generateForm.value.scalar_aggregations.length === 0) {
Message.warning('请选择聚合函数')
return
}
if (generateForm.value.output_mode === 'timeseries') {
if (!generateForm.value.interval) {
Message.warning('请输入时间间隔')
return
}
if (!generateForm.value.bucket_aggregation) {
Message.warning('请选择桶聚合')
return
}
}
generating.value = true
try {
const params: StatisticsReportParams = {
data_source: generateForm.value.data_source as any,
metric_name: generateForm.value.metric_name,
target_identities: generateForm.value.target_identities,
start_time: generateForm.value.timeRange[0],
end_time: generateForm.value.timeRange[1],
output_mode: generateForm.value.output_mode as any,
}
if (generateForm.value.output_mode === 'scalar') {
params.scalar_aggregations = generateForm.value.scalar_aggregations as any
}
if (generateForm.value.output_mode === 'timeseries') {
params.interval = generateForm.value.interval
params.bucket_aggregation = generateForm.value.bucket_aggregation as any
}
const res = await generateReport({
report_type: ReportType.STATISTICS,
title: generateForm.value.title || undefined,
params,
})
if (res.code === 0 && res.details) {
if (res.details.status === 'success') {
Message.success('报表生成成功')
generateModalVisible.value = false
fetchList()
} else if (res.details.status === 'failed') {
Message.error(res.details.error_message || '报表生成失败')
} else {
Message.info('报表正在生成中,请稍后刷新查看')
generateModalVisible.value = false
fetchList()
}
} else {
Message.error(res.message || '报表生成失败')
}
} catch (error: any) {
console.error('生成报表失败:', error)
Message.error(error.message || '生成报表失败')
} finally {
generating.value = false
}
}
// 查看内容
const handleViewContent = async (record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) return
contentLoading.value = true
contentModalVisible.value = true
contentModalTitle.value = targetRecord.title
reportContent.value = null
try {
const res = await fetchReportContent(targetRecord.id)
if (res.code === 0 && res.details) {
reportContent.value = res.details
// 如果是时间序列模式,渲染图表
if (res.details.output_mode === 'timeseries' && res.details.series) {
await nextTick()
renderChart(res.details.series)
}
} else {
Message.error(res.message || '获取报表内容失败')
}
} catch (error: any) {
console.error('获取报表内容失败:', error)
Message.error(error.message || '获取报表内容失败')
} finally {
contentLoading.value = false
}
}
// 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) {
Message.warning('请选择要导出的报表')
return
}
if (targetRecord.status !== 'success') {
Message.warning('只能导出生成成功的报表')
return
}
exporting.value = true
try {
const blob = await exportReport(targetRecord.id, format)
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `report_${targetRecord.id}.${format}`
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
}
}
// 格式化标签
const formatLabel = (key: string) => {
const labelMap: Record<string, string> = {
avg: '平均值',
max: '最大值',
min: '最小值',
sum: '求和',
count: '计数',
timestamp: '时间',
value: '值',
}
return labelMap[key] || key
}
// 格式化数值
const formatValue = (key: string, value: any) => {
if (value === null || value === undefined) return '-'
// 数字类型保留两位小数
if (typeof value === 'number') {
return value.toFixed(2)
}
return value
}
// 渲染图表
const renderChart = (series: any[]) => {
if (!chartRef.value || series.length === 0) return
if (chartInstance) {
chartInstance.dispose()
}
chartInstance = echarts.init(chartRef.value)
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: series.map((item: any) => item.timestamp),
},
yAxis: {
type: 'value',
},
series: [
{
name: '值',
type: 'line',
smooth: true,
data: series.map((item: any) => item.value),
},
],
}
chartInstance.setOption(option)
}
// 初始化
fetchList()
</script>
<script lang="ts">
export default {
name: 'StatisticsReport',
}
</script>
<style scoped lang="less">
.container {
padding: 20px;
.summary-card {
margin-bottom: 20px;
:deep(.arco-card-body) {
padding: 16px;
}
}
.chart-container {
height: 400px;
width: 100%;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
}
</style>

View File

@@ -0,0 +1,725 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="tableColumns"
:loading="loading"
:pagination="pagination"
:title="pageTitle"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<!-- 自定义表单项创建时间范围 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="创建时间" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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: 100%"
/>
</a-form-item>
</a-col>
</template>
<!-- 工具栏左侧生成报表按钮 -->
<template #toolbar-left>
<a-space>
<a-button type="primary" @click="handleOpenGenerateModal">
<template #icon><icon-file-add /></template>
生成报表
</a-button>
</a-space>
</template>
<!-- 工具栏右侧查看和导出按钮 -->
<template #toolbar-right>
<a-space>
<a-button v-if="selectedRecord" type="outline" @click="handleViewContent()">
<template #icon><icon-eye /></template>
查看内容
</a-button>
<a-dropdown v-if="selectedRecord">
<a-button type="outline">
<template #icon><icon-download /></template>
导出
</a-button>
<template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
<!-- 操作列 -->
<template #operations="{ record }">
<a-space>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleViewContent(record)"
>
查看
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('csv', record)"
>
CSV
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('xlsx', record)"
>
Excel
</a-button>
</a-space>
</template>
</search-table>
<!-- 生成报表弹窗 -->
<a-modal
v-model:visible="generateModalVisible"
title="生成 TopN 报表"
:width="600"
:ok-loading="generating"
@ok="handleGenerate"
@cancel="handleCloseGenerateModal"
>
<a-form :model="generateForm" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="数据源"
field="data_source"
:rules="[{ required: true, message: '请选择数据源' }]"
>
<a-select v-model="generateForm.data_source" placeholder="请选择" style="width: 100%">
<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-col>
<a-col :span="12">
<a-form-item
label="指标名称"
field="metric_name"
:rules="[{ required: true, message: '请输入指标名称' }]"
>
<a-input v-model="generateForm.metric_name" placeholder="请输入指标名称" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item
label="目标标识"
field="target_identities"
:rules="[{ required: true, message: '请输入目标标识' }]"
>
<a-input-tag
v-model="generateForm.target_identities"
placeholder="输入后按回车添加"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
<a-range-picker
v-model="generateForm.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="Top N" field="n">
<a-input-number
v-model="generateForm.n"
placeholder="默认10"
:min="1"
:max="500"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="排名聚合" field="rank_aggregate">
<a-select v-model="generateForm.rank_aggregate" placeholder="请选择" style="width: 100%">
<a-option value="avg">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
<a-option value="last">最后值</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="排序" field="order">
<a-select v-model="generateForm.order" placeholder="请选择" style="width: 100%">
<a-option value="desc">降序</a-option>
<a-option value="asc">升序</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<!-- 主机数据源额外参数 -->
<template v-if="generateForm.data_source === 'dc-host'">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="指标类型" field="metric_type">
<a-select v-model="generateForm.metric_type" placeholder="请选择" style="width: 100%" allow-clear>
<a-option value="cpu">CPU</a-option>
<a-option value="disk">磁盘</a-option>
<a-option value="io">IO</a-option>
<a-option value="memory">内存</a-option>
<a-option value="network">网络</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="采集器标识" field="collector_identity">
<a-input v-model="generateForm.collector_identity" placeholder="可选" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</template>
<a-form-item label="报表标题" field="title">
<a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
<!-- 查看内容弹窗 -->
<a-modal
v-model:visible="contentModalVisible"
:title="contentModalTitle"
:width="900"
:footer="false"
>
<div v-if="contentLoading" class="loading-container">
<a-spin />
</div>
<div v-else-if="reportContent">
<!-- 图表展示 -->
<div ref="chartRef" class="chart-container"></div>
<!-- 排名表格 -->
<a-table
:data="reportContent.ranking || []"
:columns="rankingTableColumns"
:pagination="false"
stripe
/>
</div>
<a-empty v-else description="暂无数据" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, nextTick } from 'vue'
import { Message } from '@arco-design/web-vue'
import SearchTable from '@/components/search-table/index.vue'
import type { FormItem } from '@/components/search-form/types'
import {
fetchReportList,
generateReport,
fetchReportContent,
exportReport,
ReportType,
type ReportRecord,
type TopNReportParams,
} from '@/api/ops/report'
import * as echarts from 'echarts'
// 页面标题
const pageTitle = 'TopN 报表'
// 列表筛选表单模型
const formModel = ref<{
report_type: string
status: string
keyword: string
timeRange: string[]
}>({
report_type: ReportType.TOPN,
status: '',
keyword: '',
timeRange: [],
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'status',
label: '状态',
type: 'select',
span: 8,
placeholder: '请选择',
options: [
{ value: '', label: '全部' },
{ value: 'pending', label: '等待中' },
{ value: 'running', label: '生成中' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
],
},
{
field: 'keyword',
label: '标题',
type: 'input',
span: 8,
placeholder: '请输入标题关键字',
},
])
// 状态
const loading = ref(false)
const generating = ref(false)
const contentLoading = ref(false)
const exporting = ref(false)
const tableData = ref<ReportRecord[]>([])
const selectedRecord = ref<ReportRecord | null>(null)
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: true,
showJumper: true,
showPageSize: true,
})
// 表格列配置
const tableColumns = computed(() => [
{
title: 'ID',
dataIndex: 'id',
width: 80,
},
{
title: '标题',
dataIndex: 'title',
width: 200,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
slotName: 'status',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 180,
},
{
title: '操作',
dataIndex: 'operations',
width: 180,
slotName: 'operations',
fixed: 'right' as const,
},
])
// 生成报表弹窗
const generateModalVisible = ref(false)
const generateForm = ref<{
data_source: string
metric_name: string
target_identities: string[]
timeRange: string[]
n: number
rank_aggregate: string
order: string
metric_type: string
collector_identity: string
title: string
}>({
data_source: '',
metric_name: '',
target_identities: [],
timeRange: [],
n: 10,
rank_aggregate: 'avg',
order: 'desc',
metric_type: '',
collector_identity: '',
title: '',
})
// 查看内容弹窗
const contentModalVisible = ref(false)
const contentModalTitle = ref('')
const reportContent = ref<Record<string, any> | null>(null)
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
// 排名表格列配置
const rankingTableColumns = computed(() => [
{
title: '排名',
dataIndex: 'rank',
width: 80,
},
{
title: '目标',
dataIndex: 'target',
width: 200,
},
{
title: '值',
dataIndex: 'value',
width: 150,
},
])
// 获取报表列表
const fetchList = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
size: pagination.pageSize,
report_type: ReportType.TOPN,
}
if (formModel.value.status) {
params.status = formModel.value.status
}
if (formModel.value.keyword) {
params.keyword = formModel.value.keyword
}
if (formModel.value.timeRange && formModel.value.timeRange.length === 2) {
params.created_from = formModel.value.timeRange[0]
params.created_to = formModel.value.timeRange[1]
}
const res = await fetchReportList(params)
if (res.code === 0 && res.details) {
tableData.value = res.details.data || []
pagination.total = res.details.total || 0
} else {
Message.error(res.message || '获取报表列表失败')
}
} catch (error: any) {
console.error('获取报表列表失败:', error)
Message.error(error.message || '获取报表列表失败')
} finally {
loading.value = false
}
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 查询
const handleSearch = () => {
pagination.current = 1
fetchList()
}
// 重置
const handleReset = () => {
formModel.value = {
report_type: ReportType.TOPN,
status: '',
keyword: '',
timeRange: [],
}
pagination.current = 1
fetchList()
}
// 刷新
const handleRefresh = () => {
fetchList()
Message.success('数据已刷新')
}
// 打开生成报表弹窗
const handleOpenGenerateModal = () => {
generateForm.value = {
data_source: '',
metric_name: '',
target_identities: [],
timeRange: [],
n: 10,
rank_aggregate: 'avg',
order: 'desc',
metric_type: '',
collector_identity: '',
title: '',
}
generateModalVisible.value = true
}
// 关闭生成报表弹窗
const handleCloseGenerateModal = () => {
generateModalVisible.value = false
}
// 生成报表
const handleGenerate = async () => {
// 验证必填项
if (!generateForm.value.data_source) {
Message.warning('请选择数据源')
return
}
if (!generateForm.value.metric_name) {
Message.warning('请输入指标名称')
return
}
if (generateForm.value.target_identities.length === 0) {
Message.warning('请输入目标标识')
return
}
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
generating.value = true
try {
const params: TopNReportParams = {
data_source: generateForm.value.data_source as any,
metric_name: generateForm.value.metric_name,
target_identities: generateForm.value.target_identities,
start_time: generateForm.value.timeRange[0],
end_time: generateForm.value.timeRange[1],
}
if (generateForm.value.n) {
params.n = generateForm.value.n
}
if (generateForm.value.rank_aggregate) {
params.rank_aggregate = generateForm.value.rank_aggregate as any
}
if (generateForm.value.order) {
params.order = generateForm.value.order as any
}
// 主机数据源额外参数
if (generateForm.value.data_source === 'dc-host') {
if (generateForm.value.metric_type) {
params.metric_type = generateForm.value.metric_type as any
}
if (generateForm.value.collector_identity) {
params.collector_identity = generateForm.value.collector_identity
}
}
const res = await generateReport({
report_type: ReportType.TOPN,
title: generateForm.value.title || undefined,
params,
})
if (res.code === 0 && res.details) {
if (res.details.status === 'success') {
Message.success('报表生成成功')
generateModalVisible.value = false
fetchList()
} else if (res.details.status === 'failed') {
Message.error(res.details.error_message || '报表生成失败')
} else {
Message.info('报表正在生成中,请稍后刷新查看')
generateModalVisible.value = false
fetchList()
}
} else {
Message.error(res.message || '报表生成失败')
}
} catch (error: any) {
console.error('生成报表失败:', error)
Message.error(error.message || '生成报表失败')
} finally {
generating.value = false
}
}
// 查看内容
const handleViewContent = async (record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) return
contentLoading.value = true
contentModalVisible.value = true
contentModalTitle.value = targetRecord.title
reportContent.value = null
try {
const res = await fetchReportContent(targetRecord.id)
if (res.code === 0 && res.details) {
reportContent.value = res.details
// 渲染图表
if (res.details.ranking) {
await nextTick()
renderChart(res.details.ranking)
}
} else {
Message.error(res.message || '获取报表内容失败')
}
} catch (error: any) {
console.error('获取报表内容失败:', error)
Message.error(error.message || '获取报表内容失败')
} finally {
contentLoading.value = false
}
}
// 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) {
Message.warning('请选择要导出的报表')
return
}
if (targetRecord.status !== 'success') {
Message.warning('只能导出生成成功的报表')
return
}
exporting.value = true
try {
const blob = await exportReport(targetRecord.id, format)
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `report_${targetRecord.id}.${format}`
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
}
}
// 渲染图表
const renderChart = (ranking: any[]) => {
if (!chartRef.value || ranking.length === 0) return
if (chartInstance) {
chartInstance.dispose()
}
chartInstance = echarts.init(chartRef.value)
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'value',
},
yAxis: {
type: 'category',
data: ranking.map((item: any) => item.target).reverse(),
},
series: [
{
name: '值',
type: 'bar',
data: ranking.map((item: any) => item.value).reverse(),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#188df0' },
{ offset: 1, color: '#83bff6' },
]),
},
},
],
}
chartInstance.setOption(option)
}
// 初始化
fetchList()
</script>
<script lang="ts">
export default {
name: 'TopNReport',
}
</script>
<style scoped lang="less">
.container {
padding: 20px;
.chart-container {
height: 400px;
width: 100%;
margin-bottom: 20px;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
}
</style>

View File

@@ -0,0 +1,840 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="tableColumns"
:loading="loading"
:pagination="pagination"
:title="pageTitle"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<!-- 自定义表单项创建时间范围 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="创建时间" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<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: 100%"
/>
</a-form-item>
</a-col>
</template>
<!-- 工具栏左侧生成报表按钮 -->
<template #toolbar-left>
<a-space>
<a-button type="primary" @click="handleOpenGenerateModal">
<template #icon><icon-file-add /></template>
生成报表
</a-button>
</a-space>
</template>
<!-- 工具栏右侧查看和导出按钮 -->
<template #toolbar-right>
<a-space>
<a-button v-if="selectedRecord" type="outline" @click="handleViewContent()">
<template #icon><icon-eye /></template>
查看内容
</a-button>
<a-dropdown v-if="selectedRecord">
<a-button type="outline">
<template #icon><icon-download /></template>
导出
</a-button>
<template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
<!-- 操作列 -->
<template #operations="{ record }">
<a-space>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleViewContent(record)"
>
查看
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('csv', record)"
>
CSV
</a-button>
<a-button
v-if="record.status === 'success'"
type="text"
size="small"
@click="handleExport('xlsx', record)"
>
Excel
</a-button>
</a-space>
</template>
</search-table>
<!-- 生成报表弹窗 -->
<a-modal
v-model:visible="generateModalVisible"
title="生成流量统计报表"
:width="600"
:ok-loading="generating"
@ok="handleGenerate"
@cancel="handleCloseGenerateModal"
>
<a-form :model="generateForm" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="拓扑 ID" field="topology_id" :rules="[{ required: true, message: '请输入拓扑 ID' }]">
<a-input-number
v-model="generateForm.topology_id"
placeholder="请输入拓扑 ID"
:min="1"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="链路 ID" field="link_id">
<a-input-number
v-model="generateForm.link_id"
placeholder="可选0 表示整拓扑"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="节点 ID" field="node_id">
<a-input v-model="generateForm.node_id" placeholder="可选" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="时间粒度" field="granularity">
<a-select v-model="generateForm.granularity" placeholder="请选择" style="width: 100%">
<a-option value="minute">分钟</a-option>
<a-option value="hour">小时</a-option>
<a-option value="day"></a-option>
<a-option value="month"></a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
<a-range-picker
v-model="generateForm.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="报表形态" field="report_shape" :rules="[{ required: true, message: '请选择报表形态' }]">
<a-select v-model="generateForm.report_shape" placeholder="请选择" style="width: 100%">
<a-option value="summary">汇总</a-option>
<a-option value="detail">明细</a-option>
<a-option value="trend">趋势</a-option>
<a-option value="top">Top 排名</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="报表标题" field="title">
<a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<!-- Top 排名额外参数 -->
<template v-if="generateForm.report_shape === 'top'">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="排序字段" field="top_order_by">
<a-select v-model="generateForm.top_order_by" placeholder="请选择" style="width: 100%">
<a-option value="total_bytes">总流量</a-option>
<a-option value="total_in_bytes">总入流量</a-option>
<a-option value="total_out_bytes">总出流量</a-option>
<a-option value="total_packets">总包数</a-option>
<a-option value="avg_latency">平均延迟</a-option>
<a-option value="avg_packet_loss">平均丢包率</a-option>
<a-option value="total_connections">总连接数</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="Top 数量" field="top_limit">
<a-input-number
v-model="generateForm.top_limit"
placeholder="1-100"
:min="1"
:max="100"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</template>
<!-- 明细额外参数 -->
<template v-if="generateForm.report_shape === 'detail'">
<a-form-item label="明细条数限制" field="detail_limit">
<a-input-number
v-model="generateForm.detail_limit"
placeholder="1-50000"
:min="1"
:max="50000"
style="width: 100%"
/>
</a-form-item>
</template>
<!-- 趋势额外参数 -->
<template v-if="generateForm.report_shape === 'trend'">
<a-form-item label="趋势粒度" field="trend_granularity">
<a-select v-model="generateForm.trend_granularity" placeholder="请选择" style="width: 100%">
<a-option value="minute">分钟</a-option>
<a-option value="hour">小时</a-option>
<a-option value="day"></a-option>
<a-option value="month"></a-option>
</a-select>
</a-form-item>
</template>
</a-form>
</a-modal>
<!-- 查看内容弹窗 -->
<a-modal
v-model:visible="contentModalVisible"
:title="contentModalTitle"
:width="900"
:footer="false"
>
<div v-if="contentLoading" class="loading-container">
<a-spin />
</div>
<div v-else-if="reportContent">
<!-- 汇总数据 -->
<template v-if="reportContentType === 'summary'">
<a-card v-if="reportContent.totals" title="流量汇总" :bordered="false" class="summary-card">
<a-descriptions :column="3" bordered>
<a-descriptions-item
v-for="(value, key) in reportContent.totals"
:key="key"
:label="formatLabel(String(key))"
>
{{ formatValue(String(key), value) }}
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-table
:data="reportContent.by_node || []"
:columns="contentTableColumns"
:pagination="{ pageSize: 10 }"
stripe
/>
</template>
<!-- 明细数据 -->
<template v-else-if="reportContentType === 'detail'">
<a-table
:data="reportContent.items || []"
:columns="contentTableColumns"
:pagination="{ pageSize: 10 }"
stripe
/>
</template>
<!-- 趋势数据 -->
<template v-else-if="reportContentType === 'trend'">
<div ref="chartRef" class="chart-container"></div>
</template>
<!-- Top 排名数据 -->
<template v-else-if="reportContentType === 'top'">
<a-table
:data="reportContent.ranking || []"
:columns="contentTableColumns"
:pagination="false"
stripe
/>
</template>
</div>
<a-empty v-else description="暂无数据" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, nextTick } from 'vue'
import { Message } from '@arco-design/web-vue'
import SearchTable from '@/components/search-table/index.vue'
import type { FormItem } from '@/components/search-form/types'
import {
fetchReportList,
generateReport,
fetchReportContent,
exportReport,
ReportType,
type ReportRecord,
type TrafficReportParams,
} from '@/api/ops/report'
import * as echarts from 'echarts'
// 页面标题
const pageTitle = '流量统计报表'
// 列表筛选表单模型
const formModel = ref<{
report_type: string
status: string
keyword: string
timeRange: string[]
}>({
report_type: ReportType.TRAFFIC,
status: '',
keyword: '',
timeRange: [],
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'status',
label: '状态',
type: 'select',
span: 8,
placeholder: '请选择',
options: [
{ value: '', label: '全部' },
{ value: 'pending', label: '等待中' },
{ value: 'running', label: '生成中' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
],
},
{
field: 'keyword',
label: '标题',
type: 'input',
span: 8,
placeholder: '请输入标题关键字',
},
])
// 状态
const loading = ref(false)
const generating = ref(false)
const contentLoading = ref(false)
const exporting = ref(false)
const tableData = ref<ReportRecord[]>([])
const selectedRecord = ref<ReportRecord | null>(null)
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: true,
showJumper: true,
showPageSize: true,
})
// 表格列配置
const tableColumns = computed(() => [
{
title: 'ID',
dataIndex: 'id',
width: 80,
},
{
title: '标题',
dataIndex: 'title',
width: 200,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
slotName: 'status',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 180,
},
{
title: '操作',
dataIndex: 'operations',
width: 180,
slotName: 'operations',
fixed: 'right' as const,
},
])
// 生成报表弹窗
const generateModalVisible = ref(false)
const generateForm = ref<{
topology_id: number | undefined
link_id: number | undefined
node_id: string
granularity: string
timeRange: string[]
report_shape: string
title: string
top_order_by: string
top_limit: number | undefined
detail_limit: number | undefined
trend_granularity: string
}>({
topology_id: undefined,
link_id: undefined,
node_id: '',
granularity: 'hour',
timeRange: [],
report_shape: 'summary',
title: '',
top_order_by: 'total_bytes',
top_limit: undefined,
detail_limit: undefined,
trend_granularity: 'hour',
})
// 查看内容弹窗
const contentModalVisible = ref(false)
const contentModalTitle = ref('')
const reportContent = ref<Record<string, any> | null>(null)
const reportContentType = ref<string>('')
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
// 内容表格列配置
const contentTableColumns = computed(() => {
if (!reportContent.value) return []
const data =
reportContent.value.by_node ||
reportContent.value.items ||
reportContent.value.ranking ||
[]
if (data.length === 0) return []
const firstRecord = data[0]
return Object.keys(firstRecord).map((key) => ({
title: formatLabel(key),
dataIndex: key,
width: 150,
}))
})
// 获取报表列表
const fetchList = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
size: pagination.pageSize,
report_type: ReportType.TRAFFIC,
}
if (formModel.value.status) {
params.status = formModel.value.status
}
if (formModel.value.keyword) {
params.keyword = formModel.value.keyword
}
if (formModel.value.timeRange && formModel.value.timeRange.length === 2) {
params.created_from = formModel.value.timeRange[0]
params.created_to = formModel.value.timeRange[1]
}
const res = await fetchReportList(params)
if (res.code === 0 && res.details) {
tableData.value = res.details.data || []
pagination.total = res.details.total || 0
} else {
Message.error(res.message || '获取报表列表失败')
}
} catch (error: any) {
console.error('获取报表列表失败:', error)
Message.error(error.message || '获取报表列表失败')
} finally {
loading.value = false
}
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 查询
const handleSearch = () => {
pagination.current = 1
fetchList()
}
// 重置
const handleReset = () => {
formModel.value = {
report_type: ReportType.TRAFFIC,
status: '',
keyword: '',
timeRange: [],
}
pagination.current = 1
fetchList()
}
// 刷新
const handleRefresh = () => {
fetchList()
Message.success('数据已刷新')
}
// 打开生成报表弹窗
const handleOpenGenerateModal = () => {
generateForm.value = {
topology_id: undefined,
link_id: undefined,
node_id: '',
granularity: 'hour',
timeRange: [],
report_shape: 'summary',
title: '',
top_order_by: 'total_bytes',
top_limit: undefined,
detail_limit: undefined,
trend_granularity: 'hour',
}
generateModalVisible.value = true
}
// 关闭生成报表弹窗
const handleCloseGenerateModal = () => {
generateModalVisible.value = false
}
// 生成报表
const handleGenerate = async () => {
// 验证必填项
if (!generateForm.value.topology_id) {
Message.warning('请输入拓扑 ID')
return
}
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
generating.value = true
try {
const params: TrafficReportParams = {
topology_id: generateForm.value.topology_id,
start_time: generateForm.value.timeRange[0],
end_time: generateForm.value.timeRange[1],
report_shape: generateForm.value.report_shape as any,
}
if (generateForm.value.link_id) {
params.link_id = generateForm.value.link_id
}
if (generateForm.value.node_id) {
params.node_id = generateForm.value.node_id
}
if (generateForm.value.granularity) {
params.granularity = generateForm.value.granularity as any
}
if (generateForm.value.report_shape === 'detail' && generateForm.value.detail_limit) {
params.detail_limit = generateForm.value.detail_limit
}
if (generateForm.value.report_shape === 'trend' && generateForm.value.trend_granularity) {
params.trend_granularity = generateForm.value.trend_granularity as any
}
if (generateForm.value.report_shape === 'top') {
if (generateForm.value.top_order_by) {
params.top_order_by = generateForm.value.top_order_by as any
}
if (generateForm.value.top_limit) {
params.top_limit = generateForm.value.top_limit
}
}
const res = await generateReport({
report_type: ReportType.TRAFFIC,
title: generateForm.value.title || undefined,
params,
})
if (res.code === 0 && res.details) {
if (res.details.status === 'success') {
Message.success('报表生成成功')
generateModalVisible.value = false
fetchList()
} else if (res.details.status === 'failed') {
Message.error(res.details.error_message || '报表生成失败')
} else {
Message.info('报表正在生成中,请稍后刷新查看')
generateModalVisible.value = false
fetchList()
}
} else {
Message.error(res.message || '报表生成失败')
}
} catch (error: any) {
console.error('生成报表失败:', error)
Message.error(error.message || '生成报表失败')
} finally {
generating.value = false
}
}
// 查看内容
const handleViewContent = async (record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) return
contentLoading.value = true
contentModalVisible.value = true
contentModalTitle.value = targetRecord.title
reportContent.value = null
try {
const res = await fetchReportContent(targetRecord.id)
if (res.code === 0 && res.details) {
reportContent.value = res.details
reportContentType.value = targetRecord.params_json?.report_shape || 'summary'
// 如果是趋势报表,渲染图表
if (reportContentType.value === 'trend' && res.details.series) {
await nextTick()
renderChart(res.details.series)
}
} else {
Message.error(res.message || '获取报表内容失败')
}
} catch (error: any) {
console.error('获取报表内容失败:', error)
Message.error(error.message || '获取报表内容失败')
} finally {
contentLoading.value = false
}
}
// 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value
if (!targetRecord) {
Message.warning('请选择要导出的报表')
return
}
if (targetRecord.status !== 'success') {
Message.warning('只能导出生成成功的报表')
return
}
exporting.value = true
try {
const blob = await exportReport(targetRecord.id, format)
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `report_${targetRecord.id}.${format}`
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
}
}
// 格式化标签
const formatLabel = (key: string) => {
const labelMap: Record<string, string> = {
node_id: '节点 ID',
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: '峰值连接数',
rank: '排名',
timestamp: '时间',
value: '值',
}
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 renderChart = (series: any[]) => {
if (!chartRef.value || series.length === 0) return
if (chartInstance) {
chartInstance.dispose()
}
chartInstance = echarts.init(chartRef.value)
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
},
legend: {
data: series.map((s) => s.name),
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: series[0]?.data?.map((item: any) => item.timestamp) || [],
},
yAxis: {
type: 'value',
},
series: series.map((s) => ({
name: s.name,
type: 'line',
smooth: true,
data: s.data?.map((item: any) => item.value) || [],
})),
}
chartInstance.setOption(option)
}
// 初始化
fetchList()
</script>
<script lang="ts">
export default {
name: 'TrafficReport',
}
</script>
<style scoped lang="less">
.container {
padding: 20px;
.summary-card {
margin-bottom: 20px;
:deep(.arco-card-body) {
padding: 16px;
}
}
.chart-container {
height: 400px;
width: 100%;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
}
</style>