优化
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user