Merge branch 'feature/report-pdf'

This commit is contained in:
zxr
2026-08-04 14:23:26 +08:00
9 changed files with 466 additions and 192 deletions

View File

@@ -1,5 +1,5 @@
import { request } from "@/api/request" import { request } from '@/api/request'
import SafeStorage, { AppStorageKey } from "@/utils/safeStorage" import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
// ============ 通用响应类型 ============ // ============ 通用响应类型 ============
@@ -43,6 +43,8 @@ export interface ReportRecord {
file_path?: string file_path?: string
file_size?: number file_size?: number
file_mime?: string file_mime?: string
started_at?: string
finished_at?: string
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -64,6 +66,13 @@ export interface PageResult<T> {
data: T[] data: T[]
} }
export type ReportExportFormat = 'csv' | 'xlsx' | 'pdf'
export interface DownloadedReport {
blob: Blob
fileName: string
}
// ============ 报表生成参数接口 ============ // ============ 报表生成参数接口 ============
export interface TrafficReportParams { export interface TrafficReportParams {
@@ -127,9 +136,7 @@ export interface NetworkDeviceReportParams {
export interface StatisticsReportParams { export interface StatisticsReportParams {
data_source: 'dc-host' | 'dc-network' | 'dc-database' | 'dc-middleware' data_source: 'dc-host' | 'dc-network' | 'dc-database' | 'dc-middleware'
/** 与 metric_name 二选一;网络等指标优先用 metric_id */ metric_id: string
metric_id?: string
metric_name?: string
target_identities: string[] target_identities: string[]
start_time: string start_time: string
end_time: string end_time: string
@@ -152,16 +159,13 @@ export interface HistoryReportParams {
export interface TopNReportParams { export interface TopNReportParams {
data_source: 'dc-host' | 'dc-network' | 'dc-database' | 'dc-middleware' | 'alert' data_source: 'dc-host' | 'dc-network' | 'dc-database' | 'dc-middleware' | 'alert'
metric_id?: string metric_id: string
metric_name?: string
target_identities: string[] target_identities: string[]
start_time: string start_time: string
end_time: string end_time: string
n?: number n?: number
rank_aggregate?: 'avg' | 'max' | 'min' | 'last' rank_aggregate?: 'avg' | 'max' | 'min' | 'last'
order?: 'desc' | 'asc' order?: 'desc' | 'asc'
metric_type?: 'cpu' | 'disk' | 'io' | 'memory' | 'network'
collector_identity?: string
} }
/** 证据导出事件行 */ /** 证据导出事件行 */
@@ -230,7 +234,8 @@ export const fetchReportMetricsAvailable = (params: {
identities?: string identities?: string
keyword?: string keyword?: string
limit?: number limit?: number
}) => request.get<ApiResponse<{ data_source: string; items: any[]; registry: any[] }>>('/DC-Control/v1/reports/metrics/available', { params }) }) =>
request.get<ApiResponse<{ data_source: string; items: any[]; registry: any[] }>>('/DC-Control/v1/reports/metrics/available', { params })
/** 逻辑指标目录Registry */ /** 逻辑指标目录Registry */
export const fetchReportMetricsRegistry = (params: { data_source: string }) => export const fetchReportMetricsRegistry = (params: { data_source: string }) =>
@@ -239,8 +244,8 @@ export const fetchReportMetricsRegistry = (params: { data_source: string }) =>
/** 查看报表内容 */ /** 查看报表内容 */
export const fetchReportContent = (id: number) => request.get<ApiResponse<Record<string, any>>>(`/DC-Control/v1/reports/${id}/content`) export const fetchReportContent = (id: number) => request.get<ApiResponse<Record<string, any>>>(`/DC-Control/v1/reports/${id}/content`)
/** 原始 ArrayBuffer → 下载用 Blobxlsx 校验 ZIP 魔数 PK */ /** 原始 ArrayBuffer → 下载用 Blobxlsx 校验 ZIP 魔数 PKPDF 校验 %PDF- */
function exportBufferToBlob(ab: ArrayBuffer, format: 'csv' | 'xlsx', contentType: string | null): Blob { function exportBufferToBlob(ab: ArrayBuffer, format: ReportExportFormat, contentType: string | null): Blob {
const u8 = new Uint8Array(ab) const u8 = new Uint8Array(ab)
if (format === 'xlsx') { if (format === 'xlsx') {
const okZip = u8.length >= 4 && u8[0] === 0x50 && u8[1] === 0x4b const okZip = u8.length >= 4 && u8[0] === 0x50 && u8[1] === 0x4b
@@ -265,16 +270,62 @@ function exportBufferToBlob(ab: ArrayBuffer, format: 'csv' | 'xlsx', contentType
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
return new Blob([ab], { type: mime }) return new Blob([ab], { type: mime })
} }
const mime = if (format === 'pdf') {
contentType && /csv|text|plain/i.test(contentType) ? contentType : 'text/csv;charset=utf-8' const okPdf = u8.length >= 5 && u8[0] === 0x25 && u8[1] === 0x50 && u8[2] === 0x44 && u8[3] === 0x46 && u8[4] === 0x2d
if (!okPdf) {
throw new Error('导出失败:服务器返回的不是有效的 PDF。')
}
const mime = contentType && /pdf/i.test(contentType) ? contentType : 'application/pdf'
return new Blob([ab], { type: mime })
}
const mime = contentType && /csv|text|plain/i.test(contentType) ? contentType : 'text/csv;charset=utf-8'
return new Blob([ab], { type: mime }) return new Blob([ab], { type: mime })
} }
function parseContentDispositionFileName(value: string | null): string | undefined {
if (!value) return undefined
const utf8FileName = value.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8FileName) {
try {
return decodeURIComponent(utf8FileName[1])
} catch {
// 编码异常时继续读取普通文件名
}
}
return value.match(/filename="([^"]+)"/i)?.[1]
}
function normalizeReportFileName(fileName: string | undefined, id: number, format: ReportExportFormat): string {
const defaultName = `report_${id}.${format}`
if (!fileName) return defaultName
let name = fileName.split(/[\\/]/).pop() || ''
name = name
.replace(/[\u0000-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '')
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/[ .]+$/, '')
const extensionIndex = name.lastIndexOf('.')
if (extensionIndex > 0) {
name = name.slice(0, extensionIndex).replace(/[ .]+$/, '')
}
if (!name) return defaultName
const reservedName = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i
if (reservedName.test(name.split('.')[0])) {
name = `_${name}`
}
return `${name}.${format}`
}
/** /**
* 报表文件导出:使用 fetch + arrayBuffer绕过 axios 拦截器与 Blob 中间态, * 报表文件导出:使用 fetch + arrayBuffer绕过 axios 拦截器与 Blob 中间态,
* 避免网络面板里已是合法 xlsxPK…但落盘文件损坏的情况。 * 避免网络面板里已是合法 xlsxPK…但落盘文件损坏的情况。
*/ */
export const exportReport = async (id: number, format: 'csv' | 'xlsx' = 'csv'): Promise<Blob> => { export const exportReport = async (id: number, format: ReportExportFormat = 'csv'): Promise<DownloadedReport> => {
const base = String(import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '') const base = String(import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '')
const url = `${base}/DC-Control/v1/reports/${id}/export?format=${encodeURIComponent(format)}` const url = `${base}/DC-Control/v1/reports/${id}/export?format=${encodeURIComponent(format)}`
const token = SafeStorage.get(AppStorageKey.TOKEN) const token = SafeStorage.get(AppStorageKey.TOKEN)
@@ -296,7 +347,10 @@ export const exportReport = async (id: number, format: 'csv' | 'xlsx' = 'csv'):
} }
throw new Error(msg) throw new Error(msg)
} }
return exportBufferToBlob(ab, format, r.headers.get('content-type')) return {
blob: exportBufferToBlob(ab, format, r.headers.get('content-type')),
fileName: normalizeReportFileName(parseContentDispositionFileName(r.headers.get('content-disposition')), id, format),
}
} }
// ============ 监测指标类接口(旧版兼容) ============ // ============ 监测指标类接口(旧版兼容) ============

View File

@@ -53,6 +53,7 @@
<template #content> <template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption> <a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption> <a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
<a-doption @click="handleExport('pdf')">导出 PDF</a-doption>
</template> </template>
</a-dropdown> </a-dropdown>
</a-space> </a-space>
@@ -70,6 +71,7 @@
<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="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('csv', record)">CSV</a-button>
<a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('xlsx', record)">Excel</a-button> <a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('xlsx', record)">Excel</a-button>
<a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('pdf', record)">PDF</a-button>
</a-space> </a-space>
</template> </template>
</search-table> </search-table>
@@ -211,12 +213,13 @@ import {
fetchReportList, fetchReportList,
generateReport, generateReport,
fetchReportContent, fetchReportContent,
exportReport,
ReportType, ReportType,
type ReportRecord, type ReportRecord,
type NetworkDeviceReportParams, type NetworkDeviceReportParams,
} from '@/api/ops/report' } from '@/api/ops/report'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { downloadReportFile } from '../useReportExport'
import type { ReportExportFormat } from '@/api/ops/report'
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow' import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
import { useReportNetworkDevicePickOptions } from '../useReportNetworkDevicePickOptions' import { useReportNetworkDevicePickOptions } from '../useReportNetworkDevicePickOptions'
@@ -560,7 +563,7 @@ const handleViewContent = async (record?: ReportRecord) => {
} }
// 导出报表 // 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => { const handleExport = async (format: ReportExportFormat, record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value const targetRecord = record || selectedRecord.value
if (!targetRecord) { if (!targetRecord) {
Message.warning('请选择要导出的报表') Message.warning('请选择要导出的报表')
@@ -575,18 +578,7 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
exporting.value = true exporting.value = true
try { try {
const blob = await exportReport(targetRecord.id, format) await downloadReportFile(targetRecord, 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('导出成功') Message.success('导出成功')
} catch (error: any) { } catch (error: any) {

View File

@@ -57,6 +57,7 @@
<template #content> <template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption> <a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption> <a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
<a-doption @click="handleExport('pdf')">导出 PDF</a-doption>
</template> </template>
</a-dropdown> </a-dropdown>
</a-space> </a-space>
@@ -74,6 +75,7 @@
<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="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('csv', record)">CSV</a-button>
<a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('xlsx', record)">Excel</a-button> <a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('xlsx', record)">Excel</a-button>
<a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('pdf', record)">PDF</a-button>
</a-space> </a-space>
</template> </template>
</search-table> </search-table>
@@ -235,13 +237,14 @@ import {
fetchReportList, fetchReportList,
generateReport, generateReport,
fetchReportContent, fetchReportContent,
exportReport,
exportEvidenceReport, exportEvidenceReport,
ReportType, ReportType,
type ReportRecord, type ReportRecord,
type FaultReportParams, type FaultReportParams,
} from '@/api/ops/report' } from '@/api/ops/report'
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow' import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
import { downloadReportFile } from '../useReportExport'
import type { ReportExportFormat } from '@/api/ops/report'
import { useFaultReportServiceIdentityOptions } from '../useReportTargetIdentityOptions' import { useFaultReportServiceIdentityOptions } from '../useReportTargetIdentityOptions'
const { const {
@@ -619,7 +622,7 @@ const handleViewContent = async (record?: ReportRecord) => {
} }
// 导出报表 // 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => { const handleExport = async (format: ReportExportFormat, record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value const targetRecord = record || selectedRecord.value
if (!targetRecord) { if (!targetRecord) {
Message.warning('请选择要导出的报表') Message.warning('请选择要导出的报表')
@@ -634,18 +637,7 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
exporting.value = true exporting.value = true
try { try {
const blob = await exportReport(targetRecord.id, format) await downloadReportFile(targetRecord, 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('导出成功') Message.success('导出成功')
} catch (error: any) { } catch (error: any) {

View File

@@ -1,38 +1,353 @@
<template> <template>
<div class="wrap"> <div class="container">
<a-result <search-table
status="info" :form-model="formModel"
title="历史报表入口已下线" :form-items="formItems"
sub-title="多指标多目标时序请使用统计报告output_mode=timeseries并按接口文档配置 interval bucket_aggregation旧类型记录仍可在各报表列表中按 report_type 筛选查看若库中有数据" :data="tableData"
:columns="tableColumns"
:loading="loading"
:pagination="pagination"
:title="pageTitle"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
> >
<template #extra> <template #form-items>
<a-space> <a-col :span="8">
<a-button type="primary" @click="goStatistics">前往统计报告</a-button> <a-form-item label="创建时间" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<a-button @click="goTopn">前往 TopN</a-button> <a-range-picker
</a-space> 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>
</a-result>
<template #reportType="{ record }">
{{ reportTypeLabel[record.report_type] || record.report_type || '—' }}
</template>
<template #status="{ record }">
<a-tag :color="reportStatusColor(record.status)">
{{ reportStatusLabel[record.status] || record.status || '—' }}
</a-tag>
</template>
<template #failureReason="{ record }">
{{ record.error_message || '—' }}
</template>
<template #operations="{ record }">
<a-button
v-if="canDownloadPDF(record)"
type="text"
size="small"
:loading="downloadingIds.has(record.id)"
@click="handleDownloadPDF(record)"
>
下载 PDF
</a-button>
<span v-else-if="record.status === 'failed'" class="operation-note">生成失败</span>
<span v-else-if="record.status === 'pending' || record.status === 'running'" class="operation-note">生成中</span>
<span v-else class="operation-note"> PDF</span>
</template>
</search-table>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useRouter } from 'vue-router' import { computed, onBeforeUnmount, reactive, ref } 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, ReportStatus, ReportType, type ReportListParams, type ReportRecord } from '@/api/ops/report'
import { downloadReportFile } from '../useReportExport'
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
const router = useRouter() const pageTitle = '历史报告'
const goStatistics = () => { const reportTypeLabel: Record<string, string> = {
router.push('/report/statistics') topn: 'TopN报表',
statistics: '统计报告',
traffic: '流量统计报告',
fault: '故障报告',
server: '服务器报告',
network_device: '网络设备报告',
evidence: '证据导出',
} }
const goTopn = () => { const pdfReportTypes = new Set([
router.push('/report/topn') ReportType.TOPN,
ReportType.STATISTICS,
ReportType.TRAFFIC,
ReportType.FAULT,
ReportType.SERVER,
ReportType.NETWORK_DEVICE,
])
const formModel = ref<{
report_type: ReportType | ''
status: ReportStatus | ''
keyword: string
timeRange?: string[]
}>({
report_type: '',
status: '',
keyword: '',
timeRange: [],
})
const formItems = computed<FormItem[]>(() => [
{
field: 'report_type',
label: '报表类型',
type: 'select',
span: 8,
placeholder: '请选择',
options: [
{ value: '', label: '全部' },
{ value: ReportType.TOPN, label: reportTypeLabel.topn },
{ value: ReportType.STATISTICS, label: reportTypeLabel.statistics },
{ value: ReportType.TRAFFIC, label: reportTypeLabel.traffic },
{ value: ReportType.FAULT, label: reportTypeLabel.fault },
{ value: ReportType.SERVER, label: reportTypeLabel.server },
{ value: ReportType.NETWORK_DEVICE, label: reportTypeLabel.network_device },
],
},
{
field: 'status',
label: '状态',
type: 'select',
span: 8,
placeholder: '请选择',
options: [
{ value: '', label: '全部' },
{ value: ReportStatus.PENDING, label: reportStatusLabel.pending },
{ value: ReportStatus.RUNNING, label: reportStatusLabel.running },
{ value: ReportStatus.SUCCESS, label: reportStatusLabel.success },
{ value: ReportStatus.FAILED, label: reportStatusLabel.failed },
],
},
{
field: 'keyword',
label: '标题',
type: 'input',
span: 8,
placeholder: '请输入标题关键字',
},
])
const loading = ref(false)
const downloadingIds = reactive(new Set<number>())
const tableData = ref<ReportRecord[]>([])
let latestRequestId = 0
onBeforeUnmount(() => {
latestRequestId += 1
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: true,
showJumper: true,
showPageSize: true,
})
const tableColumns = computed(() => [
{
title: '报告编号',
dataIndex: 'id',
width: 100,
},
{
title: '标题',
dataIndex: 'title',
width: 220,
},
{
title: '报表类型',
dataIndex: 'report_type',
width: 150,
slotName: 'reportType',
},
{
title: '状态',
dataIndex: 'status',
width: 100,
slotName: 'status',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
},
{
title: '完成时间',
dataIndex: 'finished_at',
width: 180,
},
{
title: '失败原因',
dataIndex: 'error_message',
width: 220,
slotName: 'failureReason',
},
{
title: '操作',
dataIndex: 'operations',
width: 120,
slotName: 'operations',
fixed: 'right' as const,
},
])
const canDownloadPDF = (record: ReportRecord) =>
record.status === ReportStatus.SUCCESS && pdfReportTypes.has(record.report_type as ReportType)
const fetchList = async (): Promise<boolean> => {
const requestId = ++latestRequestId
loading.value = true
const reportType = formModel.value.report_type
const status = formModel.value.status
const keyword = formModel.value.keyword.trim()
const timeRange = formModel.value.timeRange
const page = pagination.current
const size = pagination.pageSize
const params: ReportListParams = {
page,
size,
}
if (reportType) {
params.report_type = reportType
}
if (status) {
params.status = status
}
if (keyword) {
params.keyword = keyword
}
if (timeRange?.length === 2 && timeRange[0] && timeRange[1]) {
params.created_from = timeRange[0]
params.created_to = timeRange[1]
}
try {
while (true) {
const res = await fetchReportList(params)
if (requestId !== latestRequestId) return false
if (res.code !== 0 || !res.details) {
tableData.value = []
pagination.total = 0
Message.error(res.message || '获取报表列表失败')
return false
}
const total = res.details.total || 0
const maximumPage = Math.max(1, Math.ceil(total / size))
if ((params.page || 1) > maximumPage) {
pagination.current = maximumPage
params.page = maximumPage
continue
}
tableData.value = normalizeReportRows(res.details.data || [])
pagination.total = total
return true
}
} catch (error: any) {
if (requestId !== latestRequestId) return false
tableData.value = []
pagination.total = 0
console.error('获取报表列表失败:', error)
Message.error(error.message || '获取报表列表失败')
return false
} finally {
if (requestId === latestRequestId) {
loading.value = false
}
}
}
const handleFormModelUpdate = (value: Record<string, any>) => {
formModel.value = {
...formModel.value,
...value,
}
}
const handleSearch = () => {
pagination.current = 1
fetchList()
}
const handleReset = () => {
formModel.value = {
report_type: '',
status: '',
keyword: '',
timeRange: [],
}
pagination.current = 1
fetchList()
}
const handleRefresh = async () => {
if (await fetchList()) {
Message.success('数据已刷新')
}
}
const handlePageChange = (current: number) => {
pagination.current = current
fetchList()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
pagination.current = 1
fetchList()
}
const handleDownloadPDF = async (record: ReportRecord) => {
downloadingIds.add(record.id)
try {
await downloadReportFile(record, 'pdf')
Message.success('PDF 下载成功')
} catch (error: any) {
console.error('PDF 下载失败:', error)
Message.error(error.message || 'PDF 下载失败')
} finally {
downloadingIds.delete(record.id)
}
}
fetchList()
</script>
<script lang="ts">
export default {
name: 'ReportHistory',
} }
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.wrap { .container {
padding: 48px 24px; padding: 20px;
max-width: 720px;
margin: 0 auto; .operation-note {
color: var(--color-text-3);
}
} }
</style> </style>

View File

@@ -53,6 +53,7 @@
<template #content> <template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption> <a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption> <a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
<a-doption @click="handleExport('pdf')">导出 PDF</a-doption>
</template> </template>
</a-dropdown> </a-dropdown>
</a-space> </a-space>
@@ -70,6 +71,7 @@
<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="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('csv', record)">CSV</a-button>
<a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('xlsx', record)">Excel</a-button> <a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('xlsx', record)">Excel</a-button>
<a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('pdf', record)">PDF</a-button>
</a-space> </a-space>
</template> </template>
</search-table> </search-table>
@@ -211,12 +213,13 @@ import {
fetchReportList, fetchReportList,
generateReport, generateReport,
fetchReportContent, fetchReportContent,
exportReport,
ReportType, ReportType,
type ReportRecord, type ReportRecord,
type ServerReportParams, type ServerReportParams,
} from '@/api/ops/report' } from '@/api/ops/report'
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow' import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
import { downloadReportFile } from '../useReportExport'
import type { ReportExportFormat } from '@/api/ops/report'
import { useReportServerPickOptions } from '../useReportServerPickOptions' import { useReportServerPickOptions } from '../useReportServerPickOptions'
const { serverIdentityOptions, serverOptionsLoading, loadServerPickOptions } = useReportServerPickOptions() const { serverIdentityOptions, serverOptionsLoading, loadServerPickOptions } = useReportServerPickOptions()
@@ -548,7 +551,7 @@ const handleViewContent = async (record?: ReportRecord) => {
} }
// 导出报表 // 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => { const handleExport = async (format: ReportExportFormat, record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value const targetRecord = record || selectedRecord.value
if (!targetRecord) { if (!targetRecord) {
Message.warning('请选择要导出的报表') Message.warning('请选择要导出的报表')
@@ -563,18 +566,7 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
exporting.value = true exporting.value = true
try { try {
const blob = await exportReport(targetRecord.id, format) await downloadReportFile(targetRecord, 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('导出成功') Message.success('导出成功')
} catch (error: any) { } catch (error: any) {

View File

@@ -53,6 +53,7 @@
<template #content> <template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption> <a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption> <a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
<a-doption @click="handleExport('pdf')">导出 PDF</a-doption>
</template> </template>
</a-dropdown> </a-dropdown>
</a-space> </a-space>
@@ -70,6 +71,7 @@
<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="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('csv', record)">CSV</a-button>
<a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('xlsx', record)">Excel</a-button> <a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('xlsx', record)">Excel</a-button>
<a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('pdf', record)">PDF</a-button>
</a-space> </a-space>
</template> </template>
</search-table> </search-table>
@@ -248,12 +250,13 @@ import {
fetchReportList, fetchReportList,
generateReport, generateReport,
fetchReportContent, fetchReportContent,
exportReport,
ReportType, ReportType,
type ReportRecord, type ReportRecord,
type StatisticsReportParams, type StatisticsReportParams,
} from '@/api/ops/report' } from '@/api/ops/report'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { downloadReportFile } from '../useReportExport'
import type { ReportExportFormat } from '@/api/ops/report'
import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions' import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions'
import { useReportMetricRegistryOptions } from '../useReportMetricRegistryOptions' import { useReportMetricRegistryOptions } from '../useReportMetricRegistryOptions'
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow' import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
@@ -634,7 +637,7 @@ const handleViewContent = async (record?: ReportRecord) => {
} }
// 导出报表 // 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => { const handleExport = async (format: ReportExportFormat, record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value const targetRecord = record || selectedRecord.value
if (!targetRecord) { if (!targetRecord) {
Message.warning('请选择要导出的报表') Message.warning('请选择要导出的报表')
@@ -649,18 +652,7 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
exporting.value = true exporting.value = true
try { try {
const blob = await exportReport(targetRecord.id, format) await downloadReportFile(targetRecord, 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('导出成功') Message.success('导出成功')
} catch (error: any) { } catch (error: any) {

View File

@@ -53,6 +53,7 @@
<template #content> <template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption> <a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption> <a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
<a-doption @click="handleExport('pdf')">导出 PDF</a-doption>
</template> </template>
</a-dropdown> </a-dropdown>
</a-space> </a-space>
@@ -70,6 +71,7 @@
<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="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('csv', record)">CSV</a-button>
<a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('xlsx', record)">Excel</a-button> <a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('xlsx', record)">Excel</a-button>
<a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('pdf', record)">PDF</a-button>
</a-space> </a-space>
</template> </template>
</search-table> </search-table>
@@ -97,11 +99,7 @@
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item <a-form-item label="指标" field="metric_id" :rules="[{ required: true, message: '请选择指标' }]">
label="指标"
field="metric_id"
:rules="[{ required: true, message: '请选择指标' }]"
>
<a-select <a-select
v-model="generateForm.metric_id" v-model="generateForm.metric_id"
allow-clear allow-clear
@@ -116,11 +114,7 @@
</a-col> </a-col>
</a-row> </a-row>
<a-form-item <a-form-item label="目标标识" field="target_identities" :rules="[{ required: true, message: '请选择目标标识' }]">
label="目标标识"
field="target_identities"
:rules="[{ required: true, message: '请选择目标标识' }]"
>
<a-select <a-select
v-model="generateForm.target_identities" v-model="generateForm.target_identities"
multiple multiple
@@ -128,9 +122,7 @@
allow-search allow-search
:loading="targetOptionsLoading" :loading="targetOptionsLoading"
:options="targetIdentityOptions" :options="targetIdentityOptions"
:placeholder=" :placeholder="generateForm.data_source ? '请选择或搜索目标(可多选)' : '请先选择数据源'"
generateForm.data_source ? '请选择或搜索目标(可多选)' : '请先选择数据源'
"
:disabled="!generateForm.data_source || generateForm.data_source === 'alert'" :disabled="!generateForm.data_source || generateForm.data_source === 'alert'"
:max-tag-count="3" :max-tag-count="3"
style="width: 100%" style="width: 100%"
@@ -179,28 +171,6 @@
</a-col> </a-col>
</a-row> </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-form-item label="报表标题" field="title">
<a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" /> <a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" />
</a-form-item> </a-form-item>
@@ -217,12 +187,7 @@
<div ref="chartRef" class="chart-container"></div> <div ref="chartRef" class="chart-container"></div>
<!-- 排名表格引擎字段为 identity + value dc-control genTopN --> <!-- 排名表格引擎字段为 identity + value dc-control genTopN -->
<a-table <a-table :data="normalizedRankingRows" :columns="rankingTableColumns" :pagination="false" stripe />
:data="normalizedRankingRows"
:columns="rankingTableColumns"
:pagination="false"
stripe
/>
</div> </div>
<a-empty v-else description="暂无数据" /> <a-empty v-else description="暂无数据" />
</a-modal> </a-modal>
@@ -234,22 +199,15 @@ import { ref, reactive, computed, nextTick, watch } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import SearchTable from '@/components/search-table/index.vue' import SearchTable from '@/components/search-table/index.vue'
import type { FormItem } from '@/components/search-form/types' import type { FormItem } from '@/components/search-form/types'
import { import { fetchReportList, generateReport, fetchReportContent, ReportType, type ReportRecord, type TopNReportParams } from '@/api/ops/report'
fetchReportList,
generateReport,
fetchReportContent,
exportReport,
ReportType,
type ReportRecord,
type TopNReportParams,
} from '@/api/ops/report'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { downloadReportFile } from '../useReportExport'
import type { ReportExportFormat } from '@/api/ops/report'
import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions' import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions'
import { useReportMetricRegistryOptions } from '../useReportMetricRegistryOptions' import { useReportMetricRegistryOptions } from '../useReportMetricRegistryOptions'
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow' import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
const { targetIdentityOptions, targetOptionsLoading, loadTargetIdentityOptions } = const { targetIdentityOptions, targetOptionsLoading, loadTargetIdentityOptions } = useReportTargetIdentityOptions()
useReportTargetIdentityOptions()
const { metricOptions, metricOptionsLoading, loadMetricRegistryOptions } = useReportMetricRegistryOptions() const { metricOptions, metricOptionsLoading, loadMetricRegistryOptions } = useReportMetricRegistryOptions()
@@ -359,8 +317,6 @@ const generateForm = ref<{
n: number n: number
rank_aggregate: string rank_aggregate: string
order: string order: string
metric_type: string
collector_identity: string
title: string title: string
}>({ }>({
data_source: '', data_source: '',
@@ -370,8 +326,6 @@ const generateForm = ref<{
n: 10, n: 10,
rank_aggregate: 'avg', rank_aggregate: 'avg',
order: 'desc', order: 'desc',
metric_type: '',
collector_identity: '',
title: '', title: '',
}) })
@@ -386,7 +340,7 @@ watch(
} }
loadTargetIdentityOptions(ds) loadTargetIdentityOptions(ds)
loadMetricRegistryOptions(ds) loadMetricRegistryOptions(ds)
}, }
) )
// 查看内容弹窗 // 查看内容弹窗
@@ -509,8 +463,6 @@ const handleOpenGenerateModal = () => {
n: 10, n: 10,
rank_aggregate: 'avg', rank_aggregate: 'avg',
order: 'desc', order: 'desc',
metric_type: '',
collector_identity: '',
title: '', title: '',
} }
generateModalVisible.value = true generateModalVisible.value = true
@@ -567,16 +519,6 @@ const handleGenerate = async () => {
params.order = generateForm.value.order as any 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({ const res = await generateReport({
report_type: ReportType.TOPN, report_type: ReportType.TOPN,
title: generateForm.value.title || undefined, title: generateForm.value.title || undefined,
@@ -639,7 +581,7 @@ const handleViewContent = async (record?: ReportRecord) => {
} }
// 导出报表 // 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => { const handleExport = async (format: ReportExportFormat, record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value const targetRecord = record || selectedRecord.value
if (!targetRecord) { if (!targetRecord) {
Message.warning('请选择要导出的报表') Message.warning('请选择要导出的报表')
@@ -654,18 +596,7 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
exporting.value = true exporting.value = true
try { try {
const blob = await exportReport(targetRecord.id, format) await downloadReportFile(targetRecord, 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('导出成功') Message.success('导出成功')
} catch (error: any) { } catch (error: any) {
@@ -704,9 +635,7 @@ const renderChart = (ranking: any[]) => {
}, },
yAxis: { yAxis: {
type: 'category', type: 'category',
data: ranking data: ranking.map((item: any) => item.target ?? item.identity ?? item.target_identity ?? '').reverse(),
.map((item: any) => item.target ?? item.identity ?? item.target_identity ?? '')
.reverse(),
}, },
series: [ series: [
{ {

View File

@@ -53,6 +53,7 @@
<template #content> <template #content>
<a-doption @click="handleExport('csv')">导出 CSV</a-doption> <a-doption @click="handleExport('csv')">导出 CSV</a-doption>
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption> <a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
<a-doption @click="handleExport('pdf')">导出 PDF</a-doption>
</template> </template>
</a-dropdown> </a-dropdown>
</a-space> </a-space>
@@ -70,6 +71,7 @@
<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="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('csv', record)">CSV</a-button>
<a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('xlsx', record)">Excel</a-button> <a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('xlsx', record)">Excel</a-button>
<a-button v-if="record.status === 'success'" type="text" size="small" @click="handleExport('pdf', record)">PDF</a-button>
</a-space> </a-space>
</template> </template>
</search-table> </search-table>
@@ -323,12 +325,13 @@ import {
fetchReportList, fetchReportList,
generateReport, generateReport,
fetchReportContent, fetchReportContent,
exportReport,
ReportType, ReportType,
type ReportRecord, type ReportRecord,
type TrafficReportParams, type TrafficReportParams,
} from '@/api/ops/report' } from '@/api/ops/report'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { downloadReportFile } from '../useReportExport'
import type { ReportExportFormat } from '@/api/ops/report'
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow' import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
import { useReportTopologyOptions } from '../useReportTopologyOptions' import { useReportTopologyOptions } from '../useReportTopologyOptions'
import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions' import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions'
@@ -815,7 +818,7 @@ const handleViewContent = async (record?: ReportRecord) => {
} }
// 导出报表 // 导出报表
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => { const handleExport = async (format: ReportExportFormat, record?: ReportRecord) => {
const targetRecord = record || selectedRecord.value const targetRecord = record || selectedRecord.value
if (!targetRecord) { if (!targetRecord) {
Message.warning('请选择要导出的报表') Message.warning('请选择要导出的报表')
@@ -830,18 +833,7 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
exporting.value = true exporting.value = true
try { try {
const blob = await exportReport(targetRecord.id, format) await downloadReportFile(targetRecord, 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('导出成功') Message.success('导出成功')
} catch (error: any) { } catch (error: any) {

View File

@@ -0,0 +1,16 @@
import { exportReport, type ReportExportFormat, type ReportRecord } from '@/api/ops/report'
export async function downloadReportFile(record: ReportRecord, format: ReportExportFormat): Promise<void> {
const { blob, fileName } = await exportReport(record.id, format)
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
try {
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
} finally {
link.remove()
window.URL.revokeObjectURL(url)
}
}