395 lines
20 KiB
Vue
395 lines
20 KiB
Vue
<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 LoadErrorState from '@/shared/components/LoadErrorState.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' },
|
||
SELFOP_INVENTORY_TURNOVER: { label: '库存周转', retryPermission: 'platform:self-operated-analytics: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 || '—' }}</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?.taskNo?.trim() ? `任务详情 · ${detailTask.taskNo.trim()}` : '任务详情'"
|
||
width="min(720px, calc(100vw - 28px))"
|
||
fullscreenable
|
||
:loading="detailLoading"
|
||
:show-confirm="false"
|
||
destroy-on-close
|
||
@closed="closeDetail"
|
||
>
|
||
<LoadErrorState v-if="detailError" compact title="任务详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="detailTask && openDetail(detailTask)">
|
||
<template #details><ApiErrorMeta :details="detailError" /></template>
|
||
</LoadErrorState>
|
||
<div v-else-if="detailTask" class="density-dialog-sections">
|
||
<section class="density-dialog-section" data-tone="summary" aria-label="下载任务资料">
|
||
<h3 class="density-dialog-section__title task-summary-heading"><span>{{ detailTask.taskName || '—' }}</span><el-tag :type="taskStatusMeta(detailTask).type" effect="light">{{ taskStatusMeta(detailTask).label }}</el-tag></h3>
|
||
<p class="density-dialog-note">{{ typeLabel(detailTask.exportType) }} · {{ detailTask.format }}</p>
|
||
<dl class="task-detail-grid density-fields">
|
||
<div><dt>任务编号</dt><dd>{{ detailTask.taskNo || '—' }}</dd></div>
|
||
<div><dt>申请人</dt><dd>{{ detailTask.requestedUsername || '—' }}</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>
|
||
<el-alert v-if="detailTask.failureMessage" class="task-failure" role="status" title="失败原因" :description="detailTask.failureMessage" type="error" :closable="false" show-icon />
|
||
</div>
|
||
<template #footer="{ close }">
|
||
<div class="density-actions density-actions--footer">
|
||
<el-button @click="close">关闭</el-button>
|
||
<el-button
|
||
v-if="!detailLoading && !detailError && detailTask && canRetry(detailTask)"
|
||
:loading="retryingId === detailTask.id"
|
||
@click="retryTask(detailTask)"
|
||
>{{ retryLabel(detailTask) }}</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:loading="downloadingId === detailTask?.id"
|
||
:disabled="detailLoading || Boolean(detailError) || 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; }
|
||
.task-summary-heading { justify-content: space-between; }
|
||
.task-summary-heading > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||
.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); }
|
||
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-grid { grid-template-columns: 1fr; }
|
||
.task-detail-grid .is-wide { grid-column: 1; }
|
||
}
|
||
</style>
|