Selaa lähdekoodia

feat(统一支付前端): 订单管理页

alphaH 1 viikko sitten
vanhempi
sitoutus
11b44f684a

+ 12 - 6
frontend/src/api/module_payment/unified.ts

@@ -241,20 +241,22 @@ const UnifiedAPI = {
 
   // ---------- 订单 ----------
 
-  /** 订单列表(分页) */
-  orderPage(params: UnifiedOrderQueryParams) {
+  /** 订单列表(分页);`silent` = 后台静默刷新(实时联动用,失败不弹 toast) */
+  orderPage(params: UnifiedOrderQueryParams, silent = false) {
     return request<ApiResponse<UnifiedPageResult<UnifiedOrderVO[]>>>({
       url: `${API_PATH}/order`,
       method: "get",
       params,
+      silent,
     });
   },
 
-  /** 订单详情(含 `refundable_amount`);`id` 是雪花号字符串 */
-  orderDetail(id: string) {
+  /** 订单详情(含 `refundable_amount`);`id` 是雪花号字符串;`silent` 同上 */
+  orderDetail(id: string, silent = false) {
     return request<ApiResponse<UnifiedOrderVO>>({
       url: `${API_PATH}/order/${id}`,
       method: "get",
+      silent,
     });
   },
 
@@ -278,11 +280,15 @@ const UnifiedAPI = {
     });
   },
 
-  /** 该订单的退款列表(后台详情抽屉用,共用 `:detail` 权限) */
-  orderRefunds(id: string) {
+  /**
+   * 该订单的退款列表(后台详情抽屉用,共用 `:detail` 权限)。
+   * `silent` = 后台静默刷新(详情抽屉实时联动用,失败不弹 toast)。
+   */
+  orderRefunds(id: string, silent = false) {
     return request<ApiResponse<UnifiedRefundVO[]>>({
       url: `${API_PATH}/order/${id}/refunds`,
       method: "get",
+      silent,
     });
   },
 

+ 857 - 0
frontend/src/views/module_payment/unified/order/index.vue

@@ -0,0 +1,857 @@
+<template>
+  <!-- 单根节点:AppMain 的 <transition mode="out-in"> 要求组件根为元素 -->
+  <div class="unified-order">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>订单管理</span>
+        </div>
+      </template>
+
+      <div class="mb-4">
+        <el-form :inline="true" :model="searchForm">
+          <el-form-item label="订单号">
+            <el-input
+              v-model="searchForm.order_no"
+              placeholder="平台订单号"
+              clearable
+              style="width: 190px"
+              @keyup.enter="handleSearch"
+            />
+          </el-form-item>
+          <el-form-item label="商户订单号">
+            <el-input
+              v-model="searchForm.out_trade_no"
+              placeholder="调用方订单号"
+              clearable
+              style="width: 190px"
+              @keyup.enter="handleSearch"
+            />
+          </el-form-item>
+          <el-form-item label="商户号">
+            <el-input
+              v-model="searchForm.mch_no"
+              placeholder="上游商户号"
+              clearable
+              style="width: 170px"
+              @keyup.enter="handleSearch"
+            />
+          </el-form-item>
+          <el-form-item label="支付方式">
+            <el-select
+              v-model="searchForm.way_code"
+              placeholder="全部"
+              clearable
+              style="width: 170px"
+            >
+              <el-option
+                v-for="o in UNIFIED_WAY_CODE_OPTIONS"
+                :key="o.value"
+                :label="o.label"
+                :value="o.value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="状态">
+            <el-select
+              v-model="searchForm.trade_status"
+              placeholder="全部"
+              clearable
+              style="width: 140px"
+            >
+              <el-option
+                v-for="(text, value) in UNIFIED_ORDER_STATUS_TEXT"
+                :key="value"
+                :label="text"
+                :value="value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="创建日期">
+            <el-date-picker
+              v-model="dateRange"
+              type="daterange"
+              range-separator="至"
+              start-placeholder="开始日期"
+              end-placeholder="结束日期"
+              value-format="YYYY-MM-DD"
+              style="width: 250px"
+              @change="handleDateChange"
+            />
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="handleSearch">查询</el-button>
+            <el-button @click="handleReset">重置</el-button>
+          </el-form-item>
+        </el-form>
+      </div>
+
+      <el-table v-loading="loading" :data="list" border stripe>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
+        <el-table-column prop="order_no" label="订单号" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="mch_no" label="商户号" min-width="150" show-overflow-tooltip />
+        <el-table-column label="方式" width="130">
+          <template #default="{ row }">{{ wayCodeText(row.way_code) }}</template>
+        </el-table-column>
+        <el-table-column label="金额(元)" width="110">
+          <template #default="{ row }">¥{{ fenToYuan(row.amount) }}</template>
+        </el-table-column>
+        <el-table-column label="状态" width="110">
+          <template #default="{ row }">
+            <el-tag :type="ORDER_STATUS_TAG[row.trade_status] || 'info'">
+              {{ orderStatusText(row.trade_status) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column
+          prop="upstream_pay_order_id"
+          label="上游单号"
+          min-width="200"
+          show-overflow-tooltip
+        >
+          <template #default="{ row }">{{ row.upstream_pay_order_id || "-" }}</template>
+        </el-table-column>
+        <el-table-column label="支付时间" width="170">
+          <template #default="{ row }">{{ formatTime(row.pay_time) }}</template>
+        </el-table-column>
+        <el-table-column label="回调状态" width="120">
+          <template #default="{ row }">{{ notifyStatusText(row.notify_status) }}</template>
+        </el-table-column>
+        <el-table-column label="创建时间" width="170">
+          <template #default="{ row }">{{ formatTime(row.created_time) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="100" fixed="right">
+          <template #default="{ row }">
+            <el-button
+              v-hasPerm="['module_payment:unified:order:detail']"
+              size="small"
+              @click="openDetail(row)"
+            >
+              详情
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="mt-4 flex justify-end">
+        <el-pagination
+          v-model:current-page="pageNo"
+          v-model:page-size="pageSize"
+          :total="total"
+          :page-sizes="[10, 20, 50, 100]"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="() => load()"
+          @current-change="() => load()"
+        />
+      </div>
+    </el-card>
+
+    <!-- 订单详情抽屉 -->
+    <el-drawer
+      v-model="drawerVisible"
+      :title="detail ? `订单详情 · ${detail.order_no}` : '订单详情'"
+      size="760px"
+      @closed="handleDrawerClosed"
+    >
+      <div v-loading="loadingDetail" class="detail-body">
+        <el-empty v-if="!detail && !loadingDetail" description="暂无详情" />
+        <template v-if="detail">
+          <el-descriptions :column="2" border>
+            <el-descriptions-item label="订单号" :span="2">
+              {{ detail.order_no }}
+            </el-descriptions-item>
+            <el-descriptions-item label="商户订单号">
+              {{ detail.out_trade_no || "-" }}
+            </el-descriptions-item>
+            <el-descriptions-item label="商户号">{{ detail.mch_no || "-" }}</el-descriptions-item>
+            <el-descriptions-item label="支付方式">
+              {{ wayCodeDetail(detail.way_code) }}
+            </el-descriptions-item>
+            <el-descriptions-item label="订单状态">
+              <el-tag :type="ORDER_STATUS_TAG[detail.trade_status] || 'info'">
+                {{ detail.trade_status }} / {{ orderStatusText(detail.trade_status) }}
+              </el-tag>
+            </el-descriptions-item>
+            <el-descriptions-item label="订单金额">
+              {{ money(detail.amount) }}
+            </el-descriptions-item>
+            <el-descriptions-item label="已退金额">
+              {{ money(detail.refunded_amount) }}
+            </el-descriptions-item>
+            <!-- 列表不算可退金额(恒为 null),只有详情接口给值 -->
+            <el-descriptions-item label="可退金额">
+              {{ money(detail.refundable_amount) }}
+            </el-descriptions-item>
+            <el-descriptions-item label="回调状态">
+              {{ notifyStatusDetail(detail.notify_status) }}
+            </el-descriptions-item>
+            <el-descriptions-item label="上游支付单号" :span="2">
+              {{ detail.upstream_pay_order_id || "-" }}
+            </el-descriptions-item>
+            <el-descriptions-item label="上游渠道单号" :span="2">
+              {{ detail.upstream_channel_order_no || "-" }}
+            </el-descriptions-item>
+            <el-descriptions-item label="pay_data_type">
+              {{ detail.pay_data_type || "-" }}
+            </el-descriptions-item>
+            <el-descriptions-item label="错误码">{{ detail.err_code || "-" }}</el-descriptions-item>
+            <el-descriptions-item label="错误信息" :span="2">
+              {{ detail.err_msg || "-" }}
+            </el-descriptions-item>
+            <el-descriptions-item label="支付时间">
+              {{ formatTime(detail.pay_time) }}
+            </el-descriptions-item>
+            <el-descriptions-item label="下次查询时间">
+              {{ formatTime(detail.next_query_time) }}
+            </el-descriptions-item>
+            <el-descriptions-item label="创建时间">
+              {{ formatTime(detail.created_time) }}
+            </el-descriptions-item>
+            <el-descriptions-item label="更新时间">
+              {{ formatTime(detail.updated_time) }}
+            </el-descriptions-item>
+          </el-descriptions>
+
+          <!-- pay_data:一律按文本展示;ALI_WAP/ALI_PC 的 form 是上游 HTML,禁止 v-html -->
+          <div class="detail-section">
+            <div class="detail-section__title">
+              <span>支付数据(pay_data)</span>
+              <el-button v-if="payDataText" size="small" @click="copyPayData">复制</el-button>
+            </div>
+            <template v-if="payDataText">
+              <div v-if="qrCodeDataUrl" class="pay-data__qr">
+                <img :src="qrCodeDataUrl" alt="支付二维码" />
+              </div>
+              <!-- codeImgUrl 只接受 http/https;其他协议在 watch 里记 WARN 并降级为文本 -->
+              <div v-else-if="codeImgUrl" class="pay-data__qr">
+                <img :src="codeImgUrl" alt="支付二维码" referrerpolicy="no-referrer" />
+              </div>
+              <pre class="pay-data__pre">{{ payDataText }}</pre>
+            </template>
+            <div v-else class="text-muted">无</div>
+          </div>
+
+          <!-- 退款列表(spec §9.2:详情抽屉要能看到该订单的退款) -->
+          <div class="detail-section">
+            <div class="detail-section__title">
+              <span>退款记录</span>
+            </div>
+            <el-table :data="refundList" border size="small" empty-text="暂无退款">
+              <el-table-column
+                prop="refund_no"
+                label="退款单号"
+                min-width="180"
+                show-overflow-tooltip
+              />
+              <el-table-column
+                prop="out_refund_no"
+                label="商户退款单号"
+                min-width="180"
+                show-overflow-tooltip
+              />
+              <el-table-column label="金额(元)" width="110">
+                <template #default="{ row }">{{ money(row.amount) }}</template>
+              </el-table-column>
+              <el-table-column label="状态" width="120">
+                <template #default="{ row }">
+                  <el-tag :type="REFUND_STATE_TAG[row.state] || 'info'">
+                    {{ refundStateText(row.state) }}
+                  </el-tag>
+                </template>
+              </el-table-column>
+              <el-table-column label="错误信息" min-width="150" show-overflow-tooltip>
+                <template #default="{ row }">{{ row.err_msg || "-" }}</template>
+              </el-table-column>
+              <el-table-column label="退款时间" width="170">
+                <template #default="{ row }">{{ formatTime(row.success_time) }}</template>
+              </el-table-column>
+              <el-table-column label="创建时间" width="170">
+                <template #default="{ row }">{{ formatTime(row.created_time) }}</template>
+              </el-table-column>
+              <el-table-column label="回调状态" width="120">
+                <template #default="{ row }">
+                  {{ notifyStatusText(row.notify_status) }}
+                </template>
+              </el-table-column>
+            </el-table>
+          </div>
+
+          <!--
+            upstream_data_json:后端 UnifiedOrderVO 目前没有暴露该字段(只有实体有),
+            这里按本地扩展类型渲染 —— 后端补上字段后页面无需改动即可显示。
+          -->
+          <el-collapse v-if="detail.upstream_data_json" class="detail-section">
+            <el-collapse-item title="上游原始数据(upstream_data_json)" name="upstream">
+              <pre class="pay-data__pre">{{ detail.upstream_data_json }}</pre>
+            </el-collapse-item>
+          </el-collapse>
+        </template>
+      </div>
+      <template #footer>
+        <div class="drawer-footer">
+          <el-button
+            v-hasPerm="['module_payment:unified:order:sync']"
+            :loading="syncing"
+            :disabled="!detailId"
+            @click="handleSync"
+          >
+            同步
+          </el-button>
+          <!-- 列表行的 refundable_amount 恒为 null,退款只能放在拿到详情之后再判 -->
+          <el-button
+            v-hasPerm="['module_payment:unified:order:refund']"
+            type="danger"
+            :disabled="!canRefund || refunding"
+            @click="openRefund"
+          >
+            退款
+          </el-button>
+          <el-button @click="drawerVisible = false">关闭</el-button>
+        </div>
+      </template>
+    </el-drawer>
+
+    <!-- 退款弹窗 -->
+    <el-dialog
+      v-model="refundVisible"
+      title="发起退款"
+      width="520px"
+      :close-on-click-modal="!refunding"
+      :close-on-press-escape="!refunding"
+      @closed="handleRefundClosed"
+    >
+      <el-form ref="refundFormRef" :model="refundForm" :rules="refundRules" label-width="110px">
+        <el-form-item label="退款单号" prop="out_refund_no">
+          <el-input v-model="refundForm.out_refund_no" disabled />
+          <div class="form-hint">幂等键:弹窗打开时生成一次,提交失败重试沿用同一个单号</div>
+        </el-form-item>
+        <el-form-item label="退款金额(元)" prop="refund_amount">
+          <el-input v-model="refundForm.refund_amount" placeholder="最多两位小数" />
+          <div class="form-hint">可退金额 {{ money(detail?.refundable_amount) }}</div>
+        </el-form-item>
+        <el-form-item label="退款原因" prop="refund_reason">
+          <el-input
+            v-model="refundForm.refund_reason"
+            type="textarea"
+            :rows="2"
+            maxlength="128"
+            show-word-limit
+            placeholder="退款原因"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button :disabled="refunding" @click="refundVisible = false">取消</el-button>
+        <el-button type="danger" :loading="refunding" :disabled="refunding" @click="submitRefund">
+          确认退款
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+// ★ 与 sys_menu.route_name 逐字一致(`db/unified-menu.sql`):keep-alive 的 include
+//   匹配组件名,不写这一行会按文件名推成 "index",`keep_alive=true` 静默失效(bf09016)。
+defineOptions({
+  name: "payment-unified-order",
+  inheritAttrs: false,
+});
+
+import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
+import { ElMessage, type FormInstance, type FormRules } from "element-plus";
+import dayjs from "dayjs";
+import QRCode from "qrcode";
+import UnifiedAPI, {
+  type UnifiedOrderVO,
+  type UnifiedRefundVO,
+} from "@/api/module_payment/unified";
+import {
+  UNIFIED_NOTIFY_STATUS_TEXT,
+  UNIFIED_ORDER_STATUS_TEXT,
+  UNIFIED_REFUND_STATE_TEXT,
+  UNIFIED_WAY_CODE_OPTIONS,
+  buildOutRefundNo,
+  fenToYuan,
+  isSafeHttpUrl,
+  yuanToFen,
+} from "@/utils/unifiedPay";
+import { useLiveRefetch } from "@/composables";
+
+/**
+ * 详情专用的本地扩展类型。
+ *
+ * ★ 后端 `UnifiedOrderVO` 目前**没有** `upstream_data_json`(只有实体有)。
+ *   这里先声明为可选字段:后端补上后页面原样生效;没有时整块折叠区不渲染。
+ */
+type UnifiedOrderDetail = UnifiedOrderVO & { upstream_data_json?: string };
+
+type TagType = "primary" | "success" | "warning" | "info" | "danger";
+
+/** 订单状态 → 标签色(表外状态按 info 兜底,文本仍显示原值) */
+const ORDER_STATUS_TAG: Record<string, TagType> = {
+  "0": "info",
+  "1": "warning",
+  "2": "success",
+  "3": "danger",
+  "4": "info",
+  "5": "warning",
+  "6": "info",
+};
+
+/** 退款状态 → 标签色 */
+const REFUND_STATE_TAG: Record<string, TagType> = {
+  "0": "info",
+  "1": "warning",
+  "2": "success",
+  "3": "danger",
+  "4": "info",
+};
+
+const list = ref<UnifiedOrderVO[]>([]);
+const total = ref(0);
+const pageNo = ref(1);
+const pageSize = ref(20);
+const loading = ref(false);
+
+const drawerVisible = ref(false);
+/** 当前抽屉里的订单主键(打开时立刻写入,用于丢弃迟到的旧请求响应) */
+const detailId = ref<string | null>(null);
+const detail = ref<UnifiedOrderDetail | null>(null);
+const refundList = ref<UnifiedRefundVO[]>([]);
+const loadingDetail = ref(false);
+const syncing = ref(false);
+
+const refundVisible = ref(false);
+const refunding = ref(false);
+const refundFormRef = ref<FormInstance>();
+
+const dateRange = ref<string[] | null>(null);
+
+const searchForm = reactive({
+  order_no: "",
+  out_trade_no: "",
+  mch_no: "",
+  way_code: undefined as string | undefined,
+  trade_status: undefined as string | undefined,
+  start_date: undefined as string | undefined,
+  end_date: undefined as string | undefined,
+});
+
+const refundForm = reactive({
+  out_refund_no: "",
+  refund_amount: "",
+  refund_reason: "",
+});
+
+const refundRules: FormRules = {
+  refund_amount: [{ required: true, message: "请填写退款金额", trigger: "blur" }],
+  refund_reason: [{ required: true, message: "请填写退款原因", trigger: "blur" }],
+};
+
+const orderStatusText = (v?: string) => (v ? UNIFIED_ORDER_STATUS_TEXT[v] || v : "-");
+const refundStateText = (v?: string) => (v ? UNIFIED_REFUND_STATE_TEXT[v] || v : "-");
+const notifyStatusText = (v?: string) => (v ? UNIFIED_NOTIFY_STATUS_TEXT[v] || v : "-");
+
+/** 详情里把原值也带出来:状态表外的值不能因为「查不到中文」就消失 */
+const notifyStatusDetail = (v?: string) => (v ? `${notifyStatusText(v)}(${v})` : "-");
+
+const wayCodeText = (v?: string) => {
+  if (!v) {
+    return "-";
+  }
+  return UNIFIED_WAY_CODE_OPTIONS.find((o) => o.value === v)?.label || v;
+};
+
+const wayCodeDetail = (v?: string) => {
+  if (!v) {
+    return "-";
+  }
+  const opt = UNIFIED_WAY_CODE_OPTIONS.find((o) => o.value === v);
+  return opt ? `${opt.label}(${v})` : v;
+};
+
+const formatTime = (v?: string) => (v ? dayjs(v).format("YYYY-MM-DD HH:mm:ss") : "-");
+
+/** 金额(分)→ 展示文本;null/undefined 是「没有这个值」,不是 0 元 */
+const money = (fen?: number | null) =>
+  fen === null || fen === undefined ? "-" : `¥${fenToYuan(fen)}`;
+
+/** 可退金额来自详情接口;列表恒为 null,不能用列表行判断 */
+const canRefund = computed(
+  () => detail.value?.trade_status === "2" && (detail.value?.refundable_amount ?? 0) > 0
+);
+
+// ─────────────────── pay_data 展示 ───────────────────
+
+/** pay_data 原样文本:始终展示(form 是上游 HTML,只能当文本) */
+const payDataText = computed(() => detail.value?.pay_data || "");
+
+/** codeUrl 生成的二维码 dataURL */
+const qrCodeDataUrl = ref("");
+let qrSeq = 0;
+
+/** codeImgUrl 里唯一允许进 <img src> 的形状:http/https */
+const codeImgUrl = computed(() => {
+  const d = detail.value;
+  if (!d || (d.pay_data_type || "").toLowerCase() !== "codeimgurl") {
+    return "";
+  }
+  return isSafeHttpUrl(d.pay_data) ? d.pay_data || "" : "";
+});
+
+// 详情变化时刷新二维码;seq 用来丢弃迟到的异步结果(A 单的码不能渲染到 B 单上)
+watch(
+  () => [detail.value?.pay_data, detail.value?.pay_data_type] as const,
+  async () => {
+    const seq = ++qrSeq;
+    qrCodeDataUrl.value = "";
+    const data = detail.value?.pay_data || "";
+    const type = (detail.value?.pay_data_type || "").toLowerCase();
+    if (!data) {
+      return;
+    }
+    if (type === "codeimgurl" && !isSafeHttpUrl(data)) {
+      console.warn("不安全的 codeImgUrl,已按文本展示", data);
+      return;
+    }
+    if (type !== "codeurl") {
+      return;
+    }
+    try {
+      const url = await QRCode.toDataURL(data, { width: 260, margin: 1 });
+      if (seq === qrSeq) {
+        qrCodeDataUrl.value = url;
+      }
+    } catch (e) {
+      console.warn("pay_data 二维码生成失败,已按文本展示", e);
+    }
+  },
+  { immediate: true }
+);
+
+async function copyPayData() {
+  if (!payDataText.value) {
+    return;
+  }
+  try {
+    await navigator.clipboard.writeText(payDataText.value);
+    ElMessage.success("已复制");
+  } catch {
+    ElMessage.warning("浏览器拒绝了复制操作,请手动选中复制");
+  }
+}
+
+// ─────────────────── 列表 ───────────────────
+
+async function load(silent = false) {
+  if (!silent) {
+    loading.value = true;
+  }
+  try {
+    const res = await UnifiedAPI.orderPage(
+      {
+        order_no: searchForm.order_no || undefined,
+        out_trade_no: searchForm.out_trade_no || undefined,
+        mch_no: searchForm.mch_no || undefined,
+        way_code: searchForm.way_code || undefined,
+        trade_status: searchForm.trade_status || undefined,
+        start_date: searchForm.start_date || undefined,
+        end_date: searchForm.end_date || undefined,
+        page_no: pageNo.value,
+        page_size: pageSize.value,
+      },
+      silent
+    );
+    // 后端分页键是 `list`,全局 TS 声明写的是 `items` —— 两个都读,
+    // 只读 items 会静默拿到空数组(HAR 订单页同一个坑)。
+    list.value = res.data.data?.list || res.data.data?.items || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    if (!silent) {
+      loading.value = false;
+    }
+  }
+}
+
+function handleSearch() {
+  pageNo.value = 1;
+  load();
+}
+
+function handleDateChange() {
+  if (dateRange.value && dateRange.value.length === 2) {
+    searchForm.start_date = dateRange.value[0];
+    searchForm.end_date = dateRange.value[1];
+  } else {
+    searchForm.start_date = undefined;
+    searchForm.end_date = undefined;
+  }
+}
+
+function handleReset() {
+  searchForm.order_no = "";
+  searchForm.out_trade_no = "";
+  searchForm.mch_no = "";
+  searchForm.way_code = undefined;
+  searchForm.trade_status = undefined;
+  searchForm.start_date = undefined;
+  searchForm.end_date = undefined;
+  dateRange.value = null;
+  handleSearch();
+}
+
+// ─────────────────── 详情 ───────────────────
+
+/** 退款列表:始终静默(R67);只在还是同一订单时覆盖 */
+async function loadRefunds(id: string) {
+  try {
+    const res = await UnifiedAPI.orderRefunds(id, true);
+    if (detailId.value === id) {
+      refundList.value = res.data.data || [];
+    }
+  } catch {
+    // 静默:退款列表拉取失败不遮盖详情主信息
+  }
+}
+
+async function openDetail(row: UnifiedOrderVO) {
+  const id = row.id;
+  if (!id) {
+    ElMessage.warning("订单缺少主键,无法查看详情");
+    return;
+  }
+  detailId.value = id;
+  detail.value = null;
+  refundList.value = [];
+  drawerVisible.value = true;
+  loadingDetail.value = true;
+  try {
+    const res = await UnifiedAPI.orderDetail(id);
+    if (detailId.value !== id) {
+      return; // 期间又打开了别的订单:丢弃这次响应
+    }
+    detail.value = res.data.data;
+    await loadRefunds(id);
+  } catch {
+    // 拦截器已用后端原话提示;详情没拿到就收起抽屉,别留一个空壳
+    if (detailId.value === id) {
+      drawerVisible.value = false;
+    }
+  } finally {
+    if (detailId.value === id) {
+      loadingDetail.value = false;
+    }
+  }
+}
+
+/** 用户主动操作后的详情重载(非静默:失败要让操作员看见) */
+async function reloadOpenDetail() {
+  const id = detailId.value;
+  if (!id) {
+    return;
+  }
+  try {
+    const res = await UnifiedAPI.orderDetail(id);
+    if (detailId.value === id) {
+      detail.value = res.data.data;
+    }
+  } catch {
+    // 拦截器已提示;保留旧详情
+  }
+  await loadRefunds(id);
+}
+
+/** 实时联动用的静默刷新;只有同一个订单还开着才覆盖 */
+async function refreshOpenDetail() {
+  const id = detailId.value;
+  if (!drawerVisible.value || !id) {
+    return;
+  }
+  try {
+    const res = await UnifiedAPI.orderDetail(id, true);
+    if (drawerVisible.value && detailId.value === id) {
+      detail.value = res.data.data;
+    }
+  } catch {
+    // 静默
+  }
+  await loadRefunds(id);
+}
+
+function handleDrawerClosed() {
+  // 关闭动画期间又打开了别的订单:这次 closed 是上一轮的,不能把新状态清掉
+  if (drawerVisible.value) {
+    return;
+  }
+  detailId.value = null;
+  detail.value = null;
+  refundList.value = [];
+  loadingDetail.value = false;
+}
+
+async function handleSync() {
+  const id = detailId.value;
+  if (!id) {
+    return;
+  }
+  syncing.value = true;
+  try {
+    await UnifiedAPI.orderSync(id);
+    ElMessage.success("同步成功");
+    await reloadOpenDetail();
+    await load();
+  } catch {
+    // 拦截器已弹后端原话(如「上游同步失败」),这里不再叠一条
+  } finally {
+    syncing.value = false;
+  }
+}
+
+// ─────────────────── 退款 ───────────────────
+
+/** 六位随机数字(补零);`buildOutRefundNo` 负责拼时间戳与 `R` 前缀 */
+function random6(): string {
+  return String(Math.floor(Math.random() * 1000000)).padStart(6, "0");
+}
+
+function openRefund() {
+  const d = detail.value;
+  if (!d || !canRefund.value) {
+    return;
+  }
+  // ★ 幂等键只在弹窗打开时生成一次;提交失败重试必须沿用同一个号
+  refundForm.out_refund_no = buildOutRefundNo(new Date(), random6());
+  refundForm.refund_amount = fenToYuan(d.refundable_amount);
+  refundForm.refund_reason = "";
+  refundVisible.value = true;
+  nextTick(() => refundFormRef.value?.clearValidate());
+}
+
+function handleRefundClosed() {
+  refundFormRef.value?.clearValidate();
+}
+
+async function submitRefund() {
+  const id = detailId.value;
+  if (!id) {
+    return;
+  }
+  const valid = await refundFormRef.value?.validate().catch(() => false);
+  if (!valid) {
+    return;
+  }
+  const fen = yuanToFen(refundForm.refund_amount);
+  if (fen === null || fen <= 0) {
+    ElMessage.warning("退款金额格式不正确(最多两位小数,且需大于 0)");
+    return;
+  }
+  const refundable = detail.value?.refundable_amount ?? 0;
+  if (fen > refundable) {
+    ElMessage.warning(`退款金额不能超过可退金额 ¥${fenToYuan(refundable)}`);
+    return;
+  }
+  refunding.value = true;
+  try {
+    await UnifiedAPI.orderRefund(id, {
+      out_refund_no: refundForm.out_refund_no,
+      refund_amount: fen,
+      refund_reason: refundForm.refund_reason.trim(),
+    });
+    // 提交成功只代表受理:到账以退款状态收敛成「退款成功」为准
+    ElMessage.success("退款已受理");
+    refundVisible.value = false;
+    await reloadOpenDetail();
+    await load();
+  } catch {
+    // 失败保留弹窗:修正后重试沿用同一个 out_refund_no(幂等键)
+  } finally {
+    refunding.value = false;
+  }
+}
+
+// ─────────────────── 实时联动 ───────────────────
+
+/** 退款弹窗/提交期间降级为手动刷新;详情抽屉要跟着实时变,不进 busy */
+const liveBusy = computed(() => refundVisible.value || refunding.value);
+
+useLiveRefetch(
+  ["unified_order", "unified_refund"],
+  async () => {
+    await refreshOpenDetail();
+    await load(true);
+  },
+  { busy: liveBusy }
+);
+
+onMounted(() => load());
+</script>
+
+<style lang="scss" scoped>
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.detail-body {
+  min-height: 200px;
+}
+
+.detail-section {
+  margin-top: 16px;
+}
+
+.detail-section__title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
+  font-weight: 600;
+}
+
+.pay-data__qr {
+  margin-bottom: 8px;
+
+  img {
+    width: 220px;
+    height: 220px;
+    object-fit: contain;
+    border: 1px solid var(--el-border-color);
+    border-radius: 4px;
+  }
+}
+
+.pay-data__pre {
+  max-height: 260px;
+  padding: 8px 12px;
+  margin: 0;
+  overflow: auto;
+  font-family: inherit;
+  font-size: 12px;
+  line-height: 1.6;
+  color: var(--el-text-color-regular);
+  word-break: break-all;
+  white-space: pre-wrap;
+  background: var(--el-fill-color-light);
+  border-radius: 4px;
+}
+
+.form-hint {
+  margin-top: 2px;
+  font-size: 12px;
+  line-height: 1.5;
+  color: var(--el-text-color-secondary);
+}
+
+.text-muted {
+  color: var(--el-text-color-secondary);
+}
+
+.drawer-footer {
+  display: flex;
+  justify-content: flex-end;
+}
+</style>