diff --git a/backend/src/utils/__tests__/equipmentAdministrativeExport.test.ts b/backend/src/utils/__tests__/equipmentAdministrativeExport.test.ts index d0f1a92..9a59c99 100644 --- a/backend/src/utils/__tests__/equipmentAdministrativeExport.test.ts +++ b/backend/src/utils/__tests__/equipmentAdministrativeExport.test.ts @@ -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) diff --git a/backend/src/utils/equipmentAdministrativeExport.ts b/backend/src/utils/equipmentAdministrativeExport.ts index 19b92ba..d5add2a 100644 --- a/backend/src/utils/equipmentAdministrativeExport.ts +++ b/backend/src/utils/equipmentAdministrativeExport.ts @@ -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 } } } diff --git a/frontend/src/api/export.ts b/frontend/src/api/export.ts index 1db5dfb..12ca253 100644 --- a/frontend/src/api/export.ts +++ b/frontend/src/api/export.ts @@ -18,7 +18,8 @@ export const exportEquipment = (params?: { mount_status: params?.mountStatus, keyword: params?.keyword }, - responseType: 'blob' + responseType: 'blob', + timeout: 60000 }) } diff --git a/frontend/src/views/equipment/EquipmentList.vue b/frontend/src/views/equipment/EquipmentList.vue index d565508..03f00e8 100644 --- a/frontend/src/views/equipment/EquipmentList.vue +++ b/frontend/src/views/equipment/EquipmentList.vue @@ -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)]" > - + +
+
+ + {{ exportFeedback.type === 'loading' ? '生成中' : exportFeedback.type === 'success' ? '已完成' : '失败' }} + +

+ {{ exportFeedback.message }} +

+
+ +
@@ -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 () => {