|
|
@@ -57,7 +57,47 @@
|
|
|
</el-card>
|
|
|
|
|
|
<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>
|
|
|
@@ -78,9 +118,20 @@
|
|
|
<el-table-column label="已退(元)" width="110" align="right">
|
|
|
<template #default="{ row }">{{ fen2yuan(row.refunded_amount) }}</template>
|
|
|
</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">
|
|
|
<template #default="{ row }">
|
|
|
<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>
|
|
|
</el-table-column>
|
|
|
<el-table-column
|
|
|
@@ -99,7 +150,7 @@
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
<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 }">
|
|
|
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
|
|
<!--
|
|
|
@@ -107,9 +158,15 @@
|
|
|
其余状态(未支付 / 已关单 / 已全额退款)点了必然是 400 ——
|
|
|
与其给一个永远失败的按钮,不如不给。
|
|
|
★ 权限串是 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
|
|
|
- v-if="REFUNDABLE_STATUS.includes(row.status)"
|
|
|
+ v-else-if="REFUNDABLE_STATUS.includes(row.status)"
|
|
|
v-hasPerm="['module_payment:leshouying:order:refund']"
|
|
|
link
|
|
|
type="danger"
|
|
|
@@ -155,13 +212,16 @@
|
|
|
{{ detail.upstream_result_code }} {{ detail.upstream_result_msg }}
|
|
|
</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>
|
|
|
|
|
|
@@ -231,26 +291,32 @@
|
|
|
<span v-else>{{ processLabel(row.process_status) }}</span>
|
|
|
</template>
|
|
|
</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>
|
|
|
|
|
|
<!--
|
|
|
抽屉里也放一个退款入口:翻到这儿的人往往正是**看完明细才决定退**的,
|
|
|
让他退出去再找那一行是多余的一步。判据与列表那一列完全相同(同一个集合、同一个串)。
|
|
|
★ 传的是 `detail` 而不是打开抽屉时那一行:明细是刚拉的,列表行可能已经旧了。
|
|
|
+ ★★ 「退款在途」这一支同样要判 —— 少判这一处,列表刚藏掉的按钮会在抽屉里又亮出来
|
|
|
+ (这也是后端 `detail()` 也要填 `refunding` 的原因)。
|
|
|
-->
|
|
|
<template #footer>
|
|
|
+ <el-tag v-if="detail?.refunding" type="warning">
|
|
|
+ 这笔订单的退款正在处理中,到账前不能重复发起
|
|
|
+ </el-tag>
|
|
|
<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']"
|
|
|
type="danger"
|
|
|
:loading="refundingNo === detail.out_trade_no"
|
|
|
@@ -261,6 +327,123 @@
|
|
|
<el-button @click="detailVisible = false">关闭</el-button>
|
|
|
</template>
|
|
|
</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>
|
|
|
</template>
|
|
|
|
|
|
@@ -275,13 +458,15 @@ defineOptions({
|
|
|
inheritAttrs: false,
|
|
|
});
|
|
|
|
|
|
-import { computed, onMounted, reactive, ref } from "vue";
|
|
|
+import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
|
|
import { ElMessage, ElMessageBox } from "element-plus";
|
|
|
+import { Loading } from "@element-plus/icons-vue";
|
|
|
import {
|
|
|
getLsyOrderPage,
|
|
|
getLsyOrderDetail,
|
|
|
getLsyNotifyLogs,
|
|
|
getLsyOrderRefunds,
|
|
|
+ getLsyRefundDetail,
|
|
|
refundLsyOrder,
|
|
|
} from "@/api/module_payment/collection";
|
|
|
import TenantAPI, { type TenantTable } from "@/api/module_system/tenant";
|
|
|
@@ -406,6 +591,319 @@ const refunds = ref<any[]>([]);
|
|
|
/** 正在退款的那一单的**订单号**(不是 id)—— 列表行与抽屉用的是同一个键,两处的转圈才对得上。 */
|
|
|
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) {
|
|
|
return SCENE_LABEL[v] || v;
|
|
|
}
|
|
|
@@ -519,10 +1017,18 @@ async function openDetail(row: any) {
|
|
|
* 四个字段(out_trade_no / total_amount / refunded_amount / status)都在。
|
|
|
*/
|
|
|
async function handleRefund(row: any) {
|
|
|
+ // ★ 退款在途时按钮本就不渲染(列表与抽屉两处都判 `refunding`),这里是第三道:
|
|
|
+ // 万一有别的路径调进来(例如抽屉开着、列表在后台刷新过),也不能发第二笔。
|
|
|
+ // 后端还有自己的闸(在途闸门 + 预插入 + 部分唯一索引),重复点不会重复出金 ——
|
|
|
+ // 但让操作员在界面上撞一次 400 是体验问题,能免则免。
|
|
|
+ if (row?.refunding) {
|
|
|
+ ElMessage.warning("这笔订单有一笔退款正在处理中,等它出结果再发起下一笔");
|
|
|
+ return;
|
|
|
+ }
|
|
|
// 可退余额在这里只用于**预填**与「有没有得退」的早退。真正发出去的数是人填的那个,
|
|
|
// 而真正的余额校验在后端(它对台账与快照取 max,比这里的算式更严)。
|
|
|
- const remainingFen = (row?.total_amount || 0) - (row?.refunded_amount || 0);
|
|
|
- if (remainingFen <= 0) {
|
|
|
+ const leftFen = remainingFen(row);
|
|
|
+ if (leftFen <= 0) {
|
|
|
ElMessage.warning("这单没有可退余额(已退金额已达订单金额)");
|
|
|
return;
|
|
|
}
|
|
|
@@ -531,14 +1037,14 @@ async function handleRefund(row: any) {
|
|
|
try {
|
|
|
const { value } = await ElMessageBox.prompt(
|
|
|
`订单 ${row.out_trade_no}:共 ${fen2yuan(row.total_amount)} 元,` +
|
|
|
- `已退 ${fen2yuan(row.refunded_amount)} 元,可退 ${fen2yuan(remainingFen)} 元。` +
|
|
|
+ `已退 ${fen2yuan(row.refunded_amount)} 元,可退 ${fen2yuan(leftFen)} 元。` +
|
|
|
"金额默认是全部可退余额,改成更小的数是部分退款;" +
|
|
|
"提交成功只代表上游已受理,到账以退款单状态变为「退款成功」为准。",
|
|
|
"确认退款",
|
|
|
{
|
|
|
confirmButtonText: "确认退款",
|
|
|
cancelButtonText: "取消",
|
|
|
- inputValue: (remainingFen / 100).toFixed(2),
|
|
|
+ inputValue: (leftFen / 100).toFixed(2),
|
|
|
inputPattern: /^\d+(\.\d{1,2})?$/,
|
|
|
inputErrorMessage: "请填写金额,最多两位小数",
|
|
|
}
|
|
|
@@ -595,3 +1101,69 @@ onMounted(async () => {
|
|
|
}
|
|
|
});
|
|
|
</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>
|