2026-08-03 18:15:41 +08:00
< script setup lang = "ts" >
import { computed , onBeforeUnmount , onMounted , ref , watch } from 'vue'
import { Download , RefreshCw , RotateCcw } from '@lucide/vue'
import { ElMessage } from 'element-plus'
2026-08-03 18:49:18 +08:00
import { getApiErrorDetails , getApiErrorMessage , type ApiErrorDetails } from '@/shared/api/http'
2026-08-03 18:15:41 +08:00
import {
downloadPlatformExportTask ,
listPlatformExportTasks ,
retryPlatformExportTask ,
type ExportTaskStatus ,
type PlatformExportTask ,
2026-08-03 18:49:18 +08:00
} from '@/modules/downloads/api/platformExportTasks'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-03 18:15:41 +08:00
type DownloadFilters = QueryModel & {
exportType : string
status : ExportTaskStatus | ''
requestedRange : string []
}
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 result = ref ({ records : [] as PlatformExportTask [], total : 0 , pages : 0 })
const filters = ref < DownloadFilters >({ exportType : '' , status : '' , requestedRange : [] })
let refreshTimer : number | undefined
2026-08-03 18:49:18 +08:00
let disposed = false
2026-08-03 18:15:41 +08:00
const queryFields : QueryField [] = [
{ key : 'exportType' , type : 'select' , placeholder : '全部任务类型' , options : [
{ label : '客户列表' , value : 'CUSTOMER_LIST' },
] },
{ key : 'status' , type : 'select' , placeholder : '全部任务状态' , options : [
{ label : '等待处理' , value : 'PENDING' }, { label : '处理中' , value : 'RUNNING' },
{ label : '已完成' , value : 'SUCCEEDED' }, { label : '失败' , value : 'FAILED' },
{ label : '已过期' , value : 'EXPIRED' },
] },
{ key : 'requestedRange' , type : 'daterange' , placeholder : '请选择申请日期' },
]
const columns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 150 , fixed : 'left' , slotName : 'actions' },
{ 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' },
{ 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' },
{ key : 'failure' , label : '失败原因' , minWidth : 240 , slotName : 'failure' },
]
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 ()
2026-08-03 18:49:18 +08:00
if ( ! disposed && hasActiveTasks . value && ! loadError . value ) refreshTimer = window . setTimeout (() => { void loadTasks ( true ) }, 5000 )
2026-08-03 18:15:41 +08:00
}
async function loadTasks ( background = false ) {
clearRefreshTimer ()
if ( ! background ) loading . value = true
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 ,
})
result . value = { records : response . records , total : response . total , pages : response . pages }
} catch ( error ) {
loadError . value = getApiErrorDetails ( error , '下载任务加载失败' )
} finally {
if ( ! background ) loading . value = false
scheduleRefresh ()
}
}
function applyFilters () {
if ( page . value === 1 ) void loadTasks ()
else page . value = 1
}
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' )
anchor . href = url
anchor . download = safeFileName ( task . fileName || ` ${ task . taskName } .xlsx` )
document . body . append ( anchor )
anchor . click ()
anchor . remove ()
URL . revokeObjectURL ( url )
} catch ( error ) {
ElMessage . error ( getApiErrorMessage ( error , '文件下载失败,请刷新任务状态后重试' ))
} 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 type === 'CUSTOMER_LIST' ? '客户列表' : 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 ]
}
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 ) {
return task . status === 'FAILED' && task . exportType === 'CUSTOMER_LIST' && access . can ( 'platform:customer:export' )
}
watch ([ page , pageSize ], () => { void loadTasks () })
onMounted ( loadTasks )
2026-08-03 18:49:18 +08:00
onBeforeUnmount (() => {
disposed = true
clearRefreshTimer ()
})
2026-08-03 18:15:41 +08:00
</ script >
< template >
< div class = "business-page download-center-page" >
< QueryToolbar v-model = "filters" class="surface-panel" ariaLabel="下载任务筛选" :fields="queryFields" @search="applyFilters" />
< section v-if = "loadError" 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 >
< section v-else class = "surface-panel table-panel" :aria-busy = "loading" >
< div class = "download-table-header" >
< div >< h2 > 下载任务 </ h2 >< span > 共 {{ result . total }} 个任务 </ span ></ div >
< el-button :loading = "loading" @click ="loadTasks()" >< RefreshCw :size = "16" /> 刷新 </ el-button >
</ div >
< DataTable v -model:page = "page" v -model:page-size = "pageSize" :data = "result.records" :columns = "columns" :loading = "loading" :total = "result.total" row -key =" id " empty -text =" 暂无符合筛选条件的下载任务 " aria -label =" 下载任务列表 " >
< template # actions = "{ row }" >< div class = "table-actions" >
< el-button v-if = "(row as PlatformExportTask).status === 'SUCCEEDED'" link type="primary" :loading="downloadingId === (row as PlatformExportTask).id" @click="downloadTask(row as PlatformExportTask)"><Download :size="15" /> 下载 </ el-button >
< el-button v-if = "canRetry(row as PlatformExportTask)" link type="primary" :loading="retryingId === (row as PlatformExportTask).id" @click="retryTask(row as PlatformExportTask)"><RotateCcw :size="15" /> 重试 </ el-button >
2026-08-03 18:49:18 +08:00
< span v-if = "(row as PlatformExportTask).status !== 'SUCCEEDED' && !canRetry(row as PlatformExportTask)" class="action-placeholder" > — </ span >
2026-08-03 18:15:41 +08:00
</ div ></ template >
< template # type = "{ row }" >{{ typeLabel (( row as PlatformExportTask ). exportType ) }}</ template >
< template # status = "{ row }" >< el-tag : type = "statusMeta((row as PlatformExportTask).status).type" effect = "light" >{{ statusMeta (( row as PlatformExportTask ). status ). 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 >
2026-08-03 18:49:18 +08:00
< template # failure = "{ row }" >< span class = "failure-cell" : class = "{ 'failure-message': Boolean((row as PlatformExportTask).failureMessage) }" >{{ ( row as PlatformExportTask ). failureMessage || '—' }}</ span ></ template >
2026-08-03 18:15:41 +08:00
</ DataTable >
</ section >
</ div >
</ template >
< style scoped >
. download - center - page { min - width : 0 ; }
. download - error , . download - table - header { display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px ; }
. download - error { padding : 18 px ; }
. download - error strong { color : var ( -- cc - danger ); font - size : 14 px ; }
. download - error p { margin : 5 px 0 ; color : var ( -- cc - text ); font - size : 13 px ; }
. download - table - header { min - height : 64 px ; padding : 12 px 18 px ; border - bottom : 1 px solid var ( -- cc - border ); }
. download - table - header h2 { margin : 0 ; color : var ( -- cc - text ); font - size : 16 px ; }
. download - table - header span { display : block ; margin - top : 3 px ; color : var ( -- cc - muted ); font - size : 12 px ; }
. table - actions { display : flex ; min - height : 32 px ; align - items : center ; gap : 8 px ; }
. table - actions : deep (. el - button + . el - button ) { margin - left : 0 ; }
. action - placeholder { color : var ( -- cc - muted ); }
2026-08-03 18:49:18 +08:00
. failure - cell { display : block ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. failure - message { color : var ( -- cc - danger ); }
2026-08-03 18:15:41 +08:00
small { display : block ; margin - top : 3 px ; color : var ( -- cc - muted ); font - size : 11 px ; }
@ media ( max - width : 720 px ) {
. download - error , . download - table - header { align - items : stretch ; flex - direction : column ; }
. download - table - header : deep (. el - button ) { min - height : 40 px ; }
}
</ style >