feat
This commit is contained in:
840
src/views/ops/pages/report/device/index.vue
Normal file
840
src/views/ops/pages/report/device/index.vue
Normal 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>
|
||||
724
src/views/ops/pages/report/fault/index.vue
Normal file
724
src/views/ops/pages/report/fault/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
706
src/views/ops/pages/report/host/index.vue
Normal file
706
src/views/ops/pages/report/host/index.vue
Normal 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>
|
||||
771
src/views/ops/pages/report/statistics/index.vue
Normal file
771
src/views/ops/pages/report/statistics/index.vue
Normal 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>
|
||||
725
src/views/ops/pages/report/topn/index.vue
Normal file
725
src/views/ops/pages/report/topn/index.vue
Normal 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>
|
||||
840
src/views/ops/pages/report/traffic/index.vue
Normal file
840
src/views/ops/pages/report/traffic/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user