fix(导出): 修复大批量 Excel 下载超时

This commit is contained in:
WuYongJie 2026-07-10 18:41:23 +08:00
parent a754472cd5
commit 373825dbfe
4 changed files with 105 additions and 14 deletions

View File

@ -385,7 +385,11 @@ test('工作簿应生成三张行政导出表并保留日期、金额和版式
assert.ok(relationSheet.autoFilter)
assert.equal(mainSheet.getCell('A1').fill.type, 'pattern')
assert.equal(mainSheet.getCell('A1').border.top?.style, 'thin')
assert.equal(mainSheet.getCell('H2').value, null)
assert.equal(mainSheet.getCell('H2').border.top?.color?.argb, 'FFD1D5DB')
assert.equal(mainSheet.getCell('S2').border.right?.style, 'thin')
assert.strictEqual(mainSheet.getCell('A1').border, mainSheet.getCell('S2').border)
assert.strictEqual(mainSheet.getCell('A1').border, relationSheet.getCell('J2').border)
assert.equal(mainSheet.getCell('J2').alignment.wrapText, true)
assert.equal(mainSheet.getCell('F2').value, null)
assert.equal(mainSheet.getCell('L2').value, null)

View File

@ -224,17 +224,26 @@ const applyHeaderStyle = (worksheet: import('exceljs').Worksheet) => {
headerRow.height = 28
}
const LIGHT_GRAY_BORDER_COLOR = Object.freeze({ argb: 'FFD1D5DB' })
const LIGHT_THIN_BORDER_SIDE = Object.freeze({
style: 'thin' as const,
color: LIGHT_GRAY_BORDER_COLOR
})
const USED_RANGE_BORDER = Object.freeze({
top: LIGHT_THIN_BORDER_SIDE,
left: LIGHT_THIN_BORDER_SIDE,
bottom: LIGHT_THIN_BORDER_SIDE,
right: LIGHT_THIN_BORDER_SIDE
})
const applyUsedRangeBorders = (worksheet: import('exceljs').Worksheet) => {
for (let rowNumber = 1; rowNumber <= worksheet.rowCount; rowNumber += 1) {
// ExcelJS 的 columnCount 会遍历所有行计算最大列数,必须在循环外只读取一次。
const rowCount = worksheet.rowCount
const columnCount = worksheet.columnCount
for (let rowNumber = 1; rowNumber <= rowCount; rowNumber += 1) {
const row = worksheet.getRow(rowNumber)
for (let columnNumber = 1; columnNumber <= worksheet.columnCount; columnNumber += 1) {
const cell = row.getCell(columnNumber)
cell.border = {
top: { style: 'thin', color: { argb: 'FFD1D5DB' } },
left: { style: 'thin', color: { argb: 'FFD1D5DB' } },
bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } },
right: { style: 'thin', color: { argb: 'FFD1D5DB' } }
}
for (let columnNumber = 1; columnNumber <= columnCount; columnNumber += 1) {
row.getCell(columnNumber).border = USED_RANGE_BORDER
}
}
}

View File

@ -18,7 +18,8 @@ export const exportEquipment = (params?: {
mount_status: params?.mountStatus,
keyword: params?.keyword
},
responseType: 'blob'
responseType: 'blob',
timeout: 60000
})
}

View File

@ -84,8 +84,13 @@
v-if="showMoreActions"
class="absolute right-0 top-full z-20 mt-2 w-44 rounded-xl border border-border bg-bg-secondary p-2 shadow-[0_18px_44px_rgba(0,0,0,0.36)]"
>
<button class="w-full rounded-lg px-3 py-2 text-left text-sm text-text-secondary transition hover:bg-bg-hover hover:text-text-primary" @click="handleExportAndClose">
导出 Excel
<button
type="button"
class="w-full rounded-lg px-3 py-2 text-left text-sm text-text-secondary transition hover:bg-bg-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isExporting"
@click="handleExportAndClose"
>
{{ isExporting ? '正在导出...' : '导出 Excel' }}
</button>
<button class="w-full rounded-lg px-3 py-2 text-left text-sm text-text-secondary transition hover:bg-bg-hover hover:text-text-primary" @click="downloadTemplateAndClose">
下载模板
@ -127,6 +132,31 @@
<button @click="handleReset" class="btn-secondary sm:col-span-2 lg:col-span-1">重置</button>
</div>
</div>
<div
v-if="exportFeedback"
class="panel mt-4 flex items-center justify-between gap-3"
role="status"
aria-live="polite"
>
<div class="flex min-w-0 items-center gap-3">
<span class="badge" :class="getExportFeedbackBadgeClass(exportFeedback.type)">
{{ exportFeedback.type === 'loading' ? '生成中' : exportFeedback.type === 'success' ? '已完成' : '失败' }}
</span>
<p class="text-sm" :class="getExportFeedbackTextClass(exportFeedback.type)">
{{ exportFeedback.message }}
</p>
</div>
<button
v-if="exportFeedback.type !== 'loading'"
type="button"
class="btn-icon h-8 w-8 shrink-0"
aria-label="关闭导出提示"
@click="exportFeedback = null"
>
✕
</button>
</div>
</section>
<!-- 使用操作区 - 页面级核心工作区 -->
@ -862,6 +892,11 @@ const showBatchReturnModal = ref(false)
const showBatchTransferModal = ref(false)
const showMoreActions = ref(false)
const showAdvancedFilters = ref(false)
const isExporting = ref(false)
const exportFeedback = ref<{
type: 'loading' | 'success' | 'error'
message: string
} | null>(null)
const showBorrowAnomalyHandler = ref(false)
const borrowAnomalyTarget = ref<{
equipmentId: number
@ -1429,9 +1464,51 @@ const formatExportTimestamp = (date = new Date()) => {
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
}
const getExportFeedbackBadgeClass = (type: 'loading' | 'success' | 'error') => {
if (type === 'loading') return 'badge-warning'
if (type === 'success') return 'badge-success'
return 'badge-error'
}
const getExportFeedbackTextClass = (type: 'loading' | 'success' | 'error') => {
if (type === 'success') return 'text-status-success'
if (type === 'error') return 'text-status-error'
return 'text-text-secondary'
}
const isExportTimeoutError = (error: any) => {
const code = error?.originalError?.code || error?.code
const message = String(error?.message || error?.originalError?.message || '')
return code === 'ECONNABORTED' || code === 'ETIMEDOUT' || /timeout|超时/i.test(message)
}
const handleExport = async () => {
const res = await exportEquipment(filters.value)
downloadFile(res.data as Blob, `资产列表_${formatExportTimestamp()}.xlsx`)
if (isExporting.value) return
isExporting.value = true
exportFeedback.value = {
type: 'loading',
message: '正在生成资产列表,数据较多时可能需要一些时间。'
}
try {
const res = await exportEquipment(filters.value)
const filename = `行政资产台账_${formatExportTimestamp()}.xlsx`
downloadFile(res.data as Blob, filename)
exportFeedback.value = {
type: 'success',
message: `导出成功,已下载 ${filename}`
}
} catch (error: any) {
exportFeedback.value = {
type: 'error',
message: isExportTimeoutError(error)
? '导出超时,资产较多时生成会比较慢,请稍后重试。'
: '导出失败,请稍后重试;如持续失败,可缩小筛选范围。'
}
} finally {
isExporting.value = false
}
}
const handleExportAndClose = async () => {