fujian_water_biz_doc/docs/superpowers/plans/2026-05-15-xlsx-export-unification.md

21 KiB
Raw Blame History

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保持其它断言不动

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:

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:<application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8> but was:<application/vnd.ms-excel;charset=UTF-8>

  • Step 3: 在 ExcelUtils 中抽出统一下载头设置方法,并把 MIME 改成 xlsx

ExcelUtils.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";
}

然后把三个入口统一改成:

public static <T> void write(HttpServletResponse response, String filename, String sheetName,
                             Class<T> head, List<T> 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);
}
public static <T> void writeWithTemplate(HttpServletResponse response, String filename, InputStream templateStream,
                                         String sheetName, Class<T> head, List<T> 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);
    }
}
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:

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: 提交后端公共导出修复
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 响应。目标代码如下:

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 对象中追加:

response: (response: any, fallbackName?: string) => {
  downloadResponse(response, fallbackName)
}

保留原有 excel/word/zip/html/markdown/json,不要顺手改其它行为。

  • Step 3: 运行前端类型检查,确认工具签名可用

Run:

pnpm --dir "/Volumes/Dpan/github/water-workspace/water-frontend" ts:check

Expected: PASS如果有 response 未使用或 any 风格告警,只修本次新增代码,不扩散重构。

  • Step 4: 提交前端下载工具改动
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

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 的导出逻辑改成:

import download from '@/utils/download'
import { exportRedReversalRecord } from '@/api/operatingCharges/redReversalRecord'
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
  }
}

同时删除这些旧代码:

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:

pnpm --dir "/Volumes/Dpan/github/water-workspace/water-frontend" ts:check

Expected: PASS页面仍可编译新增 API 路径可被解析。

  • Step 4: 提交红冲记录页接线
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

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 中引入:

import download from '@/utils/download'
import { exportCollectionBatch } from '@/api/collection/export'

把导出函数改成:

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 中引入:

import download from '@/utils/download'
import { exportCollectionDetail } from '@/api/collection/export'

把导出函数改成:

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:

pnpm --dir "/Volumes/Dpan/github/water-workspace/water-frontend" ts:check

Expected: PASSbankCollectiondetail 不再包含 CSV 导出逻辑。

  • Step 5: 提交托收页面改造
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 中引入:

import download from '@/utils/download'
import { exportWithholdingBatch } from '@/api/collection/export'

把导出函数改成:

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 中引入:

import download from '@/utils/download'
import { exportRealtimeStatistics } from '@/api/collection/export'

把导出函数改成:

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:

pnpm --dir "/Volumes/Dpan/github/water-workspace/water-frontend" ts:check
pnpm --dir "/Volumes/Dpan/github/water-workspace/water-frontend" build:dev

Expected: 两条命令都 PASSbuild:dev 产物成功生成。

  • Step 4: 提交银行代扣与实时收费页改造
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:

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:

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=<sample>
  • /collection/bankWithholding
  • /collection/realTimeBilling

Expected:

  • 发起真实网络请求,不是浏览器本地 Blob 生成。

  • 返回文件为 .xlsx

  • 中文文件名不乱码。

  • Step 4: 分别查看两仓状态并准备后续 PR

Run:

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: 如果需要合并前整理最终提交
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(...)