Files
admin-web/src/modules/downloads/pages/DownloadCenterView.vue
T
2026-10-08 17:08:03 +08:00

412 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import {
downloadPlatformExportTask,
getPlatformExportTask,
listPlatformExportTasks,
retryPlatformExportTask,
type ExportTaskStatus,
type PlatformExportTask,
} from '@/modules/downloads/api/platformExportTasks'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
type DownloadFilters = QueryModel & {
exportType: string
status: ExportTaskStatus | ''
requestedRange: string[]
}
const exportTypeMeta: Record<string, { label: string; retryPermission: string }> = {
LAST_MILE_ORDER_LIST: { label: '尾程订单', retryPermission: 'platform:overseas-warehouse:last-mile-order:export' },
OVERSEAS_INVENTORY_BALANCE: { label: '库存余额', retryPermission: 'platform:overseas-warehouse:inventory:export' },
OVERSEAS_INVENTORY_LEDGER: { label: '进销存记录', retryPermission: 'platform:overseas-warehouse:inventory:export' },
OVERSEAS_QUOTE_LIST: { label: '海外报价', retryPermission: 'platform:overseas-master-data:quote:export' },
MONTHLY_EXCHANGE_RATE_LIST: { label: '月度汇率', retryPermission: 'platform:overseas-master-data:exchange-rate:export' },
VEHICLE_LIST: { label: '车辆', retryPermission: 'platform:overseas-master-data:vehicle:export' },
FLEET_QUOTE_LIST: { label: '车队报价', retryPermission: 'platform:fleet:quote:export' },
TRIP_OPERATIONS_LIST: { label: '出车记录', retryPermission: 'platform:fleet:operations:export' },
FINANCE_RECEIVABLE_STATISTICS: { label: '应收统计', retryPermission: 'platform:finance:receivable-statistics:export' },
LOADING_LIST_BUY_DECLARATION_ITEMS: { label: '买单报关明细', retryPermission: 'platform:firstleg:customs:manage' },
CUSTOMER_LIST: { label: '客户列表', retryPermission: 'platform:customer:export' },
DOMESTIC_WAREHOUSE_ORDER_LIST: { label: '国内仓订单列表', retryPermission: 'platform:order:export' },
FINANCE_CUSTOMER_ACCOUNT_LIST: { label: '客户账户', retryPermission: 'platform:finance:customer-account:export' },
FINANCE_CUSTOMER_ACCOUNT_LEDGER: { label: '客户账户流水', retryPermission: 'platform:finance:customer-account:export' },
FINANCE_FEE_SOURCE_LIST: { label: '客户应收应付费用来源', retryPermission: 'platform:finance:customer-arap:export' },
FINANCE_RECONCILIATION_STATEMENT_LIST: { label: '客户对账单', retryPermission: 'platform:finance:customer-statement:export' },
FINANCE_RECEIVABLE_SETTLEMENT_LIST: { label: '应收结算单列表', retryPermission: 'platform:finance:settlement-ar:export' },
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: { label: '应收结算单详情', retryPermission: 'platform:finance:settlement-ar:export' },
FINANCE_PAYABLE_SETTLEMENT_LIST: { label: '应付结算单列表', retryPermission: 'platform:finance:settlement-ap:export' },
FINANCE_PAYABLE_SETTLEMENT_DETAIL: { label: '应付结算单详情', retryPermission: 'platform:finance:settlement-ap:export' },
}
const access = useAccessPreviewStore()
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const downloadingId = ref('')
const retryingId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const syncWarning = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detailTask = ref<PlatformExportTask | null>(null)
const result = ref({ records: [] as PlatformExportTask[], total: 0, pages: 0 })
const filters = ref<DownloadFilters>({ exportType: '', status: '', requestedRange: [] })
let refreshTimer: number | undefined
let disposed = false
let loadRequestId = 0
let detailRequestId = 0
const queryFields: QueryField[] = [
{ key: 'exportType', type: 'select', label: '任务类型', placeholder: '全部任务类型', options: [
...Object.entries(exportTypeMeta).map(([value, meta]) => ({ label: meta.label, value })),
] },
{ key: 'status', type: 'select', label: '任务状态', placeholder: '全部任务状态', options: [
{ label: '等待处理', value: 'PENDING' }, { label: '处理中', value: 'RUNNING' },
{ label: '已完成', value: 'SUCCEEDED' }, { label: '失败', value: 'FAILED' },
{ label: '已过期', value: 'EXPIRED' },
] },
{ key: 'requestedRange', type: 'daterange', label: '申请日期', placeholder: '请选择申请日期' },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'taskName', prop: 'taskName', label: '任务名称', minWidth: 180, showOverflowTooltip: true },
{ key: 'type', label: '任务类型', width: 120, slotName: 'type' },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'progress', label: '数据量', width: 120, slotName: 'progress' },
{ key: 'format', prop: 'format', label: '格式', width: 80 },
{ key: 'requester', label: '申请人', minWidth: 130, slotName: 'requester' },
{ key: 'requestedAt', label: '申请时间', width: 170, slotName: 'requestedAt' },
{ key: 'completedAt', label: '完成时间', width: 170, slotName: 'completedAt' },
{ key: 'expiresAt', label: '过期时间', width: 170, slotName: 'expiresAt' },
]
const hasActiveTasks = computed(() => result.value.records.some((task) => ['PENDING', 'RUNNING'].includes(task.status)))
function clearRefreshTimer() {
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
refreshTimer = undefined
}
function scheduleRefresh() {
clearRefreshTimer()
if (!disposed && hasActiveTasks.value) refreshTimer = window.setTimeout(() => { void loadTasks(true) }, 5000)
}
async function loadTasks(background = false) {
const requestId = ++loadRequestId
clearRefreshTimer()
if (!background) loading.value = true
if (!background) loadError.value = null
try {
const range = filters.value.requestedRange
const response = await listPlatformExportTasks({
page: page.value,
size: pageSize.value,
exportType: filters.value.exportType || undefined,
status: filters.value.status || undefined,
requestedFrom: range?.[0] || undefined,
requestedTo: range?.[1] || undefined,
})
if (disposed || requestId !== loadRequestId) return
result.value = { records: response.records, total: response.total, pages: response.pages }
loadError.value = null
syncWarning.value = null
if (detailOpen.value && detailTask.value) {
const freshDetail = response.records.find((task) => task.id === detailTask.value?.id)
if (freshDetail) detailTask.value = freshDetail
}
} catch (error) {
if (disposed || requestId !== loadRequestId) return
const details = getApiErrorDetails(error, '下载任务加载失败')
if (background && result.value.records.length > 0) syncWarning.value = details
else loadError.value = details
} finally {
if (!disposed && requestId === loadRequestId) {
if (!background) loading.value = false
scheduleRefresh()
}
}
}
function applyFilters() {
if (page.value === 1) void loadTasks()
else page.value = 1
}
async function openDetail(task: PlatformExportTask) {
if (!task?.id) return
const requestId = ++detailRequestId
detailTask.value = task
detailError.value = null
detailOpen.value = true
detailLoading.value = true
try {
const response = await getPlatformExportTask(task.id)
if (disposed || requestId !== detailRequestId) return
detailTask.value = response
} catch (error) {
if (disposed || requestId !== detailRequestId) return
detailError.value = getApiErrorDetails(error, '下载任务详情加载失败')
} finally {
if (!disposed && requestId === detailRequestId) detailLoading.value = false
}
}
function closeDetail() {
detailRequestId += 1
detailTask.value = null
detailError.value = null
detailLoading.value = false
}
function downloadDetailTask() {
if (detailTask.value?.status === 'SUCCEEDED') void downloadTask(detailTask.value)
}
async function downloadTask(task: PlatformExportTask) {
downloadingId.value = task.id
try {
const blob = await downloadPlatformExportTask(task.id)
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
try {
anchor.href = url
anchor.download = safeFileName(task.fileName || `${task.taskName}.xlsx`)
document.body.append(anchor)
anchor.click()
} finally {
anchor.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
} catch (error) {
const details = getApiErrorDetails(error, '文件下载失败,请刷新任务状态后重试')
const trace = [details.code, details.requestId && `requestId: ${details.requestId}`, details.traceId && `traceId: ${details.traceId}`]
.filter(Boolean)
.join(',')
ElMessage.error(trace ? `${details.message}(${trace})` : details.message)
} finally {
downloadingId.value = ''
}
}
async function retryTask(task: PlatformExportTask) {
retryingId.value = task.id
try {
await retryPlatformExportTask(task.id)
ElMessage.success('导出任务已重新提交')
await loadTasks()
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '任务重试失败'))
} finally {
retryingId.value = ''
}
}
function safeFileName(value: string) {
const safe = value.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim()
return safe || '导出文件.xlsx'
}
function typeLabel(type: string) { return exportTypeMeta[type]?.label || type }
function statusMeta(status: ExportTaskStatus) {
const values = {
PENDING: { label: '等待处理', type: 'info' as const },
RUNNING: { label: '处理中', type: 'warning' as const },
SUCCEEDED: { label: '已完成', type: 'success' as const },
FAILED: { label: '失败', type: 'danger' as const },
EXPIRED: { label: '已过期', type: 'info' as const },
}
return values[status] ?? { label: status || '未知状态', type: 'info' as const }
}
function taskStatusMeta(task: PlatformExportTask) {
if (task.status === 'PENDING' && task.attemptCount > 0) {
return { label: '等待重试', type: 'warning' as const }
}
return statusMeta(task.status)
}
function formatDate(value: string | null) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function formatSize(bytes: number | null) {
if (bytes === null) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function canRetry(task: PlatformExportTask) {
const permission = exportTypeMeta[task.exportType]?.retryPermission
const retryableStatus = task.status === 'FAILED' || (task.status === 'PENDING' && task.attemptCount > 0)
return retryableStatus && permission !== undefined && access.can(permission)
}
function retryLabel(task: PlatformExportTask) {
return task.status === 'PENDING' ? '立即重试' : '重试'
}
watch([page, pageSize], () => { void loadTasks() })
onMounted(loadTasks)
onBeforeUnmount(() => {
disposed = true
loadRequestId += 1
detailRequestId += 1
clearRefreshTimer()
})
</script>
<template>
<AdminPage class="admin-density">
<div class="business-page download-center-page">
<ListPageLayout ariaLabel="下载任务列表">
<section :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="downloads-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无符合筛选条件的下载任务" aria-label="下载任务列表">
<template #toolbar-start><h1>下载中心</h1></template>
<template #filters>
<QueryToolbar v-model="filters" ariaLabel="下载任务筛选" :fields="queryFields" preference-key="downloads-query" @search="applyFilters" />
</template>
<template #notices><template v-if="loadError">
<section class="surface-panel download-error" role="alert">
<div><strong>下载任务暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div>
<el-button type="primary" :loading="loading" @click="loadTasks()">重新加载</el-button>
</section>
</template></template>
<template #before-results><div v-if="syncWarning" class="download-sync-warning" role="status">
<span>自动刷新暂时失败,正在保留当前任务并继续重试:{{ syncWarning.message }}</span>
<ApiErrorMeta :details="syncWarning" />
</div></template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个任务</span></template>
<template #actions="{ row }"><div class="table-actions density-actions row-actions admin-text-actions">
<el-button data-tone="neutral" class="density-action" v-if="(row as PlatformExportTask)?.id" @click="openDetail(row as PlatformExportTask)">详情</el-button>
<el-button data-tone="neutral" class="density-action" v-if="(row as PlatformExportTask).status === 'SUCCEEDED'" :loading="downloadingId === (row as PlatformExportTask).id" @click="downloadTask(row as PlatformExportTask)">下载</el-button>
<el-button data-tone="neutral" class="density-action" v-if="canRetry(row as PlatformExportTask)" :loading="retryingId === (row as PlatformExportTask).id" @click="retryTask(row as PlatformExportTask)">{{ retryLabel(row as PlatformExportTask) }}</el-button>
</div></template>
<template #type="{ row }">{{ typeLabel((row as PlatformExportTask).exportType) }}</template>
<template #status="{ row }"><el-tag :type="taskStatusMeta(row as PlatformExportTask).type" effect="light">{{ taskStatusMeta(row as PlatformExportTask).label }}</el-tag></template>
<template #progress="{ row }"><span>{{ (row as PlatformExportTask).processedRows }}<template v-if="(row as PlatformExportTask).totalRows !== null"> / {{ (row as PlatformExportTask).totalRows }}</template></span><small v-if="(row as PlatformExportTask).sizeBytes !== null">{{ formatSize((row as PlatformExportTask).sizeBytes) }}</small></template>
<template #requester="{ row }">{{ (row as PlatformExportTask).requestedUsername || (row as PlatformExportTask).requestedBy }}</template>
<template #requestedAt="{ row }">{{ formatDate((row as PlatformExportTask).requestedAt) }}</template>
<template #completedAt="{ row }">{{ formatDate((row as PlatformExportTask).completedAt) }}</template>
<template #expiresAt="{ row }">{{ formatDate((row as PlatformExportTask).expiresAt) }}</template>
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog"
v-model="detailOpen"
:title="detailTask ? `任务详情 · ${detailTask.taskNo}` : '任务详情'"
width="min(720px, calc(100vw - 28px))"
:loading="detailLoading"
:show-confirm="false"
destroy-on-close
@closed="closeDetail"
>
<section v-if="detailError" class="detail-load-error" role="alert">
<strong>任务详情暂时无法加载</strong>
<p>{{ detailError.message }}</p>
<ApiErrorMeta :details="detailError" />
<el-button type="primary" @click="detailTask && openDetail(detailTask)">重新加载</el-button>
</section>
<div v-else-if="detailTask" class="task-detail">
<div class="task-detail-summary">
<div>
<h3>{{ detailTask.taskName }}</h3>
<p>{{ typeLabel(detailTask.exportType) }} · {{ detailTask.format }}</p>
</div>
<el-tag :type="taskStatusMeta(detailTask).type" effect="light">{{ taskStatusMeta(detailTask).label }}</el-tag>
</div>
<dl class="task-detail-grid density-fields">
<div><dt>任务编号</dt><dd>{{ detailTask.taskNo }}</dd></div>
<div><dt>申请人</dt><dd>{{ detailTask.requestedUsername || detailTask.requestedBy }}</dd></div>
<div><dt>申请时间</dt><dd>{{ formatDate(detailTask.requestedAt) }}</dd></div>
<div><dt>开始时间</dt><dd>{{ formatDate(detailTask.startedAt) }}</dd></div>
<div><dt>完成时间</dt><dd>{{ formatDate(detailTask.completedAt) }}</dd></div>
<div><dt>执行次数</dt><dd>{{ detailTask.attemptCount }} / {{ detailTask.maxAttempts }}</dd></div>
<div><dt>下次重试</dt><dd>{{ detailTask.status === 'PENDING' && detailTask.attemptCount > 0 ? formatDate(detailTask.nextRetryAt) : '—' }}</dd></div>
<div><dt>过期时间</dt><dd>{{ formatDate(detailTask.expiresAt) }}</dd></div>
<div><dt>处理数据</dt><dd>{{ detailTask.processedRows }}<template v-if="detailTask.totalRows !== null"> / {{ detailTask.totalRows }}</template> 行</dd></div>
<div><dt>文件大小</dt><dd>{{ formatSize(detailTask.sizeBytes) || '—' }}</dd></div>
<div class="is-wide"><dt>文件名称</dt><dd>{{ detailTask.fileName || '—' }}</dd></div>
</dl>
<section v-if="detailTask.failureMessage" class="task-failure" role="status">
<strong>失败原因</strong>
<p>{{ detailTask.failureMessage }}</p>
</section>
</div>
<template #footer="{ close }">
<div class="app-dialog__footer">
<el-button @click="close">关闭</el-button>
<el-button
v-if="detailTask && canRetry(detailTask)"
:loading="retryingId === detailTask.id"
@click="retryTask(detailTask)"
>{{ retryLabel(detailTask) }}</el-button>
<el-button
type="primary"
:loading="downloadingId === detailTask?.id"
:disabled="detailTask?.status !== 'SUCCEEDED'"
@click="downloadDetailTask"
>下载文件</el-button>
</div>
</template>
</AppDialog>
</div>
</AdminPage>
</template>
<style scoped>
.task-detail-grid.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.task-detail-grid .is-wide { grid-column: 1 / -1; }
.download-center-page { min-width: 0; }
.download-error { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6); }
.download-error { padding: var(--admin-space-6); }
.download-error strong { color: var(--cc-danger); font-size: var(--admin-font-body); }
.download-error p { margin: 5px 0; color: var(--cc-text); font-size: var(--admin-font-body); }
.download-sync-warning { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid #fedf89; background: #fffaeb; color: #93370d; font-size: var(--admin-font-caption); }
.failure-cell { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.failure-message { color: var(--cc-danger); }
.detail-load-error { display: flex; min-height: 220px; align-items: flex-start; justify-content: center; flex-direction: column; }
.detail-load-error strong { color: var(--cc-danger); font-size: var(--admin-font-section); }
.detail-load-error p { margin: var(--admin-space-2) 0; color: var(--cc-text); font-size: var(--admin-font-body); }
.detail-load-error :deep(.el-button) { margin-top: var(--admin-space-6); }
.task-detail { min-width: 0; }
.task-detail-summary { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-6); padding-bottom: var(--admin-space-6); border-bottom: 1px solid var(--cc-border); }
.task-detail-summary h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-section); }
.task-detail-summary p { margin: var(--admin-space-2) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.task-failure { margin-top: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid color-mix(in srgb, var(--cc-danger) 24%, var(--cc-border)); border-radius: var(--admin-radius); background: color-mix(in srgb, var(--cc-danger) 5%, #fff); }
.task-failure strong { color: var(--cc-danger); font-size: var(--admin-font-body); }
.task-failure p { margin: var(--admin-space-2) 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-body); line-height: 1.6; }
small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: var(--admin-font-caption); }
@media (max-width: 720px) {
.download-error, .download-sync-warning { align-items: stretch; flex-direction: column; }
.table-actions :deep(.el-button) { min-height: 44px; }
.table-actions :deep(.el-button) { padding-right: 8px; padding-left: 8px; }
}
@media (max-width: 480px) {
.task-detail-summary { flex-direction: column; }
.task-detail-grid { grid-template-columns: 1fr; }
.task-detail-grid > div:nth-child(odd):not(.is-wide) { border-right: 0; }
.task-detail-grid .is-wide { grid-column: 1; }
}
</style>