# 转账记录导出 Excel + 时间筛选 + 导出修复 — 实现计划 > **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:** 转账管理页面(`module_payment/account/index.vue` "转账记录" tab)导出 Excel 跟随表单筛选条件,新增时间范围筛选,并修复导出时间格式(东8)与收款方类型翻译。 **Architecture:** 改造后端现有 `GET /transfer/export`(参数改为与 `transferList` 一致的 6 个可选筛选条件、导出列 11 列、创建时间格式化东8、类型 map 补齐),`transferList` 同步支持时间筛选;前端恢复"下载报表"按钮(blob 下载)、筛选表单加时间选择器并加宽控件、清理旧导出死代码。 **Tech Stack:** Java 17 / Spring Boot / MyBatis-Plus / Lombok;Vue3 + Vite + TypeScript + Element Plus + dayjs + ExcelJS。 ## Global Constraints - 设计文档(已批准):`docs/superpowers/specs/2026-08-11-transfer-export-design.md` - 时间解析沿用 `parseDateTime`(结束日期解析为当天 00:00:00,与消费记录一致) - 条件语义与 `transferList` 逐条一致(`eq` 精确匹配) - 权限保持 `module_payment:account:transfer:list`(按钮 `v-hasPerm` 同权限码) - 导出列 11 列:序号、订单号、商户订单号、金额(元)、收款方姓名、收款方类型、状态、转账标题、创建时间、备注、失败原因 - 创建时间统一东8 `yyyy-MM-dd HH:mm:ss`(后端 `atZoneSameInstant(Asia/Shanghai)`,前端 dayjs) - 空数据导出空 sheet,不特判 - 项目无后端测试框架(`java/src/test` 不存在)、无前端单测(无 vitest/jest);每任务验证 = 编译/类型检查 + 逻辑对照 + 最终 Playwright 端到端 - 后端项目路径 `../java`(相对工作目录 frontend/),Maven 编译:`cd ../java && mvn -q compile -DskipTests` --- ### Task 1: 后端 transferList 支持时间范围筛选 **Files:** - Modify: `../java/src/main/java/com/payment/platform/module/payment/account/service/AccountService.java:190-201` - Modify: `../java/src/main/java/com/payment/platform/module/payment/account/controller/AccountController.java:76-84` **Interfaces:** - Produces: `transferList(int pageNo, int pageSize, String outBizNo, String status, Long tenantId, String enterpriseId, String startTime, String endTime)` — 新参数 `startTime`/`endTime`(`yyyy-MM-dd` 或 `yyyy-MM-dd HH:mm:ss`,可空) - [ ] **Step 1: service 加时间过滤参数** `AccountService.transferList` 签名与查询条件改为: ```java public PageResult transferList(int pageNo, int pageSize, String outBizNo, String status, Long tenantId, String enterpriseId, String startTime, String endTime) { var w = new LambdaQueryWrapper().orderByDesc(TransferEntity::getId); if (outBizNo != null && !outBizNo.isBlank()) w.eq(TransferEntity::getOutBizNo, outBizNo); if (status != null && !status.isBlank()) w.eq(TransferEntity::getStatus, status); if (tenantId != null) w.eq(TransferEntity::getTenantId, tenantId); if (enterpriseId != null && !enterpriseId.isBlank()) w.eq(TransferEntity::getEnterpriseId, enterpriseId); if (startTime != null && !startTime.isBlank()) w.ge(TransferEntity::getCreatedTime, parseDateTime(startTime)); if (endTime != null && !endTime.isBlank()) w.le(TransferEntity::getCreatedTime, parseDateTime(endTime)); var p = new Page(pageNo, pageSize); var r = transferMapper.selectPage(p, w); List items = r.getRecords().stream().map(this::transferVO).toList(); return PageResult.of(pageNo, pageSize, r.getTotal(), items); } ``` - [ ] **Step 2: controller 透传新参数** `AccountController.transferList` 改为: ```java @PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')") @GetMapping("/transfer") public Result> transferList( @RequestParam(name = "page_no", defaultValue = "1") int pageNo, @RequestParam(name = "page_size", defaultValue = "20") int pageSize, @RequestParam(name = "out_biz_no", required = false) String outBizNo, @RequestParam(required = false) String status, @RequestParam(name = "tenant_id", required = false) Long tenantId, @RequestParam(name = "enterprise_id", required = false) String enterpriseId, @RequestParam(name = "start_time", required = false) String startTime, @RequestParam(name = "end_time", required = false) String endTime) { return Result.ok(accountService.transferList(pageNo, pageSize, outBizNo, status, tenantId, enterpriseId, startTime, endTime)); } ``` - [ ] **Step 3: 编译验证** Run: `cd ../java && mvn -q compile -DskipTests` Expected: BUILD SUCCESS(无编译错误) - [ ] **Step 4: Commit** ```bash cd ../java && git add src/main/java/com/payment/platform/module/payment/account/service/AccountService.java src/main/java/com/payment/platform/module/payment/account/controller/AccountController.java && git commit -m "feat: 转账列表支持时间范围筛选" ``` --- ### Task 2: 后端 transferExport 改造(6 条件 + 11 列 + 东8时间 + 类型翻译) **Files:** - Modify: `../java/src/main/java/com/payment/platform/module/payment/account/controller/AccountController.java:237-249` - Modify: `../java/src/main/java/com/payment/platform/module/payment/account/service/AccountService.java:295-369` **Interfaces:** - Consumes: Task 1 的 `parseDateTime`(已有)与筛选条件语义 - Produces: `transferExport(String outBizNo, String status, Long tenantId, String enterpriseId, String startTime, String endTime)` 返回 xlsx 字节数组;下载文件名 `transfer_report.xlsx` - [ ] **Step 1: controller 参数改为 6 条件** `AccountController.transferExport` 整体替换为: ```java @PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')") @GetMapping("/transfer/export") public void transferExport( @RequestParam(name = "out_biz_no", required = false) String outBizNo, @RequestParam(name = "status", required = false) String status, @RequestParam(name = "tenant_id", required = false) Long tenantId, @RequestParam(name = "enterprise_id", required = false) String enterpriseId, @RequestParam(name = "start_time", required = false) String startTime, @RequestParam(name = "end_time", required = false) String endTime, HttpServletResponse response) throws IOException { byte[] bytes = accountService.transferExport(outBizNo, status, tenantId, enterpriseId, startTime, endTime); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=transfer_report.xlsx"); response.getOutputStream().write(bytes); } ``` - [ ] **Step 2: service 方法整体改写** `AccountService.transferExport` 方法体替换为(注意保留类中已存在的 `ObjectMapper om`、`ExcelUtil.exportToExcel` 用法): ```java public byte[] transferExport(String outBizNo, String status, Long tenantId, String enterpriseId, String startTime, String endTime) { try { LambdaQueryWrapper w = new LambdaQueryWrapper<>(); if (outBizNo != null && !outBizNo.isBlank()) w.eq(TransferEntity::getOutBizNo, outBizNo); if (status != null && !status.isBlank()) w.eq(TransferEntity::getStatus, status); if (tenantId != null) w.eq(TransferEntity::getTenantId, tenantId); if (enterpriseId != null && !enterpriseId.isBlank()) w.eq(TransferEntity::getEnterpriseId, enterpriseId); if (startTime != null && !startTime.isBlank()) w.ge(TransferEntity::getCreatedTime, parseDateTime(startTime)); if (endTime != null && !endTime.isBlank()) w.le(TransferEntity::getCreatedTime, parseDateTime(endTime)); w.orderByDesc(TransferEntity::getId); List records = transferMapper.selectList(w); Map statusMap = Map.of( "DEALING", "处理中", "SUCCESS", "成功", "FAIL", "失败", "REFUND", "退票"); Map payeeTypeMap = Map.of( "ALIPAY_USER_ID", "支付宝账户", "ALIPAY_LOGON_ID", "支付宝账户", "ALIPAY_ACCOUNT", "支付宝账户", "BANKCARD_ACCOUNT", "银行卡", "BANK_CARD", "银行卡"); ObjectMapper om = new ObjectMapper(); List> listData = new ArrayList<>(); DateTimeFormatter timeFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); int seq = 1; for (TransferEntity r : records) { Map row = new LinkedHashMap<>(); row.put("序号", seq++); row.put("订单号", r.getOutBizNo() != null ? r.getOutBizNo() : ""); row.put("商户订单号", r.getOrderNo() != null ? r.getOrderNo() : ""); row.put("金额(元)", r.getAmount() != null ? r.getAmount().toPlainString() : "0"); row.put("状态", statusMap.getOrDefault(r.getStatus(), r.getStatus() != null ? r.getStatus() : "")); row.put("转账标题", r.getOrderTitle() != null ? r.getOrderTitle() : ""); row.put("创建时间", r.getCreatedTime() != null ? r.getCreatedTime().atZoneSameInstant(ZoneId.of("Asia/Shanghai")).format(timeFmt) : ""); // 解析 payeeInfo JSON String name = ""; String identityType = ""; if (r.getPayeeInfo() != null && !r.getPayeeInfo().isBlank()) { try { @SuppressWarnings("unchecked") Map payeeInfo = om.readValue(r.getPayeeInfo(), Map.class); name = String.valueOf(payeeInfo.getOrDefault("name", "")); identityType = String.valueOf(payeeInfo.getOrDefault("identity_type", "")); } catch (Exception ignored) { // ignore invalid JSON } } row.put("收款方姓名", name); row.put("收款方类型", payeeTypeMap.getOrDefault(identityType, identityType)); row.put("备注", r.getRemark() != null ? r.getRemark() : ""); row.put("失败原因", r.getErrorMsg() != null ? r.getErrorMsg() : ""); listData.add(row); } Map mappingDict = new LinkedHashMap<>(); mappingDict.put("序号", "序号"); mappingDict.put("订单号", "订单号"); mappingDict.put("商户订单号", "商户订单号"); mappingDict.put("金额(元)", "金额(元)"); mappingDict.put("收款方姓名", "收款方姓名"); mappingDict.put("收款方类型", "收款方类型"); mappingDict.put("状态", "状态"); mappingDict.put("转账标题", "转账标题"); mappingDict.put("创建时间", "创建时间"); mappingDict.put("备注", "备注"); mappingDict.put("失败原因", "失败原因"); log.info("导出转账记录报表: {} 条", listData.size()); return ExcelUtil.exportToExcel(listData, mappingDict); } catch (Exception e) { log.error("导出转账记录失败", e); throw new RuntimeException("导出转账记录失败: " + e.getMessage()); } } ``` - [ ] **Step 3: 补充缺失 import** `AccountService.java` 顶部 java.time 包 import 区若无 `java.time.ZoneId`,在 `import java.time.OffsetDateTime;` 后添加: ```java import java.time.ZoneId; ``` (若已存在则跳过此步;`DateTimeFormatter` 若缺失同样补 `import java.time.format.DateTimeFormatter;`) - [ ] **Step 4: 编译验证** Run: `cd ../java && mvn -q compile -DskipTests` Expected: BUILD SUCCESS - [ ] **Step 5: Commit** ```bash cd ../java && git add src/main/java/com/payment/platform/module/payment/account/service/AccountService.java src/main/java/com/payment/platform/module/payment/account/controller/AccountController.java && git commit -m "feat: 转账导出接口对齐表单筛选条件,导出列加备注/失败原因,时间格式东8,类型翻译补齐" ``` --- ### Task 3: 前端 API 参数更新(transferList + exportTransferReport) **Files:** - Modify: `src/api/module_payment/account.ts:184-197`(transferList) - Modify: `src/api/module_payment/account.ts:230-241`(exportTransferReport) **Interfaces:** - Produces: `AccountAPI.transferList(params)` 新增可选 `start_time`/`end_time`;`AccountAPI.exportTransferReport(params)` 参数改为 6 个可选字段(无必填) - [ ] **Step 1: transferList 类型加时间参数** ```ts transferList(params: { page_no?: number; page_size?: number; out_biz_no?: string; status?: string; tenant_id?: number; enterprise_id?: string; start_time?: string; end_time?: string; }) { return request>({ url: `${API_PATH}/transfer`, method: "get", params, }); }, ``` - [ ] **Step 2: exportTransferReport 参数类型替换** ```ts exportTransferReport(params: { out_biz_no?: string; status?: string; tenant_id?: number; enterprise_id?: string; start_time?: string; end_time?: string; }) { return request({ url: `${API_PATH}/transfer/export`, method: "get", params, responseType: "blob", }); }, ``` - [ ] **Step 3: 类型检查** Run: `pnpm ts:check 2>&1 | grep -c "src/api/module_payment/account.ts"` Expected: 0(该文件无类型错误;项目其他文件存在既有错误基线,忽略) - [ ] **Step 4: Commit** ```bash git add src/api/module_payment/account.ts && git commit -m "feat: 转账导出API参数对齐表单筛选(6条件全可选),transferList支持时间筛选" ``` --- ### Task 4: 前端筛选表单(时间选择器 + 控件加宽 + 查询/重置适配) **Files:** - Modify: `src/views/module_payment/account/index.vue:1158-1163`(transferSearchForm 定义) - Modify: `src/views/module_payment/account/index.vue:299-326`(筛选表单模板) - Modify: `src/views/module_payment/account/index.vue:1762-1786`(重置 + fetchTransferList) - Modify: `src/views/module_payment/account/index.vue`(script 顶部 import 区,加 dayjs) - Modify: `src/views/module_payment/account/index.vue:353`(创建时间列 formatter) **Interfaces:** - Produces: `transferSearchForm` 新增 `dateRange: string[] | null`、`start_time: string | undefined`、`end_time: string | undefined`;`handleTransferDateChange()`;`fetchTransferList` 传时间参数 - [ ] **Step 1: transferSearchForm 增加时间字段** `index.vue:1158-1163` 改为: ```ts const transferSearchForm = reactive({ out_biz_no: "", status: "", tenant_id: undefined as number | undefined, enterprise_id: undefined as string | undefined, dateRange: null as string[] | null, start_time: undefined as string | undefined, end_time: undefined as string | undefined, }); ``` - [ ] **Step 2: 表单模板加时间选择器 + 控件加宽** `index.vue:299-326` 的筛选表单:订单号 input 加 `style="width: 220px"`,状态/租户/企业 select 加 `style="width: 180px"`,并在企业 select 的 `` 后、按钮 `` 前插入: ```html ``` - [ ] **Step 3: 新增 handleTransferDateChange(仿 handleConsumeDateChange)** 在 `handleTransferSearchReset`(约 1762 行)前添加: ```ts function handleTransferDateChange() { if (transferSearchForm.dateRange && transferSearchForm.dateRange.length === 2) { transferSearchForm.start_time = (transferSearchForm.dateRange as any)[0]; transferSearchForm.end_time = (transferSearchForm.dateRange as any)[1]; } else { transferSearchForm.start_time = undefined; transferSearchForm.end_time = undefined; } } ``` - [ ] **Step 4: 重置清空时间条件** `handleTransferSearchReset`(`index.vue:1762-1768`)加三行: ```ts function handleTransferSearchReset() { transferSearchForm.out_biz_no = ""; transferSearchForm.status = ""; transferSearchForm.tenant_id = undefined; transferSearchForm.enterprise_id = undefined; transferSearchForm.dateRange = null; transferSearchForm.start_time = undefined; transferSearchForm.end_time = undefined; handleTransferSearch(); } ``` - [ ] **Step 5: fetchTransferList 传时间参数** `index.vue:1773-1780` 的请求参数加两行: ```ts const res = await AccountAPI.transferList({ page_no: transferPage.page_no, page_size: transferPage.page_size, out_biz_no: transferSearchForm.out_biz_no || undefined, status: transferSearchForm.status || undefined, tenant_id: transferSearchForm.tenant_id || undefined, enterprise_id: transferSearchForm.enterprise_id || undefined, start_time: transferSearchForm.start_time || undefined, end_time: transferSearchForm.end_time || undefined, }); ``` - [ ] **Step 6: script 顶部引入 dayjs** `` 前)添加: ```ts const transferExportLoading = ref(false); async function handleExportTransfer() { transferExportLoading.value = true; try { const res = await AccountAPI.exportTransferReport({ out_biz_no: transferSearchForm.out_biz_no || undefined, status: transferSearchForm.status || undefined, tenant_id: transferSearchForm.tenant_id || undefined, enterprise_id: transferSearchForm.enterprise_id || undefined, start_time: transferSearchForm.start_time || undefined, end_time: transferSearchForm.end_time || undefined, }); const blob = new Blob([res.data], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "转账记录.xlsx"; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); ElMessage.success("导出成功"); } catch (error) { ElMessage.error("导出失败,请稍后重试"); } finally { transferExportLoading.value = false; } } ``` - [ ] **Step 5: 确认无残留引用** Run: `grep -nE "showDownloadMenu|customDateRange|selectedQuickOption|downloadLoading|handleDownload|calculateTimeRange|formatDateTime|handleCustomDateChange" src/views/module_payment/account/index.vue` Expected: 无输出(全部清理干净) - [ ] **Step 6: 类型检查** Run: `pnpm ts:check 2>&1 | grep -c "module_payment/account/index.vue"` Expected: 8(既有基线,无新增) - [ ] **Step 7: Commit** ```bash git add src/views/module_payment/account/index.vue && git commit -m "feat: 转账记录下载报表按钮接入导出接口(跟随表单筛选),清理旧导出死代码" ``` --- ### Task 6: 端到端验证(Playwright) **Files:** 无(验证任务,不产生代码改动) - [ ] **Step 1: 启动后端与前端** Run: `cd ../java && mvn spring-boot:run`(后台);`pnpm dev`(后台,默认端口 5173,具体以 `.env`/vite 配置为准) - [ ] **Step 2: Playwright 验证筛选联动** 用 Playwright MCP 登录后进入"资金账户 → 转账记录" tab: 1. 选择时间范围(如近 7 天)→ 点查询 → 断言列表只显示范围内数据 2. 组合筛选(状态=成功 + 时间范围)→ 查询 → 列表与筛选一致 3. 点重置 → 断言时间范围被清空、列表恢复全量 - [ ] **Step 3: Playwright 验证导出** 点击"下载报表"按钮,捕获下载事件: 1. 断言下载文件名为 `转账记录.xlsx` 2. 用脚本读取 xlsx(exceljs)验证:表头恰 11 列(含备注、失败原因);"创建时间"值为 `YYYY-MM-DD HH:mm:ss` 格式且为东8墙钟时间;"收款方类型"为中文(支付宝账户/银行卡);数据条数与当前筛选下的列表一致 - [ ] **Step 4: 回归检查** 快速检查同页其他功能(单笔转账、批量转账模板下载)不受影响;`pnpm ts:check` 全量错误数不高于改动前基线