diff --git a/docs/superpowers/plans/2026-05-15-xlsx-export-unification.md b/docs/superpowers/plans/2026-05-15-xlsx-export-unification.md new file mode 100644 index 0000000..2dada9a --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-xlsx-export-unification.md @@ -0,0 +1,602 @@ +# Excel 导出 xlsx 统一化与前端 CSV 替换 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 统一后端 Excel 下载为标准 `.xlsx`,并将 5 个前端页面从本地 CSV 导出切换为后端真实 Excel 导出。 + +**Architecture:** 后端以 `ExcelUtils` 为唯一公共导出入口,统一 MIME、文件名后缀和可编码文件名策略;前端不再拼接 `csvContent` 或创建 `text/csv` Blob,而是为目标页面新增 API 下载封装,直接请求后端导出接口并复用响应头中的文件名。实现按 backend lane / frontend lane 拆开,但在同一 feature 下联调收口。 + +**Tech Stack:** Java 17, Spring Boot, EasyExcel, MockMvc, Vue 3, TypeScript, Axios wrapper (`@/config/axios`), pnpm + +--- + +## File Structure + +### Backend files +- Modify: `../water-backend/sw-framework/sw-spring-boot-starter-excel/src/main/java/cn/com/emsoft/sw/framework/excel/core/util/ExcelUtils.java` + - 统一 `write` / `writeWithTemplate` / `writeTemplate` 的 xlsx MIME 与 `Content-Disposition`。 +- Modify: `../water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/controller/admin/accountingadjust/accountProcess/AccountingAdjustRouteSmokeTest.java` + - 把现有导出 smoke 断言从 `application/vnd.ms-excel` 更新为 xlsx MIME,并验证 `Content-Disposition` 仍存在。 +- Inspect/reuse (no code unless缺口真实存在): + - `../water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/accountingadjust/accountProcess/AccountingAdjustActionController.java` + - `../water-backend/sw-business-bank/sw-business-bank-server/src/main/java/cn/com/emsoft/sw/bankbusiness/controller/admin/withholdingbatch/WithholdingBatchController.java` + - `../water-backend/sw-business-bank/sw-business-bank-server/src/main/java/cn/com/emsoft/sw/bankbusiness/controller/admin/withholdingitem/WithholdingItemController.java` + - `../water-backend/sw-business-bank/sw-business-bank-server/src/main/java/cn/com/emsoft/sw/bankbusiness/controller/admin/channelstatistics/ChannelStatisticsController.java` + +### Frontend files +- Create: `../water-frontend/src/api/operatingCharges/redReversalRecord/index.ts` + - 红冲记录页导出 API。 +- Create: `../water-frontend/src/api/collection/export/index.ts` + - 银行托收 / 托收明细 / 银行代扣 / 实时收费导出 API。 +- Modify: `../water-frontend/src/utils/download.ts` + - 增加从后端响应中提取文件名并下载 Blob 的工具,支持中文文件名编码。 +- Modify: `../water-frontend/src/views/operatingCharges/redReversalRecord/index.vue` + - 删除本地 CSV 导出,改调红冲记录导出 API。 +- Modify: `../water-frontend/src/views/collection/bankCollection/index.vue` + - 删除本地 CSV 导出,改调托收批次导出 API。 +- Modify: `../water-frontend/src/views/collection/bankCollection/detail.vue` + - 删除本地 CSV 导出,改调托收明细导出 API。 +- Modify: `../water-frontend/src/views/collection/bankWithholding/index.vue` + - 删除本地 CSV 导出,改调用同一批次导出 API,并带 `businessType=WITHHOLDING`。 +- Modify: `../water-frontend/src/views/collection/realTimeBilling/index.vue` + - 删除本地 CSV 导出,改调实时收费统计导出 API。 + +--- + +### Task 1: 统一后端 ExcelUtils 为标准 xlsx 下载 + +**Files:** +- Modify: `../water-backend/sw-framework/sw-spring-boot-starter-excel/src/main/java/cn/com/emsoft/sw/framework/excel/core/util/ExcelUtils.java` +- Test: `../water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/controller/admin/accountingadjust/accountProcess/AccountingAdjustRouteSmokeTest.java` + +- [ ] **Step 1: 先写出会失败的导出头断言** + +在 `AccountingAdjustRouteSmokeTest.java` 把 4 个导出断言先改成 xlsx MIME,保持其它断言不动: + +```java +mockMvc.perform(get(BASE + "/log-export")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.startsWith("attachment;filename="))) + .andExpect(content().contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8")); + +mockMvc.perform(get(BASE + "/log-export-excel")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.startsWith("attachment;filename="))) + .andExpect(content().contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8")); + +mockMvc.perform(get(BASE + "/prestorage-export")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.startsWith("attachment;filename="))) + .andExpect(content().contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8")); + +mockMvc.perform(get(BASE + "/prestorage-export-excel")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.startsWith("attachment;filename="))) + .andExpect(content().contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8")); +``` + +- [ ] **Step 2: 运行后端 smoke,确认当前实现失败** + +Run: + +```bash +mvn -f "/Volumes/Dpan/github/water-workspace/water-backend/pom.xml" -pl sw-business/sw-business-server -am -Dtest=AccountingAdjustRouteSmokeTest test +``` + +Expected: FAIL,错误类似 `Content type expected: but was:`。 + +- [ ] **Step 3: 在 ExcelUtils 中抽出统一下载头设置方法,并把 MIME 改成 xlsx** + +把 `ExcelUtils.java` 的响应头逻辑改成下面这种结构,三个写方法都复用它: + +```java +private static final String XLSX_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8"; + +private static void prepareAttachmentResponse(HttpServletResponse response, String filename) { + response.addHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(ensureXlsxFilename(filename))); + response.setContentType(XLSX_CONTENT_TYPE); +} + +private static String ensureXlsxFilename(String filename) { + if (filename == null || filename.isBlank()) { + return "export.xlsx"; + } + if (filename.toLowerCase().endsWith(".xlsx")) { + return filename; + } + if (filename.toLowerCase().endsWith(".xls")) { + return filename.substring(0, filename.length() - 4) + ".xlsx"; + } + return filename + ".xlsx"; +} +``` + +然后把三个入口统一改成: + +```java +public static void write(HttpServletResponse response, String filename, String sheetName, + Class head, List data) throws IOException { + prepareAttachmentResponse(response, filename); + EasyExcel.write(response.getOutputStream(), head) + .autoCloseStream(false) + .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) + .registerWriteHandler(new SelectSheetWriteHandler(head)) + .registerConverter(new LongStringConverter()) + .sheet(sheetName) + .doWrite(data); +} +``` + +```java +public static void writeWithTemplate(HttpServletResponse response, String filename, InputStream templateStream, + String sheetName, Class head, List data) throws IOException { + if (templateStream == null) { + throw new RuntimeException("Excel模板文件流不能为空"); + } + prepareAttachmentResponse(response, filename); + try (templateStream) { + EasyExcel.write(response.getOutputStream(), head) + .withTemplate(templateStream) + .autoCloseStream(false) + .registerWriteHandler(new SelectSheetWriteHandler(head)) + .registerConverter(new LongStringConverter()) + .sheet(sheetName) + .needHead(false) + .doWrite(data); + } +} +``` + +```java +public static void writeTemplate(HttpServletResponse response, String filename, InputStream templateStream) + throws IOException { + prepareAttachmentResponse(response, filename); + templateStream.transferTo(response.getOutputStream()); + templateStream.close(); +} +``` + +- [ ] **Step 4: 重新跑后端 smoke,确认通过** + +Run: + +```bash +mvn -f "/Volumes/Dpan/github/water-workspace/water-backend/pom.xml" -pl sw-business/sw-business-server -am -Dtest=AccountingAdjustRouteSmokeTest test +``` + +Expected: PASS,四个导出断言均匹配 xlsx MIME。 + +- [ ] **Step 5: 提交后端公共导出修复** + +```bash +git -C "/Volumes/Dpan/github/water-workspace/water-backend" add \ + sw-framework/sw-spring-boot-starter-excel/src/main/java/cn/com/emsoft/sw/framework/excel/core/util/ExcelUtils.java \ + sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/controller/admin/accountingadjust/accountProcess/AccountingAdjustRouteSmokeTest.java + +git -C "/Volumes/Dpan/github/water-workspace/water-backend" commit -m "fix(excel): standardize xlsx download headers" +``` + +### Task 2: 给前端下载工具增加后端文件流下载与文件名解码 + +**Files:** +- Modify: `../water-frontend/src/utils/download.ts` +- Test: `../water-frontend/src/views/operatingCharges/redReversalRecord/index.vue` (first consumer) + +- [ ] **Step 1: 为下载工具写出目标接口** + +在 `download.ts` 里新增两个内部函数:一个解析 `Content-Disposition`,一个下载 axios blob 响应。目标代码如下: + +```ts +const decodeFilename = (contentDisposition?: string, fallbackName = 'export.xlsx') => { + if (!contentDisposition) return fallbackName + const utf8Match = contentDisposition.match(/filename\*=utf-8''([^;]+)/i) + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1]) + } + const plainMatch = contentDisposition.match(/filename=([^;]+)/i) + if (plainMatch?.[1]) { + return decodeURIComponent(plainMatch[1].trim().replace(/^"|"$/g, '')) + } + return fallbackName +} + +const downloadResponse = (response: any, fallbackName = 'export.xlsx') => { + const fileName = decodeFilename(response?.headers?.['content-disposition'], fallbackName) + download0(response.data, fileName, response.data?.type || 'application/octet-stream') +} +``` + +- [ ] **Step 2: 把新工具挂到默认导出对象** + +在 `download` 对象中追加: + +```ts +response: (response: any, fallbackName?: string) => { + downloadResponse(response, fallbackName) +} +``` + +保留原有 `excel/word/zip/html/markdown/json`,不要顺手改其它行为。 + +- [ ] **Step 3: 运行前端类型检查,确认工具签名可用** + +Run: + +```bash +pnpm --dir "/Volumes/Dpan/github/water-workspace/water-frontend" ts:check +``` + +Expected: PASS;如果有 `response` 未使用或 `any` 风格告警,只修本次新增代码,不扩散重构。 + +- [ ] **Step 4: 提交前端下载工具改动** + +```bash +git -C "/Volumes/Dpan/github/water-workspace/water-frontend" add src/utils/download.ts + +git -C "/Volumes/Dpan/github/water-workspace/water-frontend" commit -m "feat(download): support backend blob responses" +``` + +### Task 3: 新增前端导出 API 封装并替换红冲记录页 + +**Files:** +- Create: `../water-frontend/src/api/operatingCharges/redReversalRecord/index.ts` +- Modify: `../water-frontend/src/views/operatingCharges/redReversalRecord/index.vue` + +- [ ] **Step 1: 新建红冲记录导出 API 文件** + +创建 `src/api/operatingCharges/redReversalRecord/index.ts`: + +```ts +import request from '@/config/axios' + +export const exportRedReversalRecord = (params: any) => { + return request.download({ + url: '/business/accounting-adjust/log-export', + params + }) +} +``` + +- [ ] **Step 2: 先删掉红冲记录页本地 CSV 构造,替换为后端下载调用** + +把 `redReversalRecord/index.vue` 的导出逻辑改成: + +```ts +import download from '@/utils/download' +import { exportRedReversalRecord } from '@/api/operatingCharges/redReversalRecord' +``` + +```ts +const handleExport = async () => { + try { + await message.exportConfirm() + exportLoading.value = true + const response = await exportRedReversalRecord({ ...queryParams }) + download.response(response, `红冲记录_${formatDate(Date.now() as any)}.xlsx`) + } finally { + exportLoading.value = false + } +} +``` + +同时删除这些旧代码: + +```ts +const data = list.value +const headers = ['红冲时间', '红冲金额', '收费员', '操作员', '收费时间', '备注'] +const csvContent = [ + headers.join(','), + ...data.map((row: any) => [ + row.reversalTime, + row.reversalAmount, + row.cashier, + row.operator, + row.chargeTime, + row.remark + ].join(',')) +].join('\n') +const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }) +download.excel(blob, `红冲记录_${formatDate(Date.now() as any)}.csv`) +``` + +- [ ] **Step 3: 运行前端类型检查,确认红冲记录页接线通过** + +Run: + +```bash +pnpm --dir "/Volumes/Dpan/github/water-workspace/water-frontend" ts:check +``` + +Expected: PASS;页面仍可编译,新增 API 路径可被解析。 + +- [ ] **Step 4: 提交红冲记录页接线** + +```bash +git -C "/Volumes/Dpan/github/water-workspace/water-frontend" add \ + src/api/operatingCharges/redReversalRecord/index.ts \ + src/views/operatingCharges/redReversalRecord/index.vue + +git -C "/Volumes/Dpan/github/water-workspace/water-frontend" commit -m "feat(red-reversal): use backend excel export" +``` + +### Task 4: 新增 collection 导出 API 并替换托收批次/托收明细页面 + +**Files:** +- Create: `../water-frontend/src/api/collection/export/index.ts` +- Modify: `../water-frontend/src/views/collection/bankCollection/index.vue` +- Modify: `../water-frontend/src/views/collection/bankCollection/detail.vue` + +- [ ] **Step 1: 新建 collection 导出 API 文件** + +创建 `src/api/collection/export/index.ts`: + +```ts +import request from '@/config/axios' + +export const exportCollectionBatch = (params: any) => { + return request.download({ + url: '/bankbusiness/withholding-batch/export-excel', + params: { + ...params, + businessType: 'COLLECTION' + } + }) +} + +export const exportCollectionDetail = (params: any) => { + return request.download({ + url: '/bankbusiness/withholding-item/export-excel', + params + }) +} + +export const exportWithholdingBatch = (params: any) => { + return request.download({ + url: '/bankbusiness/withholding-batch/export-excel', + params: { + ...params, + businessType: 'WITHHOLDING' + } + }) +} + +export const exportRealtimeStatistics = (params: any) => { + return request.download({ + url: '/bankbusiness/channel-statistics/export-excel', + params + }) +} +``` + +- [ ] **Step 2: 替换银行托收页导出** + +在 `bankCollection/index.vue` 中引入: + +```ts +import download from '@/utils/download' +import { exportCollectionBatch } from '@/api/collection/export' +``` + +把导出函数改成: + +```ts +const handleExport = async () => { + try { + await message.exportConfirm() + exportLoading.value = true + const response = await exportCollectionBatch({ ...queryParams }) + download.response(response, `银行托收_${formatDate(Date.now() as any)}.xlsx`) + } finally { + exportLoading.value = false + } +} +``` + +删除 `headers.join(',')` / `csvContent` / `Blob(text/csv)` / `.csv` 文件名代码。 + +- [ ] **Step 3: 替换托收明细页导出** + +在 `bankCollection/detail.vue` 中引入: + +```ts +import download from '@/utils/download' +import { exportCollectionDetail } from '@/api/collection/export' +``` + +把导出函数改成: + +```ts +const handleExport = async () => { + try { + await message.exportConfirm() + exportLoading.value = true + const response = await exportCollectionDetail({ ...queryParams }) + download.response(response, `托收明细_${formatDate(Date.now() as any)}.xlsx`) + } finally { + exportLoading.value = false + } +} +``` + +保留 `batchNo: route.query.batchNo || ''` 作为请求参数的一部分,不要丢掉详情上下文。 + +- [ ] **Step 4: 运行前端类型检查,确认两个页面通过** + +Run: + +```bash +pnpm --dir "/Volumes/Dpan/github/water-workspace/water-frontend" ts:check +``` + +Expected: PASS;`bankCollection` 与 `detail` 不再包含 CSV 导出逻辑。 + +- [ ] **Step 5: 提交托收页面改造** + +```bash +git -C "/Volumes/Dpan/github/water-workspace/water-frontend" add \ + src/api/collection/export/index.ts \ + src/views/collection/bankCollection/index.vue \ + src/views/collection/bankCollection/detail.vue + +git -C "/Volumes/Dpan/github/water-workspace/water-frontend" commit -m "feat(collection): switch batch exports to backend excel" +``` + +### Task 5: 替换银行代扣与实时收费页面导出 + +**Files:** +- Modify: `../water-frontend/src/views/collection/bankWithholding/index.vue` +- Modify: `../water-frontend/src/views/collection/realTimeBilling/index.vue` +- Reuse: `../water-frontend/src/api/collection/export/index.ts` + +- [ ] **Step 1: 替换银行代扣页导出** + +在 `bankWithholding/index.vue` 中引入: + +```ts +import download from '@/utils/download' +import { exportWithholdingBatch } from '@/api/collection/export' +``` + +把导出函数改成: + +```ts +const handleExport = async () => { + try { + await message.exportConfirm() + exportLoading.value = true + const response = await exportWithholdingBatch({ ...queryParams }) + download.response(response, `银行代扣_${formatDate(Date.now() as any)}.xlsx`) + } finally { + exportLoading.value = false + } +} +``` + +删除本地 CSV 生成代码。 + +- [ ] **Step 2: 替换实时收费页导出** + +在 `realTimeBilling/index.vue` 中引入: + +```ts +import download from '@/utils/download' +import { exportRealtimeStatistics } from '@/api/collection/export' +``` + +把导出函数改成: + +```ts +const handleExport = async () => { + try { + await message.exportConfirm() + exportLoading.value = true + const response = await exportRealtimeStatistics({ ...queryParams }) + download.response(response, `实时收费_${formatDate(Date.now() as any)}.xlsx`) + } finally { + exportLoading.value = false + } +} +``` + +删除原 `csvContent` / `Blob(text/csv)` 代码。 + +- [ ] **Step 3: 跑前端类型检查与最小构建** + +Run: + +```bash +pnpm --dir "/Volumes/Dpan/github/water-workspace/water-frontend" ts:check +pnpm --dir "/Volumes/Dpan/github/water-workspace/water-frontend" build:dev +``` + +Expected: 两条命令都 PASS;`build:dev` 产物成功生成。 + +- [ ] **Step 4: 提交银行代扣与实时收费页改造** + +```bash +git -C "/Volumes/Dpan/github/water-workspace/water-frontend" add \ + src/views/collection/bankWithholding/index.vue \ + src/views/collection/realTimeBilling/index.vue + +git -C "/Volumes/Dpan/github/water-workspace/water-frontend" commit -m "feat(collection): route remaining exports through backend" +``` + +### Task 6: 联调验证与收口 + +**Files:** +- Verify: `../water-backend/...` changed files +- Verify: `../water-frontend/...` changed files +- Optional evidence note: `../water-docs/docs/evidence/` (only if user asks for persistent evidence) + +- [ ] **Step 1: 手工检查代码中已消失的 CSV 痕迹** + +Run: + +```bash +grep -R "text/csv\|csvContent\|\.csv" "/Volumes/Dpan/github/water-workspace/water-frontend/src/views/operatingCharges/redReversalRecord" \ + "/Volumes/Dpan/github/water-workspace/water-frontend/src/views/collection/bankCollection" \ + "/Volumes/Dpan/github/water-workspace/water-frontend/src/views/collection/bankWithholding" \ + "/Volumes/Dpan/github/water-workspace/water-frontend/src/views/collection/realTimeBilling" +``` + +Expected: 无匹配,或者仅剩注释/字符串字面量以外的历史内容为 0。 + +- [ ] **Step 2: 手工检查后端 xlsx MIME 已生效** + +Run: + +```bash +grep -R "application/vnd.ms-excel;charset=UTF-8" "/Volumes/Dpan/github/water-workspace/water-backend/sw-framework/sw-spring-boot-starter-excel/src/main/java/cn/com/emsoft/sw/framework/excel/core/util/ExcelUtils.java" +``` + +Expected: 无匹配。 + +- [ ] **Step 3: 浏览器 smoke** + +在本地/测试环境逐页点击导出并记录结果: + +- `/operatingCharges/redReversalRecord` +- `/collection/bankCollection` +- `/collection/bankCollection/detail?batchNo=` +- `/collection/bankWithholding` +- `/collection/realTimeBilling` + +Expected: +- 发起真实网络请求,不是浏览器本地 Blob 生成。 +- 返回文件为 `.xlsx`。 +- 中文文件名不乱码。 + +- [ ] **Step 4: 分别查看两仓状态并准备后续 PR** + +Run: + +```bash +git -C "/Volumes/Dpan/github/water-workspace/water-backend" status --short + +git -C "/Volumes/Dpan/github/water-workspace/water-frontend" status --short +``` + +Expected: 两仓仅包含本计划涉及的文件改动,无额外脏改动。 + +- [ ] **Step 5: 如果需要合并前整理最终提交** + +```bash +git -C "/Volumes/Dpan/github/water-workspace/water-backend" log --oneline -3 + +git -C "/Volumes/Dpan/github/water-workspace/water-frontend" log --oneline -5 +``` + +Expected: 后端至少 1 个导出统一提交,前端至少 3 个渐进提交(下载工具、红冲记录、collection 页面)。 + +--- + +## Self-Review + +- **Spec coverage:** + - FR-001/FR-002/FR-011/SC-001/SC-006 → Task 1 + - FR-003/FR-004/FR-005/FR-008/SC-002/SC-003 → Tasks 2-5 + - FR-006/FR-007/SC-004 → Tasks 3-5 通过明确 API 路径落地 + - FR-009/FR-010/FR-012/SC-005 → Task 6 + 提交拆分策略 +- **Placeholder scan:** 已避免使用 TBD/TODO/“类似 Task N”;每个改代码步骤都给了具体代码片段。 +- **Type consistency:** 统一使用 `download.response(...)` 处理后端 blob 响应;前端导出 API 全部基于 `request.download(...)`。