diff --git a/src/api/ops/report.ts b/src/api/ops/report.ts index e82f748..9d7a546 100644 --- a/src/api/ops/report.ts +++ b/src/api/ops/report.ts @@ -1,5 +1,5 @@ -import { request } from "@/api/request" -import SafeStorage, { AppStorageKey } from "@/utils/safeStorage" +import { request } from '@/api/request' +import SafeStorage, { AppStorageKey } from '@/utils/safeStorage' // ============ 通用响应类型 ============ @@ -43,6 +43,8 @@ export interface ReportRecord { file_path?: string file_size?: number file_mime?: string + started_at?: string + finished_at?: string created_at: string updated_at: string } @@ -64,6 +66,13 @@ export interface PageResult { data: T[] } +export type ReportExportFormat = 'csv' | 'xlsx' | 'pdf' + +export interface DownloadedReport { + blob: Blob + fileName: string +} + // ============ 报表生成参数接口 ============ export interface TrafficReportParams { @@ -127,9 +136,7 @@ export interface NetworkDeviceReportParams { export interface StatisticsReportParams { data_source: 'dc-host' | 'dc-network' | 'dc-database' | 'dc-middleware' - /** 与 metric_name 二选一;网络等指标优先用 metric_id */ - metric_id?: string - metric_name?: string + metric_id: string target_identities: string[] start_time: string end_time: string @@ -152,16 +159,13 @@ export interface HistoryReportParams { export interface TopNReportParams { data_source: 'dc-host' | 'dc-network' | 'dc-database' | 'dc-middleware' | 'alert' - metric_id?: string - metric_name?: string + metric_id: string target_identities: string[] start_time: string end_time: string n?: number rank_aggregate?: 'avg' | 'max' | 'min' | 'last' order?: 'desc' | 'asc' - metric_type?: 'cpu' | 'disk' | 'io' | 'memory' | 'network' - collector_identity?: string } /** 证据导出事件行 */ @@ -230,7 +234,8 @@ export const fetchReportMetricsAvailable = (params: { identities?: string keyword?: string limit?: number -}) => request.get>('/DC-Control/v1/reports/metrics/available', { params }) +}) => + request.get>('/DC-Control/v1/reports/metrics/available', { params }) /** 逻辑指标目录(Registry) */ 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>>(`/DC-Control/v1/reports/${id}/content`) -/** 原始 ArrayBuffer → 下载用 Blob;xlsx 校验 ZIP 魔数 PK */ -function exportBufferToBlob(ab: ArrayBuffer, format: 'csv' | 'xlsx', contentType: string | null): Blob { +/** 原始 ArrayBuffer → 下载用 Blob;xlsx 校验 ZIP 魔数 PK,PDF 校验 %PDF- */ +function exportBufferToBlob(ab: ArrayBuffer, format: ReportExportFormat, contentType: string | null): Blob { const u8 = new Uint8Array(ab) if (format === 'xlsx') { 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' return new Blob([ab], { type: mime }) } - const mime = - contentType && /csv|text|plain/i.test(contentType) ? contentType : 'text/csv;charset=utf-8' + if (format === 'pdf') { + 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 }) } +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 中间态, * 避免网络面板里已是合法 xlsx(PK…)但落盘文件损坏的情况。 */ -export const exportReport = async (id: number, format: 'csv' | 'xlsx' = 'csv'): Promise => { +export const exportReport = async (id: number, format: ReportExportFormat = 'csv'): Promise => { const base = String(import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '') const url = `${base}/DC-Control/v1/reports/${id}/export?format=${encodeURIComponent(format)}` const token = SafeStorage.get(AppStorageKey.TOKEN) @@ -296,7 +347,10 @@ export const exportReport = async (id: number, format: 'csv' | 'xlsx' = 'csv'): } 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), + } } // ============ 监测指标类接口(旧版兼容) ============ diff --git a/src/views/ops/pages/report/device/index.vue b/src/views/ops/pages/report/device/index.vue index e7c1f6c..130c0e2 100644 --- a/src/views/ops/pages/report/device/index.vue +++ b/src/views/ops/pages/report/device/index.vue @@ -53,6 +53,7 @@ @@ -70,6 +71,7 @@ 查看 CSV Excel + PDF @@ -211,12 +213,13 @@ import { fetchReportList, generateReport, fetchReportContent, - exportReport, ReportType, type ReportRecord, type NetworkDeviceReportParams, } from '@/api/ops/report' import * as echarts from 'echarts' +import { downloadReportFile } from '../useReportExport' +import type { ReportExportFormat } from '@/api/ops/report' import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow' 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 if (!targetRecord) { Message.warning('请选择要导出的报表') @@ -575,18 +578,7 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => { 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) + await downloadReportFile(targetRecord, format) Message.success('导出成功') } catch (error: any) { diff --git a/src/views/ops/pages/report/fault/index.vue b/src/views/ops/pages/report/fault/index.vue index 25200fb..e1d2539 100644 --- a/src/views/ops/pages/report/fault/index.vue +++ b/src/views/ops/pages/report/fault/index.vue @@ -57,6 +57,7 @@ @@ -74,6 +75,7 @@ 查看 CSV Excel + PDF @@ -235,13 +237,14 @@ import { fetchReportList, generateReport, fetchReportContent, - exportReport, exportEvidenceReport, ReportType, type ReportRecord, type FaultReportParams, } from '@/api/ops/report' import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow' +import { downloadReportFile } from '../useReportExport' +import type { ReportExportFormat } from '@/api/ops/report' import { useFaultReportServiceIdentityOptions } from '../useReportTargetIdentityOptions' 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 if (!targetRecord) { Message.warning('请选择要导出的报表') @@ -634,18 +637,7 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => { 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) + await downloadReportFile(targetRecord, format) Message.success('导出成功') } catch (error: any) { diff --git a/src/views/ops/pages/report/history/index.vue b/src/views/ops/pages/report/history/index.vue index 19b7552..cae1831 100644 --- a/src/views/ops/pages/report/history/index.vue +++ b/src/views/ops/pages/report/history/index.vue @@ -1,38 +1,353 @@ @@ -211,12 +213,13 @@ import { fetchReportList, generateReport, fetchReportContent, - exportReport, ReportType, type ReportRecord, type ServerReportParams, } from '@/api/ops/report' import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow' +import { downloadReportFile } from '../useReportExport' +import type { ReportExportFormat } from '@/api/ops/report' import { useReportServerPickOptions } from '../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 if (!targetRecord) { Message.warning('请选择要导出的报表') @@ -563,18 +566,7 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => { 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) + await downloadReportFile(targetRecord, format) Message.success('导出成功') } catch (error: any) { diff --git a/src/views/ops/pages/report/statistics/index.vue b/src/views/ops/pages/report/statistics/index.vue index 245ebd1..cae24da 100644 --- a/src/views/ops/pages/report/statistics/index.vue +++ b/src/views/ops/pages/report/statistics/index.vue @@ -53,6 +53,7 @@ @@ -70,6 +71,7 @@ 查看 CSV Excel + PDF @@ -248,12 +250,13 @@ import { fetchReportList, generateReport, fetchReportContent, - exportReport, ReportType, type ReportRecord, type StatisticsReportParams, } from '@/api/ops/report' import * as echarts from 'echarts' +import { downloadReportFile } from '../useReportExport' +import type { ReportExportFormat } from '@/api/ops/report' import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions' import { useReportMetricRegistryOptions } from '../useReportMetricRegistryOptions' 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 if (!targetRecord) { Message.warning('请选择要导出的报表') @@ -649,18 +652,7 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => { 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) + await downloadReportFile(targetRecord, format) Message.success('导出成功') } catch (error: any) { diff --git a/src/views/ops/pages/report/topn/index.vue b/src/views/ops/pages/report/topn/index.vue index f9ecc53..12662e0 100644 --- a/src/views/ops/pages/report/topn/index.vue +++ b/src/views/ops/pages/report/topn/index.vue @@ -53,6 +53,7 @@ @@ -70,6 +71,7 @@ 查看 CSV Excel + PDF @@ -97,11 +99,7 @@ - + - + - - - @@ -217,12 +187,7 @@
- + @@ -234,22 +199,15 @@ import { ref, reactive, computed, nextTick, watch } 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 { fetchReportList, generateReport, fetchReportContent, ReportType, type ReportRecord, type TopNReportParams } from '@/api/ops/report' import * as echarts from 'echarts' +import { downloadReportFile } from '../useReportExport' +import type { ReportExportFormat } from '@/api/ops/report' import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions' import { useReportMetricRegistryOptions } from '../useReportMetricRegistryOptions' import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow' -const { targetIdentityOptions, targetOptionsLoading, loadTargetIdentityOptions } = - useReportTargetIdentityOptions() +const { targetIdentityOptions, targetOptionsLoading, loadTargetIdentityOptions } = useReportTargetIdentityOptions() const { metricOptions, metricOptionsLoading, loadMetricRegistryOptions } = useReportMetricRegistryOptions() @@ -359,8 +317,6 @@ const generateForm = ref<{ n: number rank_aggregate: string order: string - metric_type: string - collector_identity: string title: string }>({ data_source: '', @@ -370,8 +326,6 @@ const generateForm = ref<{ n: 10, rank_aggregate: 'avg', order: 'desc', - metric_type: '', - collector_identity: '', title: '', }) @@ -386,7 +340,7 @@ watch( } loadTargetIdentityOptions(ds) loadMetricRegistryOptions(ds) - }, + } ) // 查看内容弹窗 @@ -509,8 +463,6 @@ const handleOpenGenerateModal = () => { n: 10, rank_aggregate: 'avg', order: 'desc', - metric_type: '', - collector_identity: '', title: '', } generateModalVisible.value = true @@ -567,16 +519,6 @@ const handleGenerate = async () => { 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, @@ -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 if (!targetRecord) { Message.warning('请选择要导出的报表') @@ -654,18 +596,7 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => { 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) + await downloadReportFile(targetRecord, format) Message.success('导出成功') } catch (error: any) { @@ -704,9 +635,7 @@ const renderChart = (ranking: any[]) => { }, yAxis: { type: 'category', - data: ranking - .map((item: any) => item.target ?? item.identity ?? item.target_identity ?? '') - .reverse(), + data: ranking.map((item: any) => item.target ?? item.identity ?? item.target_identity ?? '').reverse(), }, series: [ { diff --git a/src/views/ops/pages/report/traffic/index.vue b/src/views/ops/pages/report/traffic/index.vue index 5587e3e..6f9db49 100644 --- a/src/views/ops/pages/report/traffic/index.vue +++ b/src/views/ops/pages/report/traffic/index.vue @@ -53,6 +53,7 @@ @@ -70,6 +71,7 @@ 查看 CSV Excel + PDF @@ -323,12 +325,13 @@ import { fetchReportList, generateReport, fetchReportContent, - exportReport, ReportType, type ReportRecord, type TrafficReportParams, } from '@/api/ops/report' import * as echarts from 'echarts' +import { downloadReportFile } from '../useReportExport' +import type { ReportExportFormat } from '@/api/ops/report' import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow' import { useReportTopologyOptions } from '../useReportTopologyOptions' 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 if (!targetRecord) { Message.warning('请选择要导出的报表') @@ -830,18 +833,7 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => { 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) + await downloadReportFile(targetRecord, format) Message.success('导出成功') } catch (error: any) { diff --git a/src/views/ops/pages/report/useReportExport.ts b/src/views/ops/pages/report/useReportExport.ts new file mode 100644 index 0000000..bc6c934 --- /dev/null +++ b/src/views/ops/pages/report/useReportExport.ts @@ -0,0 +1,16 @@ +import { exportReport, type ReportExportFormat, type ReportRecord } from '@/api/ops/report' + +export async function downloadReportFile(record: ReportRecord, format: ReportExportFormat): Promise { + 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) + } +}