fix: 修复历史报表交互状态

This commit is contained in:
zxr
2026-08-04 01:02:17 +08:00
parent 913e9e63ad
commit 24dd0c17da
3 changed files with 68 additions and 145 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'
// ============ 通用响应类型 ============ // ============ 通用响应类型 ============
@@ -136,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
@@ -161,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
} }
/** 证据导出事件行 */ /** 证据导出事件行 */
@@ -239,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 }) =>
@@ -275,21 +271,14 @@ function exportBufferToBlob(ab: ArrayBuffer, format: ReportExportFormat, content
return new Blob([ab], { type: mime }) return new Blob([ab], { type: mime })
} }
if (format === 'pdf') { if (format === 'pdf') {
const okPdf = const okPdf = u8.length >= 5 && u8[0] === 0x25 && u8[1] === 0x50 && u8[2] === 0x44 && u8[3] === 0x46 && u8[4] === 0x2d
u8.length >= 5 &&
u8[0] === 0x25 &&
u8[1] === 0x50 &&
u8[2] === 0x44 &&
u8[3] === 0x46 &&
u8[4] === 0x2d
if (!okPdf) { if (!okPdf) {
throw new Error('导出失败:服务器返回的不是有效的 PDF。') throw new Error('导出失败:服务器返回的不是有效的 PDF。')
} }
const mime = contentType && /pdf/i.test(contentType) ? contentType : 'application/pdf' const mime = contentType && /pdf/i.test(contentType) ? contentType : 'application/pdf'
return new Blob([ab], { type: mime }) return new Blob([ab], { type: mime })
} }
const mime = const mime = contentType && /csv|text|plain/i.test(contentType) ? contentType : 'text/csv;charset=utf-8'
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 })
} }
@@ -308,11 +297,7 @@ function parseContentDispositionFileName(value: string | null): string | undefin
return value.match(/filename="([^"]+)"/i)?.[1] return value.match(/filename="([^"]+)"/i)?.[1]
} }
function normalizeReportFileName( function normalizeReportFileName(fileName: string | undefined, id: number, format: ReportExportFormat): string {
fileName: string | undefined,
id: number,
format: ReportExportFormat,
): string {
const defaultName = `report_${id}.${format}` const defaultName = `report_${id}.${format}`
if (!fileName) return defaultName if (!fileName) return defaultName
@@ -340,10 +325,7 @@ function normalizeReportFileName(
* 报表文件导出:使用 fetch + arrayBuffer绕过 axios 拦截器与 Blob 中间态, * 报表文件导出:使用 fetch + arrayBuffer绕过 axios 拦截器与 Blob 中间态,
* 避免网络面板里已是合法 xlsxPK…但落盘文件损坏的情况。 * 避免网络面板里已是合法 xlsxPK…但落盘文件损坏的情况。
*/ */
export const exportReport = async ( export const exportReport = async (id: number, format: ReportExportFormat = 'csv'): Promise<DownloadedReport> => {
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)
@@ -367,11 +349,7 @@ export const exportReport = async (
} }
return { return {
blob: exportBufferToBlob(ab, format, r.headers.get('content-type')), blob: exportBufferToBlob(ab, format, r.headers.get('content-type')),
fileName: normalizeReportFileName( fileName: normalizeReportFileName(parseContentDispositionFileName(r.headers.get('content-disposition')), id, format),
parseContentDispositionFileName(r.headers.get('content-disposition')),
id,
format,
),
} }
} }

View File

@@ -48,7 +48,7 @@
v-if="canDownloadPDF(record)" v-if="canDownloadPDF(record)"
type="text" type="text"
size="small" size="small"
:loading="downloadingId === record.id" :loading="downloadingIds.has(record.id)"
@click="handleDownloadPDF(record)" @click="handleDownloadPDF(record)"
> >
下载 PDF 下载 PDF
@@ -66,13 +66,7 @@ import { computed, onBeforeUnmount, reactive, ref } 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, ReportStatus, ReportType, type ReportListParams, type ReportRecord } from '@/api/ops/report'
fetchReportList,
ReportStatus,
ReportType,
type ReportListParams,
type ReportRecord,
} from '@/api/ops/report'
import { downloadReportFile } from '../useReportExport' import { downloadReportFile } from '../useReportExport'
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow' import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
@@ -150,7 +144,7 @@ const formItems = computed<FormItem[]>(() => [
]) ])
const loading = ref(false) const loading = ref(false)
const downloadingId = ref<number | null>(null) const downloadingIds = reactive(new Set<number>())
const tableData = ref<ReportRecord[]>([]) const tableData = ref<ReportRecord[]>([])
let latestRequestId = 0 let latestRequestId = 0
@@ -218,7 +212,7 @@ const tableColumns = computed(() => [
const canDownloadPDF = (record: ReportRecord) => const canDownloadPDF = (record: ReportRecord) =>
record.status === ReportStatus.SUCCESS && pdfReportTypes.has(record.report_type as ReportType) record.status === ReportStatus.SUCCESS && pdfReportTypes.has(record.report_type as ReportType)
const fetchList = async () => { const fetchList = async (): Promise<boolean> => {
const requestId = ++latestRequestId const requestId = ++latestRequestId
loading.value = true loading.value = true
@@ -228,44 +222,57 @@ const fetchList = async () => {
const timeRange = formModel.value.timeRange const timeRange = formModel.value.timeRange
const page = pagination.current const page = pagination.current
const size = pagination.pageSize 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 { try {
const params: ReportListParams = { while (true) {
page, const res = await fetchReportList(params)
size, if (requestId !== latestRequestId) return false
}
if (reportType) { if (res.code !== 0 || !res.details) {
params.report_type = reportType tableData.value = []
} pagination.total = 0
if (status) { Message.error(res.message || '获取报表列表失败')
params.status = status return false
} }
if (keyword) {
params.keyword = keyword
}
if (timeRange?.length === 2 && timeRange[0] && timeRange[1]) {
params.created_from = timeRange[0]
params.created_to = timeRange[1]
}
const res = await fetchReportList(params) const total = res.details.total || 0
if (requestId !== latestRequestId) return const maximumPage = Math.max(1, Math.ceil(total / size))
if ((params.page || 1) > maximumPage) {
pagination.current = maximumPage
params.page = maximumPage
continue
}
if (res.code === 0 && res.details) {
tableData.value = normalizeReportRows(res.details.data || []) tableData.value = normalizeReportRows(res.details.data || [])
pagination.total = res.details.total || 0 pagination.total = total
} else { return true
tableData.value = []
pagination.total = 0
Message.error(res.message || '获取报表列表失败')
} }
} catch (error: any) { } catch (error: any) {
if (requestId !== latestRequestId) return if (requestId !== latestRequestId) return false
tableData.value = [] tableData.value = []
pagination.total = 0 pagination.total = 0
console.error('获取报表列表失败:', error) console.error('获取报表列表失败:', error)
Message.error(error.message || '获取报表列表失败') Message.error(error.message || '获取报表列表失败')
return false
} finally { } finally {
if (requestId === latestRequestId) { if (requestId === latestRequestId) {
loading.value = false loading.value = false
@@ -296,9 +303,10 @@ const handleReset = () => {
fetchList() fetchList()
} }
const handleRefresh = () => { const handleRefresh = async () => {
fetchList() if (await fetchList()) {
Message.success('数据已刷新') Message.success('数据已刷新')
}
} }
const handlePageChange = (current: number) => { const handlePageChange = (current: number) => {
@@ -313,7 +321,7 @@ const handlePageSizeChange = (pageSize: number) => {
} }
const handleDownloadPDF = async (record: ReportRecord) => { const handleDownloadPDF = async (record: ReportRecord) => {
downloadingId.value = record.id downloadingIds.add(record.id)
try { try {
await downloadReportFile(record, 'pdf') await downloadReportFile(record, 'pdf')
Message.success('PDF 下载成功') Message.success('PDF 下载成功')
@@ -321,7 +329,7 @@ const handleDownloadPDF = async (record: ReportRecord) => {
console.error('PDF 下载失败:', error) console.error('PDF 下载失败:', error)
Message.error(error.message || 'PDF 下载失败') Message.error(error.message || 'PDF 下载失败')
} finally { } finally {
downloadingId.value = null downloadingIds.delete(record.id)
} }
} }

View File

@@ -99,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
@@ -118,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
@@ -130,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%"
@@ -181,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>
@@ -219,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>
@@ -236,14 +199,7 @@ 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,
ReportType,
type ReportRecord,
type TopNReportParams,
} from '@/api/ops/report'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { downloadReportFile } from '../useReportExport' import { downloadReportFile } from '../useReportExport'
import type { ReportExportFormat } from '@/api/ops/report' import type { ReportExportFormat } from '@/api/ops/report'
@@ -251,8 +207,7 @@ import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOption
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()
@@ -362,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: '',
@@ -373,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: '',
}) })
@@ -389,7 +340,7 @@ watch(
} }
loadTargetIdentityOptions(ds) loadTargetIdentityOptions(ds)
loadMetricRegistryOptions(ds) loadMetricRegistryOptions(ds)
}, }
) )
// 查看内容弹窗 // 查看内容弹窗
@@ -512,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
@@ -570,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,
@@ -696,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: [
{ {