This commit is contained in:
2026-08-04 09:05:19 +08:00
parent fa922ca96f
commit 815d4e8daf
21 changed files with 994 additions and 128 deletions
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { ApiResponseError, http } from '@/shared/api/http'
import {
createPlatformExportTask,
downloadPlatformExportTask,
getPlatformExportTask,
listPlatformExportTasks,
retryPlatformExportTask,
} from './platformExportTasks'
@@ -24,18 +25,97 @@ describe('platformExportTasks', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/files/export-tasks', payload)
})
it('lists, downloads, and retries export tasks through File service', async () => {
it('lists, reads, downloads, and retries export tasks through File service', async () => {
const page = { records: [], page: '1', size: '20', total: '0', pages: '0' }
const task = { id: '9', status: 'SUCCEEDED' }
const blob = new Blob(['xlsx'])
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', message: 'success', data: page }).mockResolvedValueOnce(blob)
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', message: 'success', data: page })
.mockResolvedValueOnce({ code: '200', message: 'success', data: task })
.mockResolvedValueOnce(blob)
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '9', status: 'PENDING' } })
await expect(listPlatformExportTasks({ page: 1, size: 20, status: 'FAILED' })).resolves.toEqual({ records: [], page: 1, size: 20, total: 0, pages: 0 })
await expect(getPlatformExportTask('9')).resolves.toEqual(task)
await expect(downloadPlatformExportTask('9')).resolves.toBe(blob)
await expect(retryPlatformExportTask('9')).resolves.toMatchObject({ id: '9', status: 'PENDING' })
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/files/export-tasks', { params: { page: 1, size: 20, status: 'FAILED' } })
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/files/export-tasks/9/download', { responseType: 'blob' })
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/files/export-tasks/9')
expect(http.get).toHaveBeenNthCalledWith(3, '/platform/v1/files/export-tasks/9/download', { responseType: 'blob', timeout: 600_000 })
expect(http.post).toHaveBeenCalledWith('/platform/v1/files/export-tasks/9/retry', {})
})
it('parses a Blob error envelope and preserves backend trace metadata', async () => {
vi.mocked(http.get).mockRejectedValue({
isAxiosError: true,
response: {
status: 410,
data: new Blob([JSON.stringify({
code: 'FILE_EXPIRED', message: '导出文件已过期', data: null,
requestId: 'request-download-1', traceId: 'trace-download-1',
})], { type: 'application/json' }),
},
})
const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({
message: '导出文件已过期', code: 'FILE_EXPIRED', status: 410,
requestId: 'request-download-1', traceId: 'trace-download-1',
})
})
it('rejects an HTTP 200 business-error Blob instead of saving it as XLSX', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({
code: 'FILE_EXPIRED', message: '导出文件已过期', data: null,
requestId: 'request-download-200', traceId: 'trace-download-200',
})], { type: 'application/json;charset=UTF-8' }))
const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({
message: '导出文件已过期', code: 'FILE_EXPIRED', status: 200,
requestId: 'request-download-200', traceId: 'trace-download-200',
})
})
it('rejects malformed JSON download responses instead of saving them as XLSX', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob(['{not-json'], { type: 'application/json' }))
const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({
message: '下载接口返回了无法识别的 JSON 响应',
code: 'INVALID_DOWNLOAD_RESPONSE',
status: 200,
})
})
it('rejects valid JSON values that are not API envelopes', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob(['null'], { type: 'application/json' }))
const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({
message: '下载接口返回了无法识别的 JSON 响应',
code: 'INVALID_DOWNLOAD_RESPONSE',
status: 200,
})
})
it('rejects oversized JSON download responses without parsing or saving them', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob(
['x'.repeat(1024 * 1024 + 1)],
{ type: 'application/problem+json' },
))
const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({
message: '下载接口返回了超出限制的 JSON 响应',
code: 'INVALID_DOWNLOAD_RESPONSE',
status: 200,
})
})
})
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import axios from 'axios'
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type ExportTaskStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'
export type ExportTaskType = 'CUSTOMER_LIST' | string
@@ -49,6 +50,45 @@ export interface CreatePlatformExportTaskRequest {
}
const basePath = '/platform/v1/files/export-tasks'
const downloadTimeoutMs = 10 * 60 * 1000
const maxErrorEnvelopeBytes = 1024 * 1024
async function parseDownloadErrorBlob(blob: Blob, status?: number): Promise<ApiResponseError | null> {
if (!blob.type.toLowerCase().includes('json')) return null
if (blob.size > maxErrorEnvelopeBytes) {
return new ApiResponseError(
'下载接口返回了超出限制的 JSON 响应',
'INVALID_DOWNLOAD_RESPONSE',
status,
)
}
try {
const payload = JSON.parse(await blob.text()) as Partial<ApiEnvelope<unknown>>
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return new ApiResponseError(
'下载接口返回了无法识别的 JSON 响应',
'INVALID_DOWNLOAD_RESPONSE',
status,
)
}
return new ApiResponseError(
payload.code === '200' ? '下载接口未返回文件内容' : payload.message || '文件下载失败',
payload.code === '200' ? 'INVALID_DOWNLOAD_RESPONSE' : payload.code || (status ? `HTTP_${status}` : 'DOWNLOAD_FAILED'),
status,
payload.data,
payload.requestId,
payload.traceId,
)
} catch {
return new ApiResponseError(
'下载接口返回了无法识别的 JSON 响应',
'INVALID_DOWNLOAD_RESPONSE',
status,
)
}
}
export async function createPlatformExportTask(payload: CreatePlatformExportTaskRequest) {
const response = await http.post<ApiEnvelope<PlatformExportTask>, ApiEnvelope<PlatformExportTask>>(basePath, payload)
@@ -69,8 +109,30 @@ export async function listPlatformExportTasks(query: PlatformExportTaskQuery) {
}
}
export function downloadPlatformExportTask(taskId: string) {
return http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(taskId)}/download`, { responseType: 'blob' })
export async function getPlatformExportTask(taskId: string) {
const response = await http.get<ApiEnvelope<PlatformExportTask>, ApiEnvelope<PlatformExportTask>>(
`${basePath}/${encodeURIComponent(taskId)}`,
)
return unwrapApiResponse(response)
}
export async function downloadPlatformExportTask(taskId: string) {
try {
const blob = await http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(taskId)}/download`, {
responseType: 'blob',
// 导出文件上限 50 MB,不能继承普通接口的 15 秒超时,也不能无限等待半断开的连接。
timeout: downloadTimeoutMs,
})
const responseError = await parseDownloadErrorBlob(blob, 200)
if (responseError) throw responseError
return blob
} catch (error) {
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
const responseError = await parseDownloadErrorBlob(error.response.data, error.response.status)
if (responseError) throw responseError
}
throw error
}
}
export async function retryPlatformExportTask(taskId: string) {
@@ -4,6 +4,7 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
downloadPlatformExportTask,
getPlatformExportTask,
listPlatformExportTasks,
retryPlatformExportTask,
type PlatformExportTask,
@@ -14,6 +15,7 @@ import DownloadCenterView from './DownloadCenterView.vue'
vi.mock('@/modules/downloads/api/platformExportTasks', () => ({
downloadPlatformExportTask: vi.fn(),
getPlatformExportTask: vi.fn(),
listPlatformExportTasks: vi.fn(),
retryPlatformExportTask: vi.fn(),
}))
@@ -38,6 +40,7 @@ afterEach(() => vi.useRealTimers())
describe('DownloadCenterView', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:export') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
@@ -50,6 +53,7 @@ describe('DownloadCenterView', () => {
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [task], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(downloadPlatformExportTask).mockResolvedValue(new Blob(['xlsx']))
vi.mocked(getPlatformExportTask).mockResolvedValue(task)
vi.mocked(retryPlatformExportTask).mockResolvedValue({ ...task, status: 'PENDING' })
})
@@ -70,6 +74,11 @@ describe('DownloadCenterView', () => {
expect(downloadPlatformExportTask).toHaveBeenCalledWith('9')
expect(URL.createObjectURL).toHaveBeenCalled()
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledOnce()
const clickedAnchor = vi.mocked(HTMLAnchorElement.prototype.click).mock.instances[0] as HTMLAnchorElement
expect(clickedAnchor.download).toBe('客户列表.xlsx')
await new Promise((resolve) => window.setTimeout(resolve, 0))
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:export')
})
it('shows retry only for failed tasks with the source export permission', async () => {
@@ -89,6 +98,83 @@ describe('DownloadCenterView', () => {
expect(retryPlatformExportTask).toHaveBeenCalledWith('9')
})
it('loads a fresh task detail from the backend', async () => {
const wrapper = mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
const button = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((item) => item.textContent?.trim() === '详情')
button?.click()
await flushPromises()
expect(getPlatformExportTask).toHaveBeenCalledWith('9')
expect(document.body.textContent).toContain('任务详情 · EXP9')
expect(document.body.textContent).toContain('客户列表.xlsx')
wrapper.unmount()
})
it('renders an actionable list error and can recover', async () => {
vi.mocked(listPlatformExportTasks)
.mockRejectedValueOnce(new Error('network down'))
.mockResolvedValueOnce({ records: [task], page: 1, size: 20, total: 1, pages: 1 })
mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(document.querySelector('[role="alert"]')?.textContent).toContain('下载任务暂时无法加载')
const reload = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((item) => item.textContent?.trim() === '重新加载')
reload?.click()
await flushPromises()
expect(listPlatformExportTasks).toHaveBeenCalledTimes(2)
expect(document.body.textContent).toContain('共 1 个任务')
})
it('shows a clear empty state and hides retry without the source export permission', async () => {
vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
const pinia = setActivePinia(createPinia())
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['platform:download:list']
useAccessPreviewStore().initializeAfterLogin()
mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(document.body.textContent).toContain('暂无符合筛选条件的下载任务')
expect([...document.querySelectorAll<HTMLButtonElement>('button')]
.some((item) => item.textContent?.trim() === '重试')).toBe(false)
})
it('does not expose customer retry without the customer export permission', async () => {
vi.mocked(listPlatformExportTasks).mockResolvedValue({
records: [{ ...task, status: 'FAILED', failureMessage: 'IAM timeout' }],
page: 1, size: 20, total: 1, pages: 1,
})
const pinia = setActivePinia(createPinia())
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['platform:download:list']
useAccessPreviewStore().initializeAfterLogin()
mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(document.body.textContent).toContain('IAM timeout')
expect([...document.querySelectorAll<HTMLButtonElement>('button')]
.some((item) => item.textContent?.trim() === '重试')).toBe(false)
})
it('announces the initial loading state while the task request is pending', async () => {
let resolveRequest!: (value: { records: PlatformExportTask[]; page: number; size: number; total: number; pages: number }) => void
vi.mocked(listPlatformExportTasks).mockReturnValue(new Promise((resolve) => { resolveRequest = resolve }))
const wrapper = mount(DownloadCenterView, { global: { plugins: [createPinia(), ElementPlus] } })
await wrapper.vm.$nextTick()
expect(wrapper.find('.table-panel').attributes('aria-busy')).toBe('true')
resolveRequest({ records: [], page: 1, size: 20, total: 0, pages: 0 })
await flushPromises()
expect(wrapper.find('.table-panel').attributes('aria-busy')).toBe('false')
})
it('does not schedule another refresh after an in-flight load finishes on an unmounted page', async () => {
vi.useFakeTimers()
let resolveRequest!: (value: { records: PlatformExportTask[]; page: number; size: number; total: number; pages: number }) => void
@@ -102,4 +188,42 @@ describe('DownloadCenterView', () => {
expect(listPlatformExportTasks).toHaveBeenCalledTimes(1)
})
it('keeps the last successful task list and continues polling after a background refresh failure', async () => {
vi.useFakeTimers()
vi.mocked(listPlatformExportTasks)
.mockResolvedValueOnce({ records: [{ ...task, status: 'RUNNING', processedRows: 3 }], page: 1, size: 20, total: 1, pages: 1 })
.mockRejectedValueOnce(new Error('temporary network failure'))
.mockResolvedValueOnce({ records: [{ ...task, status: 'SUCCEEDED', processedRows: 12 }], page: 1, size: 20, total: 1, pages: 1 })
mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
await vi.advanceTimersByTimeAsync(5000)
await flushPromises()
expect(document.body.textContent).toContain('自动刷新暂时失败')
expect(document.body.textContent).toContain('客户列表')
await vi.advanceTimersByTimeAsync(5000)
await flushPromises()
expect(listPlatformExportTasks).toHaveBeenCalledTimes(3)
expect(document.body.textContent).not.toContain('自动刷新暂时失败')
})
it('updates an open task detail from the latest polling result', async () => {
vi.useFakeTimers()
vi.mocked(listPlatformExportTasks)
.mockResolvedValueOnce({ records: [{ ...task, status: 'RUNNING', processedRows: 3 }], page: 1, size: 20, total: 1, pages: 1 })
.mockResolvedValueOnce({ records: [{ ...task, status: 'SUCCEEDED', processedRows: 12 }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformExportTask).mockResolvedValue({ ...task, status: 'RUNNING', processedRows: 3 })
mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((item) => item.textContent?.trim() === '详情')?.click()
await flushPromises()
expect(document.body.textContent).toContain('3 / 12 行')
await vi.advanceTimersByTimeAsync(5000)
await flushPromises()
expect(document.body.textContent).toContain('12 / 12 行')
})
})
@@ -1,16 +1,18 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Download, RefreshCw, RotateCcw } from '@lucide/vue'
import { Download, Eye, RefreshCw, RotateCcw } from '@lucide/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 AppDrawer from '@/shared/components/AppDrawer.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'
@@ -28,10 +30,17 @@ 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', placeholder: '全部任务类型', options: [
@@ -46,7 +55,7 @@ const queryFields: QueryField[] = [
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 210, 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' },
@@ -68,13 +77,14 @@ function clearRefreshTimer() {
function scheduleRefresh() {
clearRefreshTimer()
if (!disposed && hasActiveTasks.value && !loadError.value) refreshTimer = window.setTimeout(() => { void loadTasks(true) }, 5000)
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
loadError.value = null
if (!background) loadError.value = null
try {
const range = filters.value.requestedRange
const response = await listPlatformExportTasks({
@@ -85,12 +95,24 @@ async function loadTasks(background = false) {
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) {
loadError.value = getApiErrorDetails(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 (!background) loading.value = false
scheduleRefresh()
if (!disposed && requestId === loadRequestId) {
if (!background) loading.value = false
scheduleRefresh()
}
}
}
@@ -99,20 +121,53 @@ function applyFilters() {
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
}
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)
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) {
ElMessage.error(getApiErrorMessage(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 = ''
}
@@ -146,7 +201,7 @@ function statusMeta(status: ExportTaskStatus) {
FAILED: { label: '失败', type: 'danger' as const },
EXPIRED: { label: '已过期', type: 'info' as const },
}
return values[status]
return values[status] ?? { label: status || '未知状态', type: 'info' as const }
}
function formatDate(value: string | null) {
@@ -170,6 +225,8 @@ watch([page, pageSize], () => { void loadTasks() })
onMounted(loadTasks)
onBeforeUnmount(() => {
disposed = true
loadRequestId += 1
detailRequestId += 1
clearRefreshTimer()
})
</script>
@@ -188,11 +245,15 @@ onBeforeUnmount(() => {
<div><h2>下载任务</h2><span>共 {{ result.total }} 个任务</span></div>
<el-button :loading="loading" @click="loadTasks()"><RefreshCw :size="16" />刷新</el-button>
</div>
<div v-if="syncWarning" class="download-sync-warning" role="status">
<span>自动刷新暂时失败,正在保留当前任务并继续重试:{{ syncWarning.message }}</span>
<ApiErrorMeta :details="syncWarning" />
</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)?.id" link type="primary" @click="openDetail(row as PlatformExportTask)"><Eye :size="15" />详情</el-button>
<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>
<span v-if="(row as PlatformExportTask).status !== 'SUCCEEDED' && !canRetry(row as PlatformExportTask)" class="action-placeholder">—</span>
</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>
@@ -204,6 +265,47 @@ onBeforeUnmount(() => {
<template #failure="{ row }"><span class="failure-cell" :class="{ 'failure-message': Boolean((row as PlatformExportTask).failureMessage) }">{{ (row as PlatformExportTask).failureMessage || '—' }}</span></template>
</DataTable>
</section>
<AppDrawer
v-model="detailOpen"
:title="detailTask ? `任务详情 · ${detailTask.taskNo}` : '任务详情'"
size="min(620px, 94vw)"
:loading="detailLoading"
:show-footer="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="statusMeta(detailTask.status).type" effect="light">{{ statusMeta(detailTask.status).label }}</el-tag>
</div>
<dl class="task-detail-grid">
<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>{{ 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>
</AppDrawer>
</div>
</template>
@@ -216,14 +318,38 @@ onBeforeUnmount(() => {
.download-table-header { min-height: 64px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }
.download-table-header h2 { margin: 0; color: var(--cc-text); font-size: 16px; }
.download-table-header span { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 12px; }
.download-sync-warning { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 18px; border-bottom: 1px solid #fedf89; background: #fffaeb; color: #93370d; font-size: 12px; }
.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }
.table-actions :deep(.el-button + .el-button) { margin-left: 0; }
.action-placeholder { color: var(--cc-muted); }
.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: 15px; }
.detail-load-error p { margin: 6px 0; color: var(--cc-text); font-size: 13px; }
.detail-load-error :deep(.el-button) { margin-top: 16px; }
.task-detail { min-width: 0; }
.task-detail-summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
.task-detail-summary h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 17px; }
.task-detail-summary p { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; }
.task-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
.task-detail-grid > div { min-width: 0; padding: 16px 14px; border-bottom: 1px solid var(--cc-border); }
.task-detail-grid > div:nth-child(odd):not(.is-wide) { border-right: 1px solid var(--cc-border); }
.task-detail-grid .is-wide { grid-column: 1 / -1; }
.task-detail-grid dt { margin-bottom: 6px; color: var(--cc-muted); font-size: 12px; }
.task-detail-grid dd { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; line-height: 1.5; }
.task-failure { margin-top: 18px; padding: 14px; border: 1px solid color-mix(in srgb, var(--cc-danger) 24%, var(--cc-border)); border-radius: 8px; background: color-mix(in srgb, var(--cc-danger) 5%, #fff); }
.task-failure strong { color: var(--cc-danger); font-size: 13px; }
.task-failure p { margin: 6px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; line-height: 1.6; }
small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 11px; }
@media (max-width: 720px) {
.download-error, .download-table-header { align-items: stretch; flex-direction: column; }
.download-table-header :deep(.el-button) { min-height: 40px; }
.download-error, .download-table-header, .download-sync-warning { align-items: stretch; flex-direction: column; }
.download-table-header :deep(.el-button), .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>