278 lines
8.3 KiB
Vue
278 lines
8.3 KiB
Vue
<template>
|
|
<ArtTableFullScreen>
|
|
<div class="operations-report-page" id="table-full-screen">
|
|
<!-- 搜索栏 -->
|
|
<ArtSearchBar
|
|
v-model:filter="searchForm"
|
|
:items="searchFormItems"
|
|
:show-expand="false"
|
|
@reset="handleReset"
|
|
@search="handleSearch"
|
|
></ArtSearchBar>
|
|
|
|
<ElCard shadow="never" class="art-table-card">
|
|
<!-- 表格头部 -->
|
|
<ArtTableHeader
|
|
:columnList="columnOptions"
|
|
v-model:columns="columnChecks"
|
|
@refresh="handleRefresh"
|
|
>
|
|
<template #left>
|
|
<ElButton type="primary" :loading="exporting" @click="handleExport">导出</ElButton>
|
|
<span v-if="snapshotText" class="report-snapshot">{{ snapshotText }}</span>
|
|
<ElTag v-if="exportTask" :type="exportStatusType" class="report-export-tag">
|
|
{{ exportTask.status_name || '处理中' }}
|
|
</ElTag>
|
|
<ElButton v-if="canDownload" type="success" link @click="downloadExport">
|
|
下载
|
|
</ElButton>
|
|
</template>
|
|
</ArtTableHeader>
|
|
|
|
<!-- 表格 -->
|
|
<ArtTable :loading="loading" :data="tableData" :pagination="false" :marginTop="10">
|
|
<template #default>
|
|
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
|
</template>
|
|
</ArtTable>
|
|
</ElCard>
|
|
</div>
|
|
</ArtTableFullScreen>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import { ExportTaskService, OperationsReportsService } from '@/api/modules'
|
|
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
|
|
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
|
import { ExportTaskStatus, type ExportTaskDetail } from '@/types/api'
|
|
import type { ActivationSummaryData, ActivationSummaryItem } from '@/types/api'
|
|
import type { SearchFormItem } from '@/types'
|
|
import { ACTIVATION_GROUP_OPTIONS, ACTIVATION_METRIC_COLUMNS, formatMetric } from '../constants'
|
|
|
|
defineOptions({ name: 'OperationsActivationReport' })
|
|
|
|
const pad = (value: number) => String(value).padStart(2, '0')
|
|
|
|
const formatDateTimeValue = (date: Date) => {
|
|
const offset = -date.getTimezoneOffset()
|
|
const sign = offset >= 0 ? '+' : '-'
|
|
const abs = Math.abs(offset)
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
|
date.getHours()
|
|
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${pad(
|
|
Math.floor(abs / 60)
|
|
)}:${pad(abs % 60)}`
|
|
}
|
|
|
|
const getDefaultRange = () => {
|
|
const end = new Date()
|
|
const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000)
|
|
start.setHours(0, 0, 0, 0)
|
|
return [formatDateTimeValue(start), formatDateTimeValue(end)]
|
|
}
|
|
|
|
const searchForm = reactive({
|
|
dateRange: getDefaultRange() as string[],
|
|
group_by: ''
|
|
})
|
|
|
|
const loading = ref(false)
|
|
const exporting = ref(false)
|
|
const summary = ref<ActivationSummaryData | null>(null)
|
|
|
|
const searchFormItems: SearchFormItem[] = [
|
|
{
|
|
label: '统计时间',
|
|
prop: 'dateRange',
|
|
type: 'datetimerange',
|
|
config: {
|
|
type: 'datetimerange',
|
|
rangeSeparator: '至',
|
|
startPlaceholder: '开始时间',
|
|
endPlaceholder: '结束时间',
|
|
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
|
|
}
|
|
},
|
|
{
|
|
label: '分组维度',
|
|
prop: 'group_by',
|
|
type: 'select',
|
|
config: { clearable: true, placeholder: '请选择分组维度' },
|
|
options: () => ACTIVATION_GROUP_OPTIONS
|
|
}
|
|
]
|
|
|
|
const columnOptions = [
|
|
{ label: '分组', prop: 'group_name' },
|
|
...ACTIVATION_METRIC_COLUMNS.map((col) => ({ label: col.label, prop: col.key }))
|
|
]
|
|
|
|
const { columnChecks, columns } = useCheckedColumns(() => [
|
|
{
|
|
prop: 'group_name',
|
|
label: '分组',
|
|
minWidth: 160,
|
|
fixed: 'left',
|
|
showOverflowTooltip: true,
|
|
formatter: (row: ActivationSummaryItem) => row.group_name || row.group_key || '-'
|
|
},
|
|
...ACTIVATION_METRIC_COLUMNS.map((col) => ({
|
|
prop: col.key,
|
|
label: col.label,
|
|
minWidth: col.minWidth,
|
|
align: 'right' as const,
|
|
formatter: (row: ActivationSummaryItem) => formatMetric(row[col.key], col)
|
|
}))
|
|
])
|
|
|
|
const snapshotText = computed(() => {
|
|
const dates = summary.value?.snapshot_dates
|
|
return dates?.length ? `数据快照:${dates.join('、')}` : ''
|
|
})
|
|
|
|
const tableData = computed(() => {
|
|
const data = summary.value
|
|
if (!data) return []
|
|
const rows = [...(data.items || [])]
|
|
if (data.totals) {
|
|
rows.push({ ...data.totals, group_name: '合计' })
|
|
}
|
|
return rows
|
|
})
|
|
|
|
const buildQuery = () => ({
|
|
start_time: searchForm.dateRange?.[0] || undefined,
|
|
end_time: searchForm.dateRange?.[1] || undefined,
|
|
group_by: searchForm.group_by || undefined
|
|
})
|
|
|
|
const getTableData = async () => {
|
|
if (!searchForm.dateRange?.[0] || !searchForm.dateRange?.[1]) {
|
|
ElMessage.warning('请选择统计时间')
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
try {
|
|
const summaryRes = await OperationsReportsService.getActivationSummary(buildQuery())
|
|
|
|
if (summaryRes.code === 0 && summaryRes.data) {
|
|
summary.value = summaryRes.data
|
|
} else {
|
|
summary.value = null
|
|
ElMessage.error(summaryRes.msg || '获取设备激活汇总失败')
|
|
}
|
|
} catch (error) {
|
|
console.error('获取设备激活报表失败:', error)
|
|
summary.value = null
|
|
ElMessage.error('获取设备激活报表失败')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const handleSearch = () => {
|
|
getTableData()
|
|
}
|
|
|
|
const handleReset = () => {
|
|
searchForm.dateRange = getDefaultRange()
|
|
searchForm.group_by = ''
|
|
getTableData()
|
|
}
|
|
|
|
const handleRefresh = () => {
|
|
getTableData()
|
|
}
|
|
|
|
const exportTask = computed(() => exportPolling.task.value)
|
|
const canDownload = computed(
|
|
() =>
|
|
exportTask.value?.status === ExportTaskStatus.COMPLETED && !!exportTask.value?.download_url
|
|
)
|
|
const exportStatusType = computed(() => {
|
|
const status = exportTask.value?.status
|
|
if (status === ExportTaskStatus.COMPLETED) return 'success'
|
|
if (status === ExportTaskStatus.FAILED) return 'danger'
|
|
if (status === ExportTaskStatus.CANCELED) return 'info'
|
|
return 'warning'
|
|
})
|
|
|
|
const exportPolling = useAsyncTaskPolling<ExportTaskDetail>({
|
|
storageKey: 'operations-report-active:activation',
|
|
fetchTask: async (taskId) => {
|
|
const res = await ExportTaskService.getExportTaskDetail(taskId)
|
|
if (res.code === 403) {
|
|
const error = new Error('暂无权限查看导出任务') as Error & { status?: number }
|
|
error.status = 403
|
|
throw error
|
|
}
|
|
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取导出任务详情失败')
|
|
return res.data
|
|
},
|
|
isForbidden: (error: any) => error?.status === 403 || error?.response?.status === 403
|
|
})
|
|
|
|
const handleExport = async () => {
|
|
if (!searchForm.dateRange?.[0] || !searchForm.dateRange?.[1]) {
|
|
ElMessage.warning('请选择统计时间')
|
|
return
|
|
}
|
|
|
|
exporting.value = true
|
|
try {
|
|
const res = await OperationsReportsService.exportActivationSummary({
|
|
...buildQuery(),
|
|
format: 'xlsx'
|
|
})
|
|
if (res.code !== 0 || !res.data) {
|
|
ElMessage.error(res.msg || '导出失败')
|
|
return
|
|
}
|
|
ElMessage.success(res.data.message || '导出任务已创建,请稍候')
|
|
await exportPolling.start(res.data.task_id)
|
|
} catch (error) {
|
|
console.error('导出设备激活汇总失败:', error)
|
|
ElMessage.error('导出设备激活汇总失败')
|
|
} finally {
|
|
exporting.value = false
|
|
}
|
|
}
|
|
|
|
const downloadExport = () => {
|
|
const url = exportTask.value?.download_url
|
|
if (!url) {
|
|
ElMessage.warning('当前任务暂无可用下载地址')
|
|
return
|
|
}
|
|
window.open(url, '_blank')
|
|
}
|
|
|
|
onMounted(() => {
|
|
getTableData()
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.operations-report-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
height: 100%;
|
|
overflow: auto;
|
|
}
|
|
|
|
.report-snapshot {
|
|
font-size: 12px;
|
|
font-weight: 400;
|
|
margin-left: 20px;
|
|
color: var(--el-text-color-secondary);
|
|
}
|
|
|
|
.report-export-tag {
|
|
margin-left: 12px;
|
|
}
|
|
</style>
|