Selaa lähdekoodia

feat(统一支付前端): 收银台页

alphaH 1 viikko sitten
vanhempi
sitoutus
f67361413e

+ 20 - 2
frontend/src/api/module_payment/unified.ts

@@ -303,11 +303,29 @@ const UnifiedAPI = {
     });
   },
 
-  /** 收银台查本单;后端 3 秒内直接返回本地快照(节流,不每跳都打上游) */
-  cashierOrder(orderNo: string) {
+  /**
+   * 收银台查本单;后端 3 秒内直接返回本地快照(节流,不每跳都打上游)。
+   *
+   * `silent` = 收银台轮询用:响应拦截器会给每个失败的非静默请求弹 toast
+   * (`utils/request.ts`),3 秒一次的上游抖动会变成每 3 秒一条错误提示。
+   */
+  cashierOrder(orderNo: string, silent = false) {
     return request<ApiResponse<UnifiedOrderVO>>({
       url: `${API_PATH}/cashier/order/${orderNo}`,
       method: "get",
+      silent,
+    });
+  },
+
+  /**
+   * 为已有订单生成公开 H5 支付页链接(后端 `UnifiedH5LinkVO{url}`)。
+   *
+   * 返回的 `url` 由后台按订单与商户签名铸造,页面展示/复制前仍要过 `isSafeHttpUrl`。
+   */
+  cashierH5Link(orderNo: string) {
+    return request<ApiResponse<{ url: string }>>({
+      url: `${API_PATH}/cashier/order/${orderNo}/h5-link`,
+      method: "get",
     });
   },
 };

+ 854 - 0
frontend/src/views/module_payment/unified/cashier/index.vue

@@ -0,0 +1,854 @@
+<template>
+  <!-- 单根节点:AppMain 的 <transition mode="out-in"> 要求组件根为元素 -->
+  <div class="unified-cashier">
+    <!-- ① 这一笔还没建出来:选模式、填金额、发起收款 -->
+    <el-card v-if="!order" class="pane">
+      <template #header>
+        <div class="card-header">
+          <span>收银台</span>
+          <span class="header-hint">主扫 / 被扫 / H5</span>
+        </div>
+      </template>
+
+      <el-alert
+        v-if="merchantsError"
+        class="mb-4"
+        type="error"
+        :closable="false"
+        show-icon
+        title="商户配置加载失败,请刷新重试"
+      />
+      <!-- 本租户没有任何商户配置时,下面所有输入都没有意义:说清楚找谁,而不是留一个报错 -->
+      <el-alert
+        v-else-if="!merchantsLoading && merchants.length === 0"
+        class="mb-4"
+        type="warning"
+        :closable="false"
+        show-icon
+        title="请联系管理员配置商户"
+      />
+
+      <el-form :model="form" label-width="96px" :disabled="formDisabled">
+        <el-form-item label="支付模式">
+          <el-radio-group v-model="mode">
+            <el-radio v-for="m in MODE_OPTIONS" :key="m.value" :value="m.value">
+              {{ m.label }}
+            </el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <!-- 单商户租户不显示下拉:钱进哪个账户没有第二选择,不该多一步 -->
+        <el-form-item v-if="merchants.length > 1" label="商户">
+          <el-select v-model="form.mch_no" placeholder="请选择商户" style="width: 100%">
+            <el-option
+              v-for="m in merchants"
+              :key="m.mch_no"
+              :label="merchantLabel(m)"
+              :value="m.mch_no"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="支付方式">
+          <el-select v-model="form.way_code" style="width: 100%">
+            <el-option
+              v-for="o in wayCodeOptions"
+              :key="o.value"
+              :label="o.label"
+              :value="o.value"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="金额(元)">
+          <el-input v-model="form.amount_text" placeholder="0.00" @keyup.enter="submit" />
+        </el-form-item>
+
+        <el-form-item label="商品标题">
+          <el-input
+            v-model="form.subject"
+            placeholder="展示给顾客的订单标题"
+            maxlength="128"
+            show-word-limit
+            @keyup.enter="submit"
+          />
+        </el-form-item>
+
+        <el-form-item v-if="mode === 'barcode'" label="付款码">
+          <!-- ★ 付款码只在这一笔的请求里出现:type=password、提交后立即清空,
+               不写 localStorage/sessionStorage,也不打日志。 -->
+          <el-input
+            v-model="form.auth_code"
+            type="password"
+            autocomplete="off"
+            placeholder="请扫描或输入顾客付款码"
+            @keyup.enter="submit"
+          />
+        </el-form-item>
+
+        <el-form-item>
+          <el-button
+            v-hasPerm="['module_payment:unified:cashier:create']"
+            type="primary"
+            :loading="creating"
+            :disabled="submitDisabled"
+            @click="submit"
+          >
+            发起收款
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <div v-if="inputHint" class="warn">{{ inputHint }}</div>
+    </el-card>
+
+    <!-- ② 已经建单:展示支付数据与状态,每 3 秒静默查询一次 -->
+    <el-card v-else class="pane">
+      <template #header>
+        <div class="card-header">
+          <span>收款结果</span>
+          <el-tag :type="statusTagType">{{ statusText }}</el-tag>
+        </div>
+      </template>
+
+      <div class="amount-line">
+        <span class="amount-unit">¥</span>
+        <span class="amount-value">{{ fenToYuan(order.amount) }}</span>
+      </div>
+
+      <el-descriptions :column="1" border size="small" class="meta">
+        <el-descriptions-item label="订单号">{{ order.order_no }}</el-descriptions-item>
+        <el-descriptions-item label="商户订单号">{{ order.out_trade_no }}</el-descriptions-item>
+        <el-descriptions-item label="支付方式">
+          {{ wayCodeLabel(order.way_code) }}
+        </el-descriptions-item>
+        <el-descriptions-item v-if="order.mch_no" label="商户号">
+          {{ order.mch_no }}
+        </el-descriptions-item>
+        <el-descriptions-item v-if="order.pay_time" label="支付时间">
+          {{ formatTime(order.pay_time) }}
+        </el-descriptions-item>
+      </el-descriptions>
+
+      <!--
+        主扫:pay_data_type 判等忽略大小写(codeUrl / payUrl / payurl / codeImgUrl);
+        任何 URL 进二维码或 <img> 之前都先过 isSafeHttpUrl —— pay_data 是上游原样返回的数据。
+      -->
+      <div v-if="isScan" class="pay-area">
+        <template v-if="qrDataUrl">
+          <div class="qr-wrap">
+            <img :src="qrDataUrl" alt="支付二维码" />
+          </div>
+          <div class="hint">请顾客使用对应 App 扫码支付</div>
+        </template>
+        <template v-else-if="safeCodeImgUrl">
+          <div class="qr-wrap">
+            <img :src="safeCodeImgUrl" alt="支付二维码" referrerpolicy="no-referrer" />
+          </div>
+          <div class="hint">请顾客使用对应 App 扫码支付</div>
+        </template>
+        <pre v-else-if="order.pay_data" class="pay-data-text">{{ order.pay_data }}</pre>
+        <div v-else class="hint text-muted">上游未返回支付二维码数据</div>
+      </div>
+
+      <!-- 被扫:没有可展示的支付数据,只有状态在动 -->
+      <div v-else-if="isBarcode" class="pay-area hint">
+        {{ isPending ? "正在等待上游返回支付结果…" : "本笔已结束" }}
+      </div>
+
+      <!-- H5:只有 http/https 链接才给「打开支付页」;其他内容按文本展示并 warn -->
+      <div v-else-if="isH5Way" class="pay-area">
+        <template v-if="h5PayUrl">
+          <el-button type="primary" @click="openPayPage">打开支付页</el-button>
+          <div class="link-line">
+            <span class="link-text">{{ h5PayUrl }}</span>
+            <CopyButton :text="h5PayUrl" />
+          </div>
+        </template>
+        <pre v-else-if="order.pay_data" class="pay-data-text">{{ order.pay_data }}</pre>
+        <div v-else class="hint text-muted">上游未返回支付链接</div>
+      </div>
+
+      <div v-if="order.err_code || order.err_msg" class="err-box">
+        <div v-if="order.err_code">错误码:{{ order.err_code }}</div>
+        <div v-if="order.err_msg">错误信息:{{ order.err_msg }}</div>
+      </div>
+
+      <div class="poll-line">
+        <template v-if="pollTimedOut">已停止自动查询,请到订单页手动同步</template>
+        <template v-else-if="isPending">正在等待支付结果,每 3 秒自动查询…</template>
+      </div>
+
+      <!--
+        R72:未终态且订单方式是公开 H5 页可接受的之一时,可以把后台铸造的
+        公开支付页链接发给顾客。链接本身同样先过 isSafeHttpUrl 再展示/复制。
+      -->
+      <div v-if="canGenerateH5Link || h5Link" class="h5-link">
+        <div class="h5-link__title">公开 H5 支付页</div>
+        <el-button
+          v-if="canGenerateH5Link"
+          size="small"
+          :loading="h5LinkLoading"
+          @click="generateH5Link"
+        >
+          复制 H5 支付链接
+        </el-button>
+        <template v-if="h5Link">
+          <div class="link-line">
+            <span class="link-text">{{ h5Link }}</span>
+            <CopyButton v-if="safeH5Link" :text="h5Link" />
+          </div>
+          <div v-if="!safeH5Link" class="warn">该链接不是 http/https 地址,只展示不复制</div>
+        </template>
+      </div>
+
+      <div class="footer">
+        <el-button @click="reset">再收一笔</el-button>
+      </div>
+    </el-card>
+  </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-cashier",
+  inheritAttrs: false,
+});
+
+import {
+  computed,
+  onActivated,
+  onBeforeUnmount,
+  onDeactivated,
+  onMounted,
+  reactive,
+  ref,
+  watch,
+} from "vue";
+import { ElMessage } from "element-plus";
+import dayjs from "dayjs";
+import QRCode from "qrcode";
+import UnifiedAPI, {
+  type UnifiedMerchantVO,
+  type UnifiedOrderCreateParams,
+  type UnifiedOrderVO,
+} from "@/api/module_payment/unified";
+import {
+  UNIFIED_ORDER_STATUS_TEXT,
+  UNIFIED_WAY_CODE_OPTIONS,
+  buildOutTradeNo,
+  fenToYuan,
+  isSafeHttpUrl,
+  shouldPollUnified,
+  yuanToFen,
+} from "@/utils/unifiedPay";
+
+/**
+ * 统一支付收银台(主扫 / 被扫 / H5)。
+ *
+ * 【三件事必须一直成立】
+ *   1. 金额全程走 `yuanToFen` / `fenToYuan`,页面里不出现第二份 `* 100` / `/ 100`;
+ *   2. 付款码只活在内存里的一个 `reactive` 字段:`type=password`、提交即清空、
+ *      不落任何浏览器存储、不进日志;
+ *   3. 页面不接触 `app_secret` —— 商户列表响应里根本没有这个字段,
+ *      这里也不提供任何「查看密钥」的入口。
+ *
+ * 【轮询】每 3 秒一次、**静默**(R70):失败保留上次已知状态,不弹 toast。
+ *   终态(2/3/4/5/6)静默停在最终状态;5 分钟仍未收敛才停表并提示去订单页手动同步(R71)。
+ *   keep-alive 之下离开页面是失活不是卸载,`onDeactivated` 也要停表,回来再按剩余时间接上。
+ */
+
+type PayMode = "scan" | "barcode" | "h5";
+type TagType = "primary" | "success" | "warning" | "info" | "danger";
+
+/** 轮询间隔,与后端 `UnifiedCashierService.SNAPSHOT_THROTTLE_SECONDS` 同值(改一处要改另一处) */
+const POLL_MS = 3000;
+/** 轮询上限,与 `shouldPollUnified` 的 300000 是同一个数(判据仍以 helper 为准,这里只用于提示) */
+const POLL_MAX_MS = 300000;
+/** 仍在等待的状态(与 `shouldPollUnified` 里的集合一致) */
+const PENDING_STATUS = new Set(["0", "1"]);
+
+const MODE_OPTIONS: Array<{ value: PayMode; label: string }> = [
+  { value: "scan", label: "主扫" },
+  { value: "barcode", label: "被扫" },
+  { value: "h5", label: "H5" },
+];
+
+/** 三种模式下收银台允许的 wayCode(spec §4.6 的 16 个值里的子集) */
+const WAY_CODES_BY_MODE: Record<PayMode, string[]> = {
+  scan: ["QR_CASHIER", "ALI_QR", "WX_NATIVE"],
+  barcode: ["AUTO_BAR", "ALI_BAR", "WX_BAR", "YSF_BAR"],
+  h5: ["WX_H5"],
+};
+
+const DEFAULT_WAY_CODE: Record<PayMode, string> = {
+  scan: "QR_CASHIER",
+  barcode: "AUTO_BAR",
+  h5: "WX_H5",
+};
+
+/**
+ * 公开 H5 支付页可接受的方式(R72)= 收银台可选方式 ∩ 后端 `H5_SUPPORTED`。
+ * 条码类需要付款码,公开页不收 `channel_extra`,所以不在此列。
+ */
+const H5_LINK_WAY_CODES = new Set(["QR_CASHIER", "ALI_QR", "WX_NATIVE", "WX_H5"]);
+
+/** 订单状态 → 标签色(表外状态按 info 兜底,文本仍显示原值) */
+const ORDER_STATUS_TAG: Record<string, TagType> = {
+  "0": "info",
+  "1": "warning",
+  "2": "success",
+  "3": "danger",
+  "4": "info",
+  "5": "warning",
+  "6": "info",
+};
+
+const mode = ref<PayMode>("scan");
+const merchants = ref<UnifiedMerchantVO[]>([]);
+const merchantsLoading = ref(true);
+const merchantsError = ref(false);
+const creating = ref(false);
+const inputHint = ref("");
+
+const form = reactive({
+  mch_no: "",
+  way_code: DEFAULT_WAY_CODE.scan,
+  amount_text: "",
+  subject: "",
+  auth_code: "",
+});
+
+const order = ref<UnifiedOrderVO | null>(null);
+const qrDataUrl = ref("");
+const h5Link = ref("");
+const h5LinkLoading = ref(false);
+const pollTimedOut = ref(false);
+
+let pollTimer: number | undefined;
+/** 本单开始轮询的时刻:5 分钟窗口从建单成功那一秒算,不因失活/重连重置 */
+let pollStartedAt = 0;
+let pollInFlight = false;
+/** `out_trade_no` 幂等键:首次点击生成,失败重试/双击复用,创建成功才清空 */
+let pendingOutTradeNo = "";
+/** 二维码异步生成的代次:迟到的 A 单结果不能渲染到 B 单上 */
+let qrSeq = 0;
+
+const wayCodeOptions = computed(() =>
+  UNIFIED_WAY_CODE_OPTIONS.filter((o) => WAY_CODES_BY_MODE[mode.value].includes(o.value))
+);
+
+const formDisabled = computed(() => merchantsLoading.value || merchants.value.length === 0);
+
+/** 没有商户、多商户未选、正在提交 —— 三种情况都不该再发一次建单请求 */
+const submitDisabled = computed(
+  () =>
+    creating.value ||
+    merchantsLoading.value ||
+    merchants.value.length === 0 ||
+    (merchants.value.length > 1 && !form.mch_no)
+);
+
+const payKind = computed(() => (order.value?.pay_data_type || "").toLowerCase());
+const payValue = computed(() => order.value?.pay_data || "");
+
+/** `codeImgUrl` 里唯一允许进 `<img src>` 的形状:http/https */
+const safeCodeImgUrl = computed(() =>
+  payKind.value === "codeimgurl" && isSafeHttpUrl(payValue.value) ? payValue.value : ""
+);
+
+const isPending = computed(() => !!order.value && PENDING_STATUS.has(order.value.trade_status));
+const isScan = computed(
+  () => !!order.value && WAY_CODES_BY_MODE.scan.includes(order.value.way_code)
+);
+const isBarcode = computed(
+  () => !!order.value && WAY_CODES_BY_MODE.barcode.includes(order.value.way_code)
+);
+const isH5Way = computed(() => order.value?.way_code === "WX_H5");
+
+/** H5 模式的支付链接:只放行 http/https,其他内容由模板降级成文本 */
+const h5PayUrl = computed(() => {
+  const o = order.value;
+  if (!o || o.way_code !== "WX_H5") {
+    return "";
+  }
+  return isSafeHttpUrl(o.pay_data) ? o.pay_data || "" : "";
+});
+
+const statusText = computed(() => {
+  const st = order.value?.trade_status;
+  return st ? UNIFIED_ORDER_STATUS_TEXT[st] || st : "-";
+});
+
+const statusTagType = computed<TagType>(() => {
+  const st = order.value?.trade_status;
+  return (st && ORDER_STATUS_TAG[st]) || "info";
+});
+
+/** R72:未终态且该方式能被公开 H5 页受理时,才提供「复制 H5 支付链接」 */
+const canGenerateH5Link = computed(() => {
+  const o = order.value;
+  return !!o && PENDING_STATUS.has(o.trade_status) && H5_LINK_WAY_CODES.has(o.way_code);
+});
+
+const safeH5Link = computed(() => isSafeHttpUrl(h5Link.value));
+
+const merchantLabel = (m: UnifiedMerchantVO) =>
+  m.merchant_name ? `${m.merchant_name}(${m.mch_no})` : m.mch_no;
+
+const wayCodeLabel = (v?: string) => {
+  if (!v) {
+    return "-";
+  }
+  return UNIFIED_WAY_CODE_OPTIONS.find((o) => o.value === v)?.label || v;
+};
+
+const formatTime = (v?: string) => (v ? dayjs(v).format("YYYY-MM-DD HH:mm:ss") : "-");
+
+/** 六位随机数字(补零);`buildOutTradeNo` 负责拼时间戳与 `C` 前缀 */
+function random6(): string {
+  return String(Math.floor(Math.random() * 1000000)).padStart(6, "0");
+}
+
+async function loadMerchants() {
+  merchantsLoading.value = true;
+  merchantsError.value = false;
+  try {
+    const res = await UnifiedAPI.merchantPage({ page_no: 1, page_size: 100 });
+    // 后端分页键是 `list`,全局 TS 声明写的是 `items` —— 两个都读,
+    // 只读 items 会静默拿到空数组(商户页/订单页同一个坑)。
+    merchants.value = res.data.data?.list || res.data.data?.items || [];
+    form.mch_no = merchants.value.length === 1 ? merchants.value[0].mch_no : "";
+  } catch {
+    // 拦截器已经弹过后端原话;这里把「加载失败」与「确实 0 条」分开,
+    // 免得权限/网络问题被说成「没有商户配置」。
+    merchants.value = [];
+    merchantsError.value = true;
+  } finally {
+    merchantsLoading.value = false;
+  }
+}
+
+function startPolling() {
+  window.clearInterval(pollTimer);
+  pollTimer = window.setInterval(poll, POLL_MS);
+}
+
+function stopPolling() {
+  window.clearInterval(pollTimer);
+  pollTimer = undefined;
+}
+
+/** 建单成功后接管屏幕:清掉上一单的一切派生状态,再决定要不要开表 */
+function applyOrder(vo: UnifiedOrderVO) {
+  stopPolling();
+  order.value = vo;
+  h5Link.value = "";
+  h5LinkLoading.value = false;
+  pollTimedOut.value = false;
+  pollStartedAt = Date.now();
+  if (shouldPollUnified(vo.trade_status, 0)) {
+    startPolling();
+  }
+}
+
+async function submit() {
+  if (creating.value) {
+    return;
+  }
+  inputHint.value = "";
+
+  if (merchantsLoading.value) {
+    return;
+  }
+  if (merchants.value.length === 0) {
+    inputHint.value = "请联系管理员配置商户";
+    return;
+  }
+  if (merchants.value.length > 1 && !form.mch_no) {
+    inputHint.value = "本租户配了多个商户,请先选择商户";
+    return;
+  }
+
+  const fen = yuanToFen(form.amount_text);
+  if (fen === null) {
+    inputHint.value = "金额格式不正确:请输入最多两位小数的数字";
+    return;
+  }
+  if (fen <= 0) {
+    inputHint.value = "金额必须大于 0";
+    return;
+  }
+
+  const subject = form.subject.trim();
+  if (!subject) {
+    inputHint.value = "请填写商品标题";
+    return;
+  }
+
+  let authCode = "";
+  if (mode.value === "barcode") {
+    authCode = form.auth_code.trim();
+    if (!authCode) {
+      inputHint.value = "请扫描或输入顾客付款码";
+      return;
+    }
+  }
+
+  // ★ 幂等键只在点击时生成一次:失败重试、双击都复用同一个号,创建成功才清空。
+  if (!pendingOutTradeNo) {
+    pendingOutTradeNo = buildOutTradeNo(new Date(), random6());
+  }
+
+  const params: UnifiedOrderCreateParams = {
+    mch_no: form.mch_no || undefined,
+    amount: fen,
+    way_code: form.way_code,
+    subject,
+    out_trade_no: pendingOutTradeNo,
+  };
+  if (mode.value === "scan") {
+    // 主扫让上游把支付数据按 codeUrl 返回(spec §4.6 / R33)
+    params.channel_extra = { payDataType: "codeUrl" };
+  } else if (mode.value === "barcode") {
+    params.channel_extra = { auth_code: authCode };
+    // ★ 付款码提交后立即清空:失败也不回填,重试由操作员重新扫码。
+    form.auth_code = "";
+  }
+  // H5 模式不带 channel_extra:公开/网页支付不需要付款码,后端也不接受多余键。
+
+  creating.value = true;
+  try {
+    const res = await UnifiedAPI.cashierCreate(params);
+    const vo = res.data.data;
+    if (!vo) {
+      // 没有订单可展示就不要清幂等键:此时再点一次会复用同一个号,避免重复落单。
+      ElMessage.warning("下单成功但未返回订单信息,请到订单页确认");
+      return;
+    }
+    pendingOutTradeNo = "";
+    applyOrder(vo);
+  } catch {
+    // 拦截器已用后端原话弹过 toast(例如「商户已停用」)。输入保留,直接重试。
+  } finally {
+    creating.value = false;
+  }
+}
+
+async function poll() {
+  if (pollInFlight) {
+    return;
+  }
+  const current = order.value;
+  if (!current) {
+    stopPolling();
+    return;
+  }
+  const orderNo = current.order_no;
+  const elapsed = Date.now() - pollStartedAt;
+  if (!shouldPollUnified(current.trade_status, elapsed)) {
+    stopPolling();
+    if (PENDING_STATUS.has(current.trade_status) && elapsed >= POLL_MAX_MS) {
+      pollTimedOut.value = true;
+    }
+    return;
+  }
+
+  pollInFlight = true;
+  try {
+    // R70:轮询必须静默 —— 非静默失败会被响应拦截器每 3 秒弹一次 toast。
+    const res = await UnifiedAPI.cashierOrder(orderNo, true);
+    const vo = res.data.data;
+    // 一个 await 就是一个可以换单的缝:回来先确认屏幕上还是同一单。
+    if (!order.value || order.value.order_no !== orderNo || !vo) {
+      return;
+    }
+    order.value = vo;
+    const now = Date.now();
+    if (!shouldPollUnified(vo.trade_status, now - pollStartedAt)) {
+      stopPolling();
+      if (PENDING_STATUS.has(vo.trade_status) && now - pollStartedAt >= POLL_MAX_MS) {
+        pollTimedOut.value = true;
+      }
+    }
+  } catch {
+    // R70:静默轮询失败保留上次已知状态,不弹 toast。下一个 3 秒继续问。
+  } finally {
+    pollInFlight = false;
+  }
+}
+
+/** R72:为当前订单铸造公开 H5 支付页链接;展示/复制前仍校验协议 */
+async function generateH5Link() {
+  const o = order.value;
+  if (!o || h5LinkLoading.value) {
+    return;
+  }
+  h5LinkLoading.value = true;
+  try {
+    const res = await UnifiedAPI.cashierH5Link(o.order_no);
+    const url = res.data.data?.url || "";
+    if (url && !isSafeHttpUrl(url)) {
+      // 不打印链接本身:链接里带签名参数
+      console.warn("不安全的 H5 支付链接,已按文本展示");
+    }
+    // 迟到的响应:期间已经换了单
+    if (order.value?.order_no === o.order_no) {
+      h5Link.value = url;
+    }
+  } catch {
+    // 拦截器已提示(例如「商户已停用」)
+  } finally {
+    h5LinkLoading.value = false;
+  }
+}
+
+function openPayPage() {
+  const url = h5PayUrl.value;
+  if (!url || !isSafeHttpUrl(url)) {
+    return;
+  }
+  window.open(url, "_blank", "noopener,noreferrer");
+}
+
+function reset() {
+  stopPolling();
+  qrSeq += 1;
+  qrDataUrl.value = "";
+  order.value = null;
+  h5Link.value = "";
+  h5LinkLoading.value = false;
+  pollTimedOut.value = false;
+  pollStartedAt = 0;
+  pendingOutTradeNo = "";
+  inputHint.value = "";
+  form.amount_text = "";
+  form.subject = "";
+  form.auth_code = "";
+  // mode / way_code / mch_no 保留:柜台连收几笔通常是同一个组合
+}
+
+// 切换模式就换回该模式的默认方式,并把上一模式残留的付款码抹掉
+watch(mode, (m) => {
+  form.way_code = DEFAULT_WAY_CODE[m];
+  form.auth_code = "";
+  inputHint.value = "";
+});
+
+// 主扫:codeUrl / payUrl 生成二维码,codeImgUrl 交给 <img>(computed)。
+// seq 用来丢弃迟到的异步结果 —— A 单的码不能渲染到 B 单上。
+watch(
+  () => [order.value?.order_no, order.value?.pay_data, order.value?.pay_data_type] as const,
+  async () => {
+    const seq = ++qrSeq;
+    qrDataUrl.value = "";
+    const o = order.value;
+    if (!o || !WAY_CODES_BY_MODE.scan.includes(o.way_code)) {
+      return;
+    }
+    const kind = (o.pay_data_type || "").toLowerCase();
+    const value = o.pay_data || "";
+    if (!value) {
+      return;
+    }
+    if (kind === "codeurl" || kind === "payurl") {
+      if (!isSafeHttpUrl(value)) {
+        console.warn("不安全的支付二维码内容,已按文本展示");
+        return;
+      }
+      try {
+        const url = await QRCode.toDataURL(value, { width: 260, margin: 2 });
+        if (seq === qrSeq) {
+          qrDataUrl.value = url;
+        }
+      } catch (e) {
+        console.warn("支付二维码生成失败,已按文本展示", e);
+      }
+    } else if (kind === "codeimgurl" && !isSafeHttpUrl(value)) {
+      console.warn("不安全的 codeImgUrl,已按文本展示");
+    }
+  },
+  { immediate: true }
+);
+
+// H5:非 http/https 的 pay_data 只按文本展示,并在控制台留一条 WARN
+watch(
+  () => [order.value?.order_no, order.value?.way_code, order.value?.pay_data] as const,
+  () => {
+    const o = order.value;
+    if (!o || o.way_code !== "WX_H5") {
+      return;
+    }
+    const value = o.pay_data || "";
+    if (value && !isSafeHttpUrl(value)) {
+      console.warn("不安全的 H5 支付链接,已按文本展示");
+    }
+  },
+  { immediate: true }
+);
+
+onMounted(() => {
+  loadMerchants();
+});
+
+// 离开页面必须停表:keep-alive 走的是失活不是卸载,两处都要挂。
+onBeforeUnmount(stopPolling);
+onDeactivated(stopPolling);
+// 回来时若这一单还在等,把表接上(不重置 5 分钟窗口:它是从建单开始算的墙钟)
+onActivated(() => {
+  const o = order.value;
+  if (!o) {
+    return;
+  }
+  const elapsed = Date.now() - pollStartedAt;
+  if (shouldPollUnified(o.trade_status, elapsed)) {
+    if (pollTimer === undefined) {
+      startPolling();
+    }
+  } else if (PENDING_STATUS.has(o.trade_status) && elapsed >= POLL_MAX_MS) {
+    pollTimedOut.value = true;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.unified-cashier {
+  display: flex;
+  justify-content: center;
+}
+
+.pane {
+  width: 560px;
+  margin-bottom: 16px;
+}
+
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.header-hint {
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+}
+
+.warn {
+  margin-top: 8px;
+  font-size: 13px;
+  color: var(--el-color-warning);
+}
+
+.amount-line {
+  display: flex;
+  align-items: baseline;
+  justify-content: center;
+  margin: 4px 0 16px;
+
+  .amount-unit {
+    font-size: 22px;
+    color: var(--el-text-color-regular);
+  }
+
+  .amount-value {
+    margin-left: 4px;
+    font-size: 34px;
+    font-weight: 600;
+    color: var(--el-text-color-primary);
+    font-variant-numeric: tabular-nums;
+  }
+}
+
+.meta {
+  margin-bottom: 16px;
+}
+
+.pay-area {
+  margin: 16px 0;
+  text-align: center;
+}
+
+.qr-wrap {
+  display: inline-block;
+  padding: 10px;
+  line-height: 0;
+  background: #fff;
+  border: 1px solid var(--el-border-color);
+}
+
+.pay-data-text {
+  max-height: 220px;
+  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;
+}
+
+.hint {
+  margin-top: 10px;
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+}
+
+.text-muted {
+  color: var(--el-text-color-secondary);
+}
+
+.err-box {
+  padding: 8px 12px;
+  margin-top: 12px;
+  font-size: 13px;
+  line-height: 1.7;
+  color: var(--el-color-danger);
+  background: var(--el-color-danger-light-9);
+  border-radius: 4px;
+}
+
+.poll-line {
+  margin-top: 12px;
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+  text-align: center;
+}
+
+.h5-link {
+  padding-top: 12px;
+  margin-top: 16px;
+  border-top: 1px dashed var(--el-border-color);
+}
+
+.h5-link__title {
+  margin-bottom: 8px;
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--el-text-color-regular);
+}
+
+.link-line {
+  display: flex;
+  gap: 6px;
+  align-items: center;
+  margin-top: 8px;
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+}
+
+.link-text {
+  flex: 1;
+  word-break: break-all;
+}
+
+.footer {
+  margin-top: 18px;
+  text-align: center;
+}
+</style>