Просмотр исходного кода

feat(乐收赢): 订单页批量退款(跨分页勾选 + 进度遮罩轮询),并接上退款在途标记

用户 2026-09-30 提的三件事,都在这一页:

1) 跨分页勾选多笔退款,弹窗轮询 loading,「就像批量转账一样」——
   形态照 module_payment/account/index.vue 的批量转账:selection 列挂
   reserve-selection(翻页不丢勾选)、逐笔提交建退款单、进度遮罩 +
   el-progress 轮询到终态,出来再给一张结果表。
   与批量转账唯一的语义差别:转账金额来自 Excel(每行自带),
   这里**请求体不带 refund_fee** = 退剩余全额,剩余由服务端算
   (LsyRefundService 对台账与快照取 max)。轮询查的是**退款单**
   (/refund/{out_refund_no})不是订单:一个订单可以有多笔退款。
   勾选与行内按钮共用同一个判据 refundableRow,不写两份 ——
   写两份就会出现「勾得上但按钮不在」这种自相矛盾的界面。

2) 退款在途时不再显示退款按钮(用户报「点了退款,回调送达前按钮还在」)——
   列表行、订单详情抽屉两处都判 row.refunding(后端字段见
   LeshouyingOrderController#markRefunding),发起批量退款前再过滤一遍。
   行上同时显示一枚「退款中」tag:只看状态列看不出「正在退」。

3) 回调轨迹不再铺上游报文原文 —— 后端已把 raw_body 堵在序列化层
   (@JsonIgnore),这里把那块 <pre> 摘掉,只留可读的本地字段,
   并写明原话要去库里看(pay_lsy_notify_log.raw_body,已脱敏已截断)。
alphaH 2 недель назад
Родитель
Сommit
425b34487e
1 измененных файлов с 597 добавлено и 25 удалено
  1. 597 25
      frontend/src/views/module_payment/collection/order/index.vue

+ 597 - 25
frontend/src/views/module_payment/collection/order/index.vue

@@ -57,7 +57,47 @@
     </el-card>
     </el-card>
 
 
     <el-card shadow="never" style="margin-top: 12px">
     <el-card shadow="never" style="margin-top: 12px">
-      <el-table v-loading="loading" :data="list" border stripe>
+      <!--
+        批量退款工具条。★ 勾选是**跨分页保留**的(selection 列挂了 reserve-selection,
+        表格挂了 row-key)—— 翻页不会把已选的清掉,于是「第 1 页选两笔、第 3 页再选一笔」
+        能一次退掉。这一点必须在界面上说出来(右边的计数标签),否则操作员不敢翻页。
+      -->
+      <div class="batch-refund-toolbar">
+        <el-button
+          v-hasPerm="['module_payment:leshouying:order:refund']"
+          type="danger"
+          :disabled="selectedRows.length === 0"
+          @click="openBatchRefund"
+        >
+          批量退款{{ selectedRows.length ? `(${selectedRows.length})` : "" }}
+        </el-button>
+        <el-tag v-if="selectedRows.length" type="info" size="small">
+          已选 {{ selectedRows.length }} 笔(翻页保留)
+        </el-tag>
+        <el-button v-if="selectedRows.length" link type="primary" @click="clearSelection">
+          清空勾选
+        </el-button>
+        <span class="batch-refund-hint">
+          只能勾选「可退且当前没有退款在途」的订单;每笔按**可退余额**全额退。
+          部分退款请用行内的「退款」按钮逐笔填金额。
+        </span>
+      </div>
+
+      <el-table
+        ref="orderTableRef"
+        v-loading="loading"
+        :data="list"
+        border
+        stripe
+        row-key="out_trade_no"
+        @selection-change="handleSelectionChange"
+      >
+        <!--
+          多选列。`:selectable` 是这一页的**傻瓜式**闸:不可退 / 退款在途的行**压根勾不上**,
+          而不是勾上之后在提交时才报错。判据与行内那个退款按钮完全同一个函数
+          (`refundableRow`)—— 两处各写各的,就会出现「勾得上但按钮不在」这种自相矛盾的界面。
+        -->
+        <el-table-column type="selection" width="45" :selectable="refundableRow" reserve-selection />
         <!-- ★ 超管的列表是**跨租户混排**的(拦截器对他们不加谓词):没有这一列,
         <!-- ★ 超管的列表是**跨租户混排**的(拦截器对他们不加谓词):没有这一列,
              两笔不同租户的订单长得一模一样,筛选有没有生效也无法在界面上核对。 -->
              两笔不同租户的订单长得一模一样,筛选有没有生效也无法在界面上核对。 -->
         <el-table-column v-if="isSuperAdmin" label="租户" width="150" show-overflow-tooltip>
         <el-table-column v-if="isSuperAdmin" label="租户" width="150" show-overflow-tooltip>
@@ -78,9 +118,20 @@
         <el-table-column label="已退(元)" width="110" align="right">
         <el-table-column label="已退(元)" width="110" align="right">
           <template #default="{ row }">{{ fen2yuan(row.refunded_amount) }}</template>
           <template #default="{ row }">{{ fen2yuan(row.refunded_amount) }}</template>
         </el-table-column>
         </el-table-column>
+        <el-table-column label="可退(元)" width="110" align="right">
+          <template #default="{ row }">{{ fen2yuan(remainingFen(row)) }}</template>
+        </el-table-column>
         <el-table-column label="状态" width="130">
         <el-table-column label="状态" width="130">
           <template #default="{ row }">
           <template #default="{ row }">
             <el-tag :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
             <el-tag :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
+            <!--
+              ★ 退款在途时订单状态**一动都不动**(钱确实收过了,还是 SUCCESS)⇒
+                只看状态列看不出「正在退」。这枚标记是 `pay_lsy_refund` 里
+                有没有 status ∈ CREATED/REFUNDING 的行,由后端 `markRefunding` 填。
+            -->
+            <el-tag v-if="row.refunding" type="warning" size="small" style="margin-left: 4px">
+              退款中
+            </el-tag>
           </template>
           </template>
         </el-table-column>
         </el-table-column>
         <el-table-column
         <el-table-column
@@ -99,7 +150,7 @@
           </template>
           </template>
         </el-table-column>
         </el-table-column>
         <el-table-column prop="created_time" label="创建时间" width="180" />
         <el-table-column prop="created_time" label="创建时间" width="180" />
-        <el-table-column label="操作" width="150" fixed="right">
+        <el-table-column label="操作" width="170" fixed="right">
           <template #default="{ row }">
           <template #default="{ row }">
             <el-button link type="primary" @click="openDetail(row)">详情</el-button>
             <el-button link type="primary" @click="openDetail(row)">详情</el-button>
             <!--
             <!--
@@ -107,9 +158,15 @@
               其余状态(未支付 / 已关单 / 已全额退款)点了必然是 400 ——
               其余状态(未支付 / 已关单 / 已全额退款)点了必然是 400 ——
               与其给一个永远失败的按钮,不如不给。
               与其给一个永远失败的按钮,不如不给。
               ★ 权限串是 refundLsyOrder 上方那段注释里说的**独立**串,不复用列表的 :list。
               ★ 权限串是 refundLsyOrder 上方那段注释里说的**独立**串,不复用列表的 :list。
+              ★★ 2026-09-30:再加一条「退款不在途」。用户报的「我点了退款,回调送达前
+                 页面上还有退款按钮,难道还能重复点吗?」—— 后端有三道闸挡着,
+                 重复点**不会**重复出金,但界面不该引诱人去点。
+                 判据 `row.refunding` 来自后端(见 `LeshouyingOrderController#markRefunding`),
+                 与后端在途闸门是**同一个** LsyRefundStatus.PENDING 集合。
             -->
             -->
+            <el-tag v-if="row.refunding" type="warning" size="small">退款中</el-tag>
             <el-button
             <el-button
-              v-if="REFUNDABLE_STATUS.includes(row.status)"
+              v-else-if="REFUNDABLE_STATUS.includes(row.status)"
               v-hasPerm="['module_payment:leshouying:order:refund']"
               v-hasPerm="['module_payment:leshouying:order:refund']"
               link
               link
               type="danger"
               type="danger"
@@ -155,13 +212,16 @@
           {{ detail.upstream_result_code }} {{ detail.upstream_result_msg }}
           {{ detail.upstream_result_code }} {{ detail.upstream_result_msg }}
         </el-descriptions-item>
         </el-descriptions-item>
         <!--
         <!--
-          上游返回值只按**文本**渲染:不拼 innerHTML、不做模板、也不做成 href ——
-          `code_url` 是上游回的任意字符串,`:href` 会把它当 URL 打开(`javascript:` 前缀即等价于执行脚本)。
-          要恢复成可点击链接,应先加 http/https 协议白名单。
+          ★ 这里**故意没有**「支付链接」(`code_url`)这一行 —— 用户 2026-09-30 裁定:
+            订单详情不显示支付链接。别顺手补回来。
+            链接的用途是「把这笔单付掉」,而它只在**创建那一刻**有意义:
+            码有有效期、被扫单压根没有码,翻详情的人多半是在事后对账,
+            看到的是一条点不开也付不了的字符串。
+            真正需要链接的地方各自有入口(主扫下单后当场返回、收银台页当场显示)。
+          ★ 顺带记下当初为什么它不是链接:`code_url` 是上游回的任意字符串,
+            `:href` 会把它当 URL 打开(`javascript:` 前缀即等价于执行脚本)。
+            将来若真要在哪儿显示它,先加 http/https 协议白名单。
         -->
         -->
-        <el-descriptions-item v-if="detail.code_url" label="支付链接">
-          <span style="word-break: break-all">{{ detail.code_url }}</span>
-        </el-descriptions-item>
         <el-descriptions-item label="备注">{{ detail.description || "-" }}</el-descriptions-item>
         <el-descriptions-item label="备注">{{ detail.description || "-" }}</el-descriptions-item>
       </el-descriptions>
       </el-descriptions>
 
 
@@ -231,26 +291,32 @@
             <span v-else>{{ processLabel(row.process_status) }}</span>
             <span v-else>{{ processLabel(row.process_status) }}</span>
           </template>
           </template>
         </el-table-column>
         </el-table-column>
-        <el-table-column label="原文" min-width="200">
-          <template #default="{ row }">
-            <el-popover placement="left" :width="600" trigger="click">
-              <template #reference>
-                <el-button link type="primary">查看</el-button>
-              </template>
-              <pre style="max-height: 400px; overflow: auto">{{ row.raw_body }}</pre>
-            </el-popover>
-          </template>
-        </el-table-column>
+        <!--
+          ★ 这里**故意没有**「原文」这一列 —— 用户 2026-09-30 裁定:回调轨迹不显示原文。
+            别顺手补回来:那一列铺出来的是**上游自己的字段名**
+            (`alipay_order_no` / `trade_date` / `actual_money` …)和一份 JSON,
+            属上游信息,不该到使用者眼前。
+          ★ 后端也堵死了同一条路(`LsyNotifyLogEntity.rawBody` 挂 `@JsonIgnore`)——
+            只删这一列是不够的,报文照样随响应体进浏览器,F12 一看就有。
+            要查原话就查库:`pay_lsy_notify_log.raw_body`(已脱敏、已截断)。
+          ★ 这一列原本要回答的问题(「这次回调到底成没成、为什么没成」)由上面三列回答:
+            类型 / 验签 / 处理 —— 它们就是那份 JSON 的中文摘要,且不暴露上游字段。
+        -->
       </el-table>
       </el-table>
 
 
       <!--
       <!--
         抽屉里也放一个退款入口:翻到这儿的人往往正是**看完明细才决定退**的,
         抽屉里也放一个退款入口:翻到这儿的人往往正是**看完明细才决定退**的,
         让他退出去再找那一行是多余的一步。判据与列表那一列完全相同(同一个集合、同一个串)。
         让他退出去再找那一行是多余的一步。判据与列表那一列完全相同(同一个集合、同一个串)。
         ★ 传的是 `detail` 而不是打开抽屉时那一行:明细是刚拉的,列表行可能已经旧了。
         ★ 传的是 `detail` 而不是打开抽屉时那一行:明细是刚拉的,列表行可能已经旧了。
+        ★★ 「退款在途」这一支同样要判 —— 少判这一处,列表刚藏掉的按钮会在抽屉里又亮出来
+           (这也是后端 `detail()` 也要填 `refunding` 的原因)。
       -->
       -->
       <template #footer>
       <template #footer>
+        <el-tag v-if="detail?.refunding" type="warning">
+          这笔订单的退款正在处理中,到账前不能重复发起
+        </el-tag>
         <el-button
         <el-button
-          v-if="detail && REFUNDABLE_STATUS.includes(detail.status)"
+          v-else-if="detail && REFUNDABLE_STATUS.includes(detail.status)"
           v-hasPerm="['module_payment:leshouying:order:refund']"
           v-hasPerm="['module_payment:leshouying:order:refund']"
           type="danger"
           type="danger"
           :loading="refundingNo === detail.out_trade_no"
           :loading="refundingNo === detail.out_trade_no"
@@ -261,6 +327,123 @@
         <el-button @click="detailVisible = false">关闭</el-button>
         <el-button @click="detailVisible = false">关闭</el-button>
       </template>
       </template>
     </el-drawer>
     </el-drawer>
+
+    <!--
+      批量退款对话框。形态照 `account/index.vue` 的「批量转账」(进度遮罩 + el-progress
+      + 成功/失败/在途三个计数 + 结果对话框),用户 2026-09-30 的要求原话就是
+      「前端弹窗轮询loading,就像批量转账一样」。
+
+      ★ 与批量转账**唯一的**语义差别:转账的金额来自 Excel(每行自带),
+        而这里每笔退的是**该单的可退余额**,且金额**不由前端计算后发出** ——
+        请求体里不带 refund_fee,剩余由服务端算(`LsyRefundService` 对台账与快照取 max,
+        比页面上的算式更严)。页面上那一列只是**预计**,写明「以服务端为准」。
+    -->
+    <el-dialog
+      v-model="batchRefundVisible"
+      title="批量退款"
+      width="860px"
+      :close-on-click-modal="false"
+      :close-on-press-escape="false"
+    >
+      <div class="batch-refund-container" :class="{ 'has-overlay': batchRefundRunning }">
+        <el-alert type="warning" :closable="false" show-icon>
+          <template #title>
+            提交成功 ≠ 到账:每笔发出去后状态是「退款中」,要等上游回调才转成「退款成功」。
+            下方进度会**轮询**每笔的退款单状态直到终态(最多等 {{ BATCH_POLL_MAX_MINUTES }} 分钟)。
+          </template>
+        </el-alert>
+
+        <el-table :data="batchRefundRows" border stripe size="small" style="margin-top: 12px">
+          <el-table-column prop="out_trade_no" label="商户订单号" min-width="190" show-overflow-tooltip />
+          <el-table-column label="场景" width="70">
+            <template #default="{ row }">{{ sceneLabel(row.pay_scene) }}</template>
+          </el-table-column>
+          <el-table-column label="订单金额(元)" width="110" align="right">
+            <template #default="{ row }">{{ fen2yuan(row.total_amount) }}</template>
+          </el-table-column>
+          <el-table-column label="已退(元)" width="100" align="right">
+            <template #default="{ row }">{{ fen2yuan(row.refunded_amount) }}</template>
+          </el-table-column>
+          <el-table-column label="预计退款(元)" width="120" align="right">
+            <template #default="{ row }">{{ fen2yuan(remainingFen(row)) }}</template>
+          </el-table-column>
+          <el-table-column label="状态" width="110">
+            <template #default="{ row }">
+              <el-tag :type="batchRowTagType(row.out_trade_no)" size="small">
+                {{ batchRowLabel(row.out_trade_no) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+        </el-table>
+
+        <!-- 遮罩:形态照批量转账(pointer-events 关在容器上,防止处理中改表单) -->
+        <div v-if="batchRefundRunning" class="batch-refund-overlay">
+          <div class="overlay-content">
+            <el-icon class="is-loading" size="40"><Loading /></el-icon>
+            <h3>正在处理批量退款,请勿关闭对话框</h3>
+            <!--
+              ★ 进度条量的是**已到终态的笔数**,不是「已提交的笔数」。
+                提交阶段它一直停在 0% —— 这是诚实的:钱一笔都还没退回顾客。
+                「已提交」另有那个 warning 标签在数。
+            -->
+            <el-progress :percentage="batchRefundPercent" :stroke-width="20" />
+            <p>
+              {{ batchRefundPhase === "submitting" ? "正在发起退款" : "正在等待退款结果" }}:
+              {{ batchRefundCurrentNo || "-" }}
+            </p>
+            <div class="result-summary">
+              <el-tag type="success">成功: {{ batchRefundSuccessCount }}</el-tag>
+              <el-tag type="danger">失败: {{ batchRefundFailCount }}</el-tag>
+              <el-tag type="warning">退款中: {{ batchRefundPendingCount }}</el-tag>
+              <el-tag type="info">已提交: {{ batchRefundSubmitted }}/{{ batchRefundRows.length }}</el-tag>
+            </div>
+          </div>
+        </div>
+      </div>
+      <template #footer>
+        <el-button :disabled="batchRefundRunning" @click="closeBatchRefundDialog">取消</el-button>
+        <el-button
+          type="primary"
+          :loading="batchRefundRunning"
+          :disabled="batchRefundRows.length === 0"
+          @click="handleBatchRefund"
+        >
+          开始批量退款({{ batchRefundRows.length }} 笔)
+        </el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="batchRefundResultVisible" title="批量退款结果" width="760px">
+      <div class="card-header" style="margin-bottom: 8px">
+        <el-tag type="success" style="margin-right: 8px">{{ batchRefundSuccessCount }} 成功</el-tag>
+        <el-tag type="danger" style="margin-right: 8px">{{ batchRefundFailCount }} 失败</el-tag>
+        <el-tag type="warning">退款中: {{ batchRefundPendingCount }}</el-tag>
+      </div>
+      <!--
+        ★ 超时那条腿也必须说话:轮询到上限仍有未到终态的笔数时,这里给的是
+          「还在退款中,稍后刷新查看」——**不**谎报成功,也不谎报失败。
+          (谎报的代价:操作员按「失败」再退一次。)
+      -->
+      <el-alert v-if="batchRefundTimedOut" type="warning" :closable="false" show-icon
+        title="仍有退款未到终态,已停止等待。它们仍在处理中,请稍后刷新页面查看,不要重复发起。" />
+      <el-table :data="batchRefundResults" border stripe>
+        <el-table-column prop="out_trade_no" label="商户订单号" min-width="190" show-overflow-tooltip />
+        <el-table-column label="退款金额(元)" width="120" align="right">
+          <template #default="{ row }">{{ fen2yuan(row.refund_fee) }}</template>
+        </el-table-column>
+        <el-table-column label="结果" width="110">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 'success' ? 'success' : row.status === 'fail' ? 'danger' : 'warning'">
+              {{ row.status === "success" ? "退款成功" : row.status === "fail" ? "失败" : "退款中" }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="message" label="说明" min-width="200" show-overflow-tooltip />
+      </el-table>
+      <template #footer>
+        <el-button @click="batchRefundResultVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
   </div>
   </div>
 </template>
 </template>
 
 
@@ -275,13 +458,15 @@ defineOptions({
   inheritAttrs: false,
   inheritAttrs: false,
 });
 });
 
 
-import { computed, onMounted, reactive, ref } from "vue";
+import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
 import { ElMessage, ElMessageBox } from "element-plus";
+import { Loading } from "@element-plus/icons-vue";
 import {
 import {
   getLsyOrderPage,
   getLsyOrderPage,
   getLsyOrderDetail,
   getLsyOrderDetail,
   getLsyNotifyLogs,
   getLsyNotifyLogs,
   getLsyOrderRefunds,
   getLsyOrderRefunds,
+  getLsyRefundDetail,
   refundLsyOrder,
   refundLsyOrder,
 } from "@/api/module_payment/collection";
 } from "@/api/module_payment/collection";
 import TenantAPI, { type TenantTable } from "@/api/module_system/tenant";
 import TenantAPI, { type TenantTable } from "@/api/module_system/tenant";
@@ -406,6 +591,319 @@ const refunds = ref<any[]>([]);
 /** 正在退款的那一单的**订单号**(不是 id)—— 列表行与抽屉用的是同一个键,两处的转圈才对得上。 */
 /** 正在退款的那一单的**订单号**(不是 id)—— 列表行与抽屉用的是同一个键,两处的转圈才对得上。 */
 const refundingNo = ref<string | undefined>();
 const refundingNo = ref<string | undefined>();
 
 
+/** 表格实例:只为了 `clearSelection()`(跨页保留的勾选不会自己清)。 */
+const orderTableRef = ref<any>();
+/** 当前勾选的行。★ 因为挂了 `reserve-selection`,这里面**含其它页**的行。 */
+const selectedRows = ref<any[]>([]);
+
+function handleSelectionChange(rows: any[]) {
+  selectedRows.value = rows;
+}
+function clearSelection() {
+  orderTableRef.value?.clearSelection();
+}
+
+/**
+ * 可退余额(分)。**只用于显示与「勾不勾得上」**,真正的金额由服务端算
+ * (`LsyRefundService` 对退款台账与订单快照取 max,比这里的减法更严 ——
+ * 快照可能滞后于台账)。负数收敛成 0:超退的历史脏数据不该显示成「-1.00 元可退」。
+ */
+function remainingFen(row: any) {
+  const left = (row?.total_amount || 0) - (row?.refunded_amount || 0);
+  return left > 0 ? left : 0;
+}
+
+/**
+ * 这一行**能不能**被选中退款 —— 多选列的 `:selectable` 与行内退款按钮的 `v-if` 是同一个判据。
+ *
+ * ★ 两处共用同一个函数是刻意的:各写各的就会出现「勾得上但行里没有退款按钮」这种
+ *   自相矛盾的界面,而操作员只会得出「这系统坏了」。
+ * ★ `!row.refunding` 那一条是本轮修的(用户 2026-09-30:退款在途时按钮还亮着):
+ *   退款在途期间订单状态一动不动(还是 SUCCESS),只看状态判不出来。
+ */
+function refundableRow(row: any) {
+  return REFUNDABLE_STATUS.includes(row?.status) && !row?.refunding && remainingFen(row) > 0;
+}
+
+// ─────────────────────── 批量退款(跨页勾选 + 轮询) ───────────────────────
+
+/**
+ * 轮询节奏与上限。**两个数都在这里定,别散在逻辑里**:
+ * 3 秒 × 40 次 = 2 分钟,与对话框上写给操作员的那句话(`BATCH_POLL_MAX_MINUTES`)同源。
+ *
+ * ★ 为什么必须**有**上限:上游回调可能永远不来(下游地址没配、通道挂了)。
+ *   没有上限就是一个永远转圈、关不掉的对话框,而钱的事最怕「界面卡住所以我不知道」。
+ */
+const BATCH_POLL_INTERVAL_MS = 3000;
+const BATCH_POLL_MAX_TICKS = 40;
+const BATCH_POLL_MAX_MINUTES = Math.round((BATCH_POLL_INTERVAL_MS * BATCH_POLL_MAX_TICKS) / 60000);
+
+const batchRefundVisible = ref(false);
+const batchRefundResultVisible = ref(false);
+/** 处理中(提交 + 轮询两段都算)—— 遮罩、按钮 disabled、不许关窗都看它。 */
+const batchRefundRunning = ref(false);
+const batchRefundPhase = ref<"submitting" | "polling">("submitting");
+const batchRefundCurrentNo = ref("");
+const batchRefundSubmitted = ref(0);
+/** 到上限仍未出终态 —— 结果框要为此说一句实话(不谎报成功,也不谎报失败)。 */
+const batchRefundTimedOut = ref(false);
+
+/** 打开对话框那一刻勾选行的**快照**:处理过程中表格刷新不该让预览表在眼前变形。 */
+const batchRefundRows = ref<any[]>([]);
+/**
+ * 每一笔的结果。`status` 三个取值:`processing`(已受理未终态)/ `success` / `fail`。
+ * 三个计数**都是从它算出来的**(computed),不是各自累加的计数器 ——
+ * 计数器 + 轮询改写状态 = 同一件事存两份,两份迟早不一致。
+ */
+const batchRefundResults = ref<any[]>([]);
+
+const batchRefundSuccessCount = computed(
+  () => batchRefundResults.value.filter((r) => r.status === "success").length
+);
+const batchRefundFailCount = computed(
+  () => batchRefundResults.value.filter((r) => r.status === "fail").length
+);
+const batchRefundPendingCount = computed(
+  () => batchRefundResults.value.filter((r) => r.status === "processing").length
+);
+/**
+ * 进度条 = **已到终态的笔数 / 总数**。
+ * 提交阶段它停在 0% 是**如实**的:钱一笔都还没退回顾客,只是请求发出去了。
+ * 「已提交」另有那个 info 标签在数。
+ */
+const batchRefundPercent = computed(() => {
+  const total = batchRefundRows.value.length;
+  if (!total) return 0;
+  const terminal = batchRefundResults.value.filter(
+    (r) => r.status === "success" || r.status === "fail"
+  ).length;
+  return Math.min(100, Math.round((terminal / total) * 100));
+});
+
+let batchPollTimer: ReturnType<typeof setInterval> | undefined;
+let batchPollTicks = 0;
+let batchPollBusy = false;
+
+function batchResultOf(outTradeNo: string) {
+  return batchRefundResults.value.find((r) => r.out_trade_no === outTradeNo);
+}
+function batchRowLabel(outTradeNo: string) {
+  const r = batchResultOf(outTradeNo);
+  if (!r) return batchRefundRunning.value ? "排队中" : "待发起";
+  if (r.status === "success") return "退款成功";
+  if (r.status === "fail") return "失败";
+  return "退款中";
+}
+function batchRowTagType(outTradeNo: string) {
+  const r = batchResultOf(outTradeNo);
+  if (!r) return "info";
+  if (r.status === "success") return "success";
+  if (r.status === "fail") return "danger";
+  return "warning";
+}
+
+function openBatchRefund() {
+  // 勾选里可能夹着**开单之后状态变了**的行(页面是跨页保留勾选的,那一页可能已经刷新过)。
+  // 这里再滤一遍 —— `:selectable` 只管「点的时候」,管不到「点完之后数据变了」。
+  const rows = selectedRows.value.filter(refundableRow);
+  if (rows.length === 0) {
+    ElMessage.warning("请先勾选可退款的订单(只有「可退」且当前没有退款在途的订单能勾)");
+    return;
+  }
+  if (rows.length !== selectedRows.value.length) {
+    ElMessage.warning(
+      `已跳过 ${selectedRows.value.length - rows.length} 笔不可退/退款在途的订单`
+    );
+  }
+  batchRefundRows.value = rows.map((r) => ({ ...r }));
+  batchRefundResults.value = [];
+  batchRefundSubmitted.value = 0;
+  batchRefundTimedOut.value = false;
+  batchRefundCurrentNo.value = "";
+  batchRefundPhase.value = "submitting";
+  batchRefundResultVisible.value = false;
+  batchRefundVisible.value = true;
+}
+
+/**
+ * 开始批量退款:**先逐笔提交,再轮询到终态**。
+ *
+ * ★ 为什么提交是**顺序**的而不是 `Promise.all`:这是动钱的接口,串行能让
+ *   「第几笔出的问题」在界面上是可读的(同时发 20 个,报错回来是一团)。
+ *   批量转账也是这么写的。
+ * ★ 为什么提交之后还要**轮询**:这一步拿到的是「已受理」,不是「已到账」。
+ *   不轮询的界面会在点完之后立刻显示「成功」,而用户上一轮反馈的正是
+ *   「我点了退款,回调送达前页面上还有退款按钮」——同一个病根:把「已受理」当成了「已完成」。
+ */
+async function handleBatchRefund() {
+  const total = batchRefundRows.value.length;
+  if (total === 0 || batchRefundRunning.value) {
+    return;
+  }
+  try {
+    await ElMessageBox.confirm(
+      `确定要退款 ${total} 笔吗?每笔按**该单的可退余额**全额退(金额以服务端计算为准)。` +
+        "提交后状态是「退款中」,要等上游回调才变成「退款成功」。",
+      "确认批量退款",
+      { confirmButtonText: "确认退款", cancelButtonText: "取消", type: "warning" }
+    );
+  } catch {
+    return; // 取消(ElMessageBox 以 "cancel" reject),一笔都不发
+  }
+
+  batchRefundRunning.value = true;
+  batchRefundPhase.value = "submitting";
+  batchRefundResults.value = [];
+  batchRefundSubmitted.value = 0;
+  batchRefundTimedOut.value = false;
+
+  try {
+    for (const row of batchRefundRows.value) {
+      batchRefundCurrentNo.value = row.out_trade_no;
+      try {
+        // ★ **不带金额** = 退剩余全额,剩余由服务端算(见 refundLsyOrder 的注释)。
+        //   页面上的「预计退款」只用于预览:把前端算的数发出去,就有两份式子会漂移。
+        const res: any = await refundLsyOrder(row.out_trade_no);
+        const created = res.data.data;
+        batchRefundResults.value.push({
+          out_trade_no: row.out_trade_no,
+          refund_fee: created?.refund_fee ?? remainingFen(row),
+          out_refund_no: created?.out_refund_no || "",
+          status: "processing",
+          message: "已受理,等待上游结果",
+        });
+      } catch (e: any) {
+        // 业务前置校验不通过(状态不可退 / 有退款在途 / 超余额)在这里是 HTTP 200 + code 400,
+        // 经响应拦截器变成一次 error toast ⇒ 这里**不重试**,照原样记下来给操作员看。
+        batchRefundResults.value.push({
+          out_trade_no: row.out_trade_no,
+          refund_fee: remainingFen(row),
+          out_refund_no: "",
+          status: "fail",
+          message: e?.message || "发起失败",
+        });
+      }
+      batchRefundSubmitted.value++;
+    }
+
+    batchRefundPhase.value = "polling";
+    if (batchRefundPendingCount.value > 0) {
+      batchRefundCurrentNo.value = "";
+      startBatchRefundPolling(); // 收尾在轮询那一侧(stop + finish),这里不能提前关 running
+      return;
+    }
+    await finishBatchRefund();
+  } catch (e: any) {
+    // 走到这里只可能是**意外**(每一笔自己的错都在上面接住了):
+    // 停下来并说话,不要让遮罩挂在界面上。
+    stopBatchRefundPolling();
+    batchRefundRunning.value = false;
+    ElMessage.error(e?.message || "批量退款过程出错");
+  }
+}
+
+function startBatchRefundPolling() {
+  stopBatchRefundPolling();
+  batchPollTicks = 0;
+  batchPollTimer = setInterval(() => {
+    void pollBatchRefundOnce();
+  }, BATCH_POLL_INTERVAL_MS);
+}
+
+function stopBatchRefundPolling() {
+  if (batchPollTimer !== undefined) {
+    clearInterval(batchPollTimer);
+    batchPollTimer = undefined;
+  }
+}
+
+/**
+ * 一轮轮询:把每一笔「已受理」的退款单查一次,改写它自己的状态。
+ *
+ * ★ 查的是**退款单**(`/refund/{out_refund_no}`),不是订单:一个订单可以有多笔退款,
+ *   拿订单去问「退到哪一步了」答不出是哪一笔。
+ * ★ ★ 查询失败**不等于**退款失败:网络抖一下就把一笔正在退的钱标成「失败」,
+ *   操作员下一步就是再退一次。所以单笔查询失败只保留 processing,下一轮再试。
+ */
+async function pollBatchRefundOnce() {
+  // 上一轮还没回来就跳过这一轮。弱网下 setInterval 会叠请求,
+  // 而每一轮都是 N 条按单号的查询 —— 叠起来就是 N×k 条,且它们的结果还会互相覆盖。
+  if (batchPollBusy) {
+    return;
+  }
+  batchPollBusy = true;
+  try {
+    batchPollTicks++;
+    for (const r of batchRefundResults.value.filter((x) => x.status === "processing")) {
+      if (!r.out_refund_no) {
+        // 拿不到退款单号就**没有定位键**,轮询不了。不标失败(钱可能已经退在路上了),
+        // 也不能一直假装在查 —— 说清楚要人工看。它会一直算作「退款中」,直到上限。
+        r.message = "已受理,但未拿到退款单号,请稍后刷新页面查看";
+        continue;
+      }
+      try {
+        const res: any = await getLsyRefundDetail(r.out_refund_no);
+        const st = res.data.data?.status;
+        if (st === "SUCCESS") {
+          r.status = "success";
+          r.message = "退款成功,已到账";
+        } else if (st === "FAIL") {
+          r.status = "fail";
+          r.message = "退款失败,可稍后重试";
+        } else {
+          r.message = `上游处理中(${refundStatusLabel(st)})`;
+        }
+      } catch {
+        // 见方法头 ★:查询失败不是退款失败,保留 processing 下一轮再试。
+      }
+    }
+
+    if (batchRefundPendingCount.value === 0) {
+      await finishBatchRefund();
+      return;
+    }
+    if (batchPollTicks >= BATCH_POLL_MAX_TICKS) {
+      batchRefundTimedOut.value = true;
+      await finishBatchRefund();
+    }
+  } finally {
+    batchPollBusy = false;
+  }
+}
+
+/** 收尾:停轮询、撤遮罩、刷新列表与勾选、开结果框。提交阶段全失败时也走这里(不必轮询)。 */
+async function finishBatchRefund() {
+  stopBatchRefundPolling();
+  batchRefundRunning.value = false;
+  batchRefundPhase.value = "submitting";
+  batchRefundCurrentNo.value = "";
+  try {
+    await loadData(); // 列表要跟着变:refunding 标记、已退金额、状态
+  } catch {
+    // 列表刷新失败不影响结果框 —— 结果框里的数据本来就是本地累积的,
+    // 不能因为一次刷新失败就让操作员看不到哪些成功、哪些失败。
+  }
+  clearSelection(); // ★ 勾选跨页保留 —— 不主动清,退过的单会一直挂在勾选里
+  batchRefundResultVisible.value = true;
+  batchRefundVisible.value = false;
+}
+
+function closeBatchRefundDialog() {
+  // 处理中不许关(按钮已 disabled,这是第二道)。关掉只会让轮询失去界面,
+  // 而请求**已经发出去了** —— 界面消失不等于事情停下。
+  if (batchRefundRunning.value) {
+    return;
+  }
+  stopBatchRefundPolling();
+  batchRefundVisible.value = false;
+}
+
+onBeforeUnmount(() => {
+  // ★ 必须清:组件被 keep-alive 缓存、或路由切走时定时器**不会**自己停,
+  //   而它每一轮都在打后端(且回调里还会改已经卸掉的组件的状态)。
+  stopBatchRefundPolling();
+});
+
 function sceneLabel(v: string) {
 function sceneLabel(v: string) {
   return SCENE_LABEL[v] || v;
   return SCENE_LABEL[v] || v;
 }
 }
@@ -519,10 +1017,18 @@ async function openDetail(row: any) {
  *   四个字段(out_trade_no / total_amount / refunded_amount / status)都在。
  *   四个字段(out_trade_no / total_amount / refunded_amount / status)都在。
  */
  */
 async function handleRefund(row: any) {
 async function handleRefund(row: any) {
+  // ★ 退款在途时按钮本就不渲染(列表与抽屉两处都判 `refunding`),这里是第三道:
+  //   万一有别的路径调进来(例如抽屉开着、列表在后台刷新过),也不能发第二笔。
+  //   后端还有自己的闸(在途闸门 + 预插入 + 部分唯一索引),重复点不会重复出金 ——
+  //   但让操作员在界面上撞一次 400 是体验问题,能免则免。
+  if (row?.refunding) {
+    ElMessage.warning("这笔订单有一笔退款正在处理中,等它出结果再发起下一笔");
+    return;
+  }
   // 可退余额在这里只用于**预填**与「有没有得退」的早退。真正发出去的数是人填的那个,
   // 可退余额在这里只用于**预填**与「有没有得退」的早退。真正发出去的数是人填的那个,
   // 而真正的余额校验在后端(它对台账与快照取 max,比这里的算式更严)。
   // 而真正的余额校验在后端(它对台账与快照取 max,比这里的算式更严)。
-  const remainingFen = (row?.total_amount || 0) - (row?.refunded_amount || 0);
-  if (remainingFen <= 0) {
+  const leftFen = remainingFen(row);
+  if (leftFen <= 0) {
     ElMessage.warning("这单没有可退余额(已退金额已达订单金额)");
     ElMessage.warning("这单没有可退余额(已退金额已达订单金额)");
     return;
     return;
   }
   }
@@ -531,14 +1037,14 @@ async function handleRefund(row: any) {
   try {
   try {
     const { value } = await ElMessageBox.prompt(
     const { value } = await ElMessageBox.prompt(
       `订单 ${row.out_trade_no}:共 ${fen2yuan(row.total_amount)} 元,` +
       `订单 ${row.out_trade_no}:共 ${fen2yuan(row.total_amount)} 元,` +
-        `已退 ${fen2yuan(row.refunded_amount)} 元,可退 ${fen2yuan(remainingFen)} 元。` +
+        `已退 ${fen2yuan(row.refunded_amount)} 元,可退 ${fen2yuan(leftFen)} 元。` +
         "金额默认是全部可退余额,改成更小的数是部分退款;" +
         "金额默认是全部可退余额,改成更小的数是部分退款;" +
         "提交成功只代表上游已受理,到账以退款单状态变为「退款成功」为准。",
         "提交成功只代表上游已受理,到账以退款单状态变为「退款成功」为准。",
       "确认退款",
       "确认退款",
       {
       {
         confirmButtonText: "确认退款",
         confirmButtonText: "确认退款",
         cancelButtonText: "取消",
         cancelButtonText: "取消",
-        inputValue: (remainingFen / 100).toFixed(2),
+        inputValue: (leftFen / 100).toFixed(2),
         inputPattern: /^\d+(\.\d{1,2})?$/,
         inputPattern: /^\d+(\.\d{1,2})?$/,
         inputErrorMessage: "请填写金额,最多两位小数",
         inputErrorMessage: "请填写金额,最多两位小数",
       }
       }
@@ -595,3 +1101,69 @@ onMounted(async () => {
   }
   }
 });
 });
 </script>
 </script>
+
+<style lang="scss" scoped>
+.batch-refund-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 10px;
+
+  .batch-refund-hint {
+    color: var(--el-text-color-secondary);
+    font-size: 12px;
+    line-height: 1.4;
+  }
+}
+
+/*
+  遮罩:形态照 `account/index.vue` 的批量转账(绝对定位盖住容器 + `pointer-events: none`
+  由 `has-overlay` 关在容器上)。处理中**不许**再去改上面的内容 —— 那是一份
+  已经发出去的退款清单,改它等于让界面和事实不符。
+*/
+.batch-refund-container {
+  position: relative;
+
+  &.has-overlay {
+    pointer-events: none;
+  }
+}
+
+.batch-refund-overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(255, 255, 255, 0.95);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 10;
+  border-radius: 8px;
+
+  .overlay-content {
+    text-align: center;
+    padding: 40px;
+    width: 100%;
+
+    h3 {
+      margin: 20px 0;
+      color: #303133;
+    }
+
+    p {
+      margin: 15px 0;
+      color: #606266;
+    }
+
+    .result-summary {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 15px;
+      justify-content: center;
+      margin-top: 20px;
+    }
+  }
+}
+</style>