Răsfoiți Sursa

feat(乐收赢): 订单抽屉显示已退金额与退款单

用户 2026-09-29 报:「订单的状态是已全额退款,但是详情中没有显示退款之类的东西」。

已退金额这一行**不需要后端改动** —— `refunded_amount` 本来就在详情响应里
(`LsyOrderEntity.refundedAmount`),缺的只是没人渲染它。补一行。

「退款单」一节用新端点 `getLsyOrderRefunds`,五列:商户退款单号 / 金额 / 状态 /
退款单号 / 完成时间,整节 `v-if="refunds.length"` —— 空表会让每一笔正常支付都拖
一节「没有退款」的空架子,而这一节存在的意义恰恰是回答「这笔到底退没退」。

几处刻意选择,都写进代码注释了:

- **不拿 `getLsyRefundPage({keyword})` 代替**:那条腿是 LIKE,给的是「订单号包含
  本单号」的退款单,抽屉要的是「属于本单的」。订单号互为前缀时两者不同。
- 状态中文映射逐个取自 `LsyRefundStatus` 枚举源码,措辞与退款列表页一致 ——
  同一份状态在两个页面叫两个名字,排查时会对不上。
- 完成时间**不引入 dayjs 格式化**(本仓有 `formatToDateTime`):同一个抽屉里
  「回调轨迹」那节显示的是 Jackson 原文,两节时间长得不一样比原样显示更难读。
  宽度 180 与该节、以及订单列表的「创建时间」取齐。
- 完成时间挂 `show-overflow-tooltip`:实测值是**微秒** 6 位
  (`2026-09-29T11:04:13.007374+08:00`,32 字符),比按毫秒估的多 3 个字符;
  截断至少能悬停看全,不是无声丢字。
- `openDetail` 三条 ref **先清空再赋值**:抽屉实例是复用的,任何一条请求抛异常,
  上一次的内容会原样留着,而它看起来完全像「这一笔的数据」。

验证(本仓 `frontend/` 无测试框架,`npm run ts:check` 有 289 条历史遗留基线,
命令恒 exit 2 ⇒ 只能同机前后差分):
- 改后 289 条,与基线**逐字节相同**;两个被改文件里 0 条。
- 前后端 URL 契约静态对照:后端 `@RequestMapping("/payment/collection/order")` +
  `@GetMapping("/{outTradeNo}/refunds")` ↔ 前端 `/payment/collection/order/${outTradeNo}/refunds`。
- ★ 模板**未被渲染验证** —— vue-tsc 只查表达式类型,不证明浏览器里画得出来。
  这一步留给最终验收。
alphaH 2 săptămâni în urmă
părinte
comite
436df3b52d

+ 14 - 0
frontend/src/api/module_payment/collection.ts

@@ -43,6 +43,20 @@ export function getLsyNotifyLogs(outTradeNo: string) {
   });
 }
 
+/**
+ * 本单的退款单 —— 订单抽屉的「退款」一节用它。
+ *
+ * ★ 不要拿 `getLsyRefundPage({ keyword })` 代替:那条腿是 `LIKE out_trade_no`,
+ *   给的是「订单号**包含**本单号」的退款单,而抽屉要的是「**属于**本单的」。
+ *   两者在订单号互为前缀时会给出不同的行,且客户端无法把它收敛回去。
+ */
+export function getLsyOrderRefunds(outTradeNo: string) {
+  return request({
+    url: `/payment/collection/order/${outTradeNo}/refunds`,
+    method: "get",
+  });
+}
+
 // ==================== 退款 ====================
 export function getLsyRefundPage(params: {
   pageNo: number;

+ 85 - 0
frontend/src/views/module_payment/collection/order/index.vue

@@ -101,6 +101,14 @@
         <el-descriptions-item label="金额">
           {{ fen2yuan(detail.total_amount) }} 元 / 实收 {{ fen2yuan(detail.receipt_amount) }} 元
         </el-descriptions-item>
+        <!--
+          ★ 已退金额**本来就在详情响应里**(`LsyOrderEntity.refundedAmount`),只是此前没人渲染它:
+            状态列写着「已全额退款」而详情里一个字都看不到退款,用户 2026-09-29 报的正是这个。
+            这一行不需要后端改动 —— 缺的是显示。
+        -->
+        <el-descriptions-item label="已退金额">
+          {{ fen2yuan(detail.refunded_amount) }} 元
+        </el-descriptions-item>
         <el-descriptions-item label="支付结果">
           {{ detail.upstream_result_code }} {{ detail.upstream_result_msg }}
         </el-descriptions-item>
@@ -115,6 +123,51 @@
         <el-descriptions-item label="备注">{{ detail.description || "-" }}</el-descriptions-item>
       </el-descriptions>
 
+      <!--
+        退款单只在**有**的时候出现。空表会让每一笔正常支付都拖一节「没有退款」的空架子,
+        而这一节存在的意义恰恰是回答「这笔到底退没退、退了哪几笔」。
+      -->
+      <template v-if="refunds.length">
+        <h4 style="margin-top: 20px">退款单</h4>
+        <el-table :data="refunds" border size="small">
+          <el-table-column
+            prop="out_refund_no"
+            label="商户退款单号"
+            min-width="150"
+            show-overflow-tooltip
+          />
+          <el-table-column label="金额(元)" width="90" align="right">
+            <template #default="{ row }">{{ fen2yuan(row.refund_fee) }}</template>
+          </el-table-column>
+          <el-table-column label="状态" width="90">
+            <template #default="{ row }">
+              <el-tag :type="refundTagType(row.status)" size="small">
+                {{ refundStatusLabel(row.status) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <!--
+            上游退款单号可能是空的:它只在**同步应答**里才学到(预插入的那一行还没拿到)。
+            空串在这里会看起来像「退款单号丢了」,所以显式渲染成 -。
+          -->
+          <el-table-column label="退款单号" min-width="160" show-overflow-tooltip>
+            <template #default="{ row }">{{ row.refund_no || "-" }}</template>
+          </el-table-column>
+          <!--
+            宽度 180 与同抽屉「回调轨迹」的时间列、以及上方订单列表的「创建时间」一致 ——
+            这三处显示的是同一种值,宽度不该各写各的。
+            ★ 值的形态是**实测**的,不是估的:HTTP 层实收
+              `2026-09-29T11:04:13.007374+08:00`(**微秒** 6 位,共 32 字符)。
+              按「毫秒 3 位」估会少算 3 个字符、窄列里静默截断。
+              所以这里挂 show-overflow-tooltip:截断至少能悬停看全,不是无声丢字。
+            本页**不**引入 dayjs 格式化 —— 同一个抽屉里两节时间长得不一样,比原样显示更难读。
+          -->
+          <el-table-column label="完成时间" width="180" show-overflow-tooltip>
+            <template #default="{ row }">{{ row.finish_time || "-" }}</template>
+          </el-table-column>
+        </el-table>
+      </template>
+
       <h4 style="margin-top: 20px">回调轨迹</h4>
       <el-table :data="notifyLogs" border size="small">
         <el-table-column prop="created_time" label="时间" width="180" />
@@ -167,6 +220,7 @@ import {
   getLsyOrderPage,
   getLsyOrderDetail,
   getLsyNotifyLogs,
+  getLsyOrderRefunds,
 } from "@/api/module_payment/collection";
 
 const STATUS_OPTIONS = [
@@ -213,6 +267,18 @@ const VERIFY_LABEL: Record<string, string> = {
   FAIL: "失败",
 };
 
+/**
+ * `LsyRefundStatus` 的四个取值 → 中文。**逐个取自枚举源码**(`CREATED / REFUNDING / SUCCESS / FAIL`,
+ * 见 `LsyRefundStatus.java` 的枚举体),措辞与退款列表页 `collection/refund/index.vue` 的下拉一致 ——
+ * 同一份状态在两个页面叫两个名字,排查时会对不上。
+ */
+const REFUND_STATUS_LABEL: Record<string, string> = {
+  CREATED: "已建单",
+  REFUNDING: "退款中",
+  SUCCESS: "退款成功",
+  FAIL: "退款失败",
+};
+
 /** 查询参数用 camelCase:后端 `@RequestParam` 未写 name,线上名即 `pageNo` / `pageSize` */
 const query = reactive({
   pageNo: 1,
@@ -227,6 +293,7 @@ const loading = ref(false);
 const detailVisible = ref(false);
 const detail = ref<any>(null);
 const notifyLogs = ref<any[]>([]);
+const refunds = ref<any[]>([]);
 
 function sceneLabel(v: string) {
   return SCENE_LABEL[v] || v;
@@ -253,6 +320,15 @@ function processLabel(v: string) {
 function verifyLabel(v: string) {
   return VERIFY_LABEL[v] || v || "-";
 }
+function refundStatusLabel(v: string) {
+  return REFUND_STATUS_LABEL[v] || v || "-";
+}
+/** 在途用 warning、失败用 danger —— 与退款列表页同一套配色,避免同一状态两种颜色。 */
+function refundTagType(v: string) {
+  if (v === "SUCCESS") return "success";
+  if (v === "FAIL") return "danger";
+  return "warning";
+}
 /**
  * 「处理」列里**需要人看一眼**的四类,标红。
  *
@@ -298,6 +374,13 @@ function handleReset() {
 }
 
 async function openDetail(row: any) {
+  // ★ 先清空、再拉取:抽屉实例是**被复用**的,三条请求里任何一条抛异常,
+  //   上一次的内容就会原样留在界面上 —— 而它看起来完全像「这一笔订单的数据」。
+  //   (清空放在最前,所以下面三条赋值不再需要各自的兜底 || []。)
+  detail.value = null;
+  notifyLogs.value = [];
+  refunds.value = [];
+
   const res: any = await getLsyOrderDetail(row.out_trade_no);
   // ★ 两层 data:`request.ts` 的响应拦截器 `return response`,**不做解包**,
   //   所以 res.data 是整个 `{code,msg,data}` 信封,业务数据在 res.data.data。
@@ -306,6 +389,8 @@ async function openDetail(row: any) {
   detail.value = res.data.data;
   const logs: any = await getLsyNotifyLogs(row.out_trade_no);
   notifyLogs.value = logs.data.data || [];
+  const rfs: any = await getLsyOrderRefunds(row.out_trade_no);
+  refunds.value = rfs.data.data || [];
   detailVisible.value = true;
 }