|
|
@@ -73,9 +73,25 @@
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
<el-table-column prop="created_time" label="创建时间" width="180" />
|
|
|
- <el-table-column label="操作" width="100" fixed="right">
|
|
|
+ <el-table-column label="操作" width="150" fixed="right">
|
|
|
<template #default="{ row }">
|
|
|
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
|
|
+ <!--
|
|
|
+ 退款只在「可退」的状态上出现,判据与后端 LsyRefundService.REFUNDABLE 同一集合:
|
|
|
+ 其余状态(未支付 / 已关单 / 已全额退款)点了必然是 400 ——
|
|
|
+ 与其给一个永远失败的按钮,不如不给。
|
|
|
+ ★ 权限串是 refundLsyOrder 上方那段注释里说的**独立**串,不复用列表的 :list。
|
|
|
+ -->
|
|
|
+ <el-button
|
|
|
+ v-if="REFUNDABLE_STATUS.includes(row.status)"
|
|
|
+ v-hasPerm="['module_payment:leshouying:order:refund']"
|
|
|
+ link
|
|
|
+ type="danger"
|
|
|
+ :loading="refundingNo === row.out_trade_no"
|
|
|
+ @click="handleRefund(row)"
|
|
|
+ >
|
|
|
+ 退款
|
|
|
+ </el-button>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
</el-table>
|
|
|
@@ -200,6 +216,24 @@
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
</el-table>
|
|
|
+
|
|
|
+ <!--
|
|
|
+ 抽屉里也放一个退款入口:翻到这儿的人往往正是**看完明细才决定退**的,
|
|
|
+ 让他退出去再找那一行是多余的一步。判据与列表那一列完全相同(同一个集合、同一个串)。
|
|
|
+ ★ 传的是 `detail` 而不是打开抽屉时那一行:明细是刚拉的,列表行可能已经旧了。
|
|
|
+ -->
|
|
|
+ <template #footer>
|
|
|
+ <el-button
|
|
|
+ v-if="detail && REFUNDABLE_STATUS.includes(detail.status)"
|
|
|
+ v-hasPerm="['module_payment:leshouying:order:refund']"
|
|
|
+ type="danger"
|
|
|
+ :loading="refundingNo === detail.out_trade_no"
|
|
|
+ @click="handleRefund(detail)"
|
|
|
+ >
|
|
|
+ 退款
|
|
|
+ </el-button>
|
|
|
+ <el-button @click="detailVisible = false">关闭</el-button>
|
|
|
+ </template>
|
|
|
</el-drawer>
|
|
|
</div>
|
|
|
</template>
|
|
|
@@ -216,11 +250,13 @@ defineOptions({
|
|
|
});
|
|
|
|
|
|
import { onMounted, reactive, ref } from "vue";
|
|
|
+import { ElMessage, ElMessageBox } from "element-plus";
|
|
|
import {
|
|
|
getLsyOrderPage,
|
|
|
getLsyOrderDetail,
|
|
|
getLsyNotifyLogs,
|
|
|
getLsyOrderRefunds,
|
|
|
+ refundLsyOrder,
|
|
|
} from "@/api/module_payment/collection";
|
|
|
|
|
|
const STATUS_OPTIONS = [
|
|
|
@@ -279,6 +315,16 @@ const REFUND_STATUS_LABEL: Record<string, string> = {
|
|
|
FAIL: "退款失败",
|
|
|
};
|
|
|
|
|
|
+/**
|
|
|
+ * 「可以发起退款」的订单状态 —— 与后端 `LsyRefundService.REFUNDABLE` **同一个集合**。
|
|
|
+ *
|
|
|
+ * ★ 这是**前端这一侧的第二道**,不是闸:真正的闸在后端(开放 API 的调用方根本不经过这一页)。
|
|
|
+ * 这里拦的是体验 —— 给一个点了必然 400 的按钮,操作员会以为是系统坏了。
|
|
|
+ * ★ 后端加了新状态(例如「已部分退款」的某种细分)而这里没跟上的后果是:
|
|
|
+ * 那一类单**看不到退款按钮**(不是点错),所以漂移方向是安全的。
|
|
|
+ */
|
|
|
+const REFUNDABLE_STATUS = ["SUCCESS", "PARTIAL_REFUNDED"];
|
|
|
+
|
|
|
/** 查询参数用 camelCase:后端 `@RequestParam` 未写 name,线上名即 `pageNo` / `pageSize` */
|
|
|
const query = reactive({
|
|
|
pageNo: 1,
|
|
|
@@ -294,6 +340,8 @@ const detailVisible = ref(false);
|
|
|
const detail = ref<any>(null);
|
|
|
const notifyLogs = ref<any[]>([]);
|
|
|
const refunds = ref<any[]>([]);
|
|
|
+/** 正在退款的那一单的**订单号**(不是 id)—— 列表行与抽屉用的是同一个键,两处的转圈才对得上。 */
|
|
|
+const refundingNo = ref<string | undefined>();
|
|
|
|
|
|
function sceneLabel(v: string) {
|
|
|
return SCENE_LABEL[v] || v;
|
|
|
@@ -394,5 +442,72 @@ async function openDetail(row: any) {
|
|
|
detailVisible.value = true;
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * 发起退款(**动钱**)。
|
|
|
+ *
|
|
|
+ * 确认框里三件事必须写出来,少一件这个按钮就是在误导操作员:
|
|
|
+ * ① 金额默认是**全部可退余额**(改小即部分退款)—— 单位在这里是元,发给后端前转成分;
|
|
|
+ * ② 提交成功 ≠ 到账:拿到的是「退款中」,到账以状态变成「退款成功」为准(提示语照此写);
|
|
|
+ * ③ 权限是一枚**独立的**串(见 `refundLsyOrder` 的注释),没有它的人看不到这个按钮。
|
|
|
+ *
|
|
|
+ * 不做 HTML 拼接:单号走文本插值,不用 dangerouslyUseHTMLString。
|
|
|
+ * ★ `row` 既可能是列表行、也可能是抽屉里的 `detail` —— 两者都是订单实体,
|
|
|
+ * 四个字段(out_trade_no / total_amount / refunded_amount / status)都在。
|
|
|
+ */
|
|
|
+async function handleRefund(row: any) {
|
|
|
+ // 可退余额在这里只用于**预填**与「有没有得退」的早退。真正发出去的数是人填的那个,
|
|
|
+ // 而真正的余额校验在后端(它对台账与快照取 max,比这里的算式更严)。
|
|
|
+ const remainingFen = (row?.total_amount || 0) - (row?.refunded_amount || 0);
|
|
|
+ if (remainingFen <= 0) {
|
|
|
+ ElMessage.warning("这单没有可退余额(已退金额已达订单金额)");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ let input = "";
|
|
|
+ try {
|
|
|
+ const { value } = await ElMessageBox.prompt(
|
|
|
+ `订单 ${row.out_trade_no}:共 ${fen2yuan(row.total_amount)} 元,` +
|
|
|
+ `已退 ${fen2yuan(row.refunded_amount)} 元,可退 ${fen2yuan(remainingFen)} 元。` +
|
|
|
+ "金额默认是全部可退余额,改成更小的数是部分退款;" +
|
|
|
+ "提交成功只代表上游已受理,到账以退款单状态变为「退款成功」为准。",
|
|
|
+ "确认退款",
|
|
|
+ {
|
|
|
+ confirmButtonText: "确认退款",
|
|
|
+ cancelButtonText: "取消",
|
|
|
+ inputValue: (remainingFen / 100).toFixed(2),
|
|
|
+ inputPattern: /^\d+(\.\d{1,2})?$/,
|
|
|
+ inputErrorMessage: "请填写金额,最多两位小数",
|
|
|
+ }
|
|
|
+ );
|
|
|
+ input = value ?? "";
|
|
|
+ } catch {
|
|
|
+ return; // 取消(ElMessageBox 以 "cancel" reject),不发请求
|
|
|
+ }
|
|
|
+
|
|
|
+ // 元 → 分。`Math.round` 而不是 `* 100` 取整:0.29 * 100 在二进制浮点下是 28.999…。
|
|
|
+ const fen = Math.round(Number(input) * 100);
|
|
|
+ if (!Number.isFinite(fen) || fen <= 0) {
|
|
|
+ ElMessage.warning("退款金额必须大于 0");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ refundingNo.value = row.out_trade_no;
|
|
|
+ try {
|
|
|
+ const res: any = await refundLsyOrder(row.out_trade_no, fen);
|
|
|
+ const created = res.data.data;
|
|
|
+ // 照实说「已受理」:这一步之后退款单停在「退款中」,钱还没回到顾客账上。
|
|
|
+ ElMessage.success(
|
|
|
+ `退款已受理(${refundStatusLabel(created?.status)}),到账以状态变为「退款成功」为准`
|
|
|
+ );
|
|
|
+ if (detailVisible.value && detail.value?.out_trade_no === row.out_trade_no) {
|
|
|
+ // 抽屉正开着同一单:三块数据一起重拉,别让界面停在退款前的快照上
|
|
|
+ await openDetail({ out_trade_no: row.out_trade_no });
|
|
|
+ }
|
|
|
+ loadData();
|
|
|
+ } finally {
|
|
|
+ refundingNo.value = undefined;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
onMounted(loadData);
|
|
|
</script>
|