Przeglądaj źródła

feat(汇安融): 收款链接(四)—— 前端:顾客页 + 商户页收款二维码入口

后端三刀已齐(2109cb6c 存储与顾客页端点 / b00a8348 管理端取链接 / 8b4619ba 装配接线),
本刀把页面接上。五个文件,全部是前端。

路由与免登录声明
- router/index.ts 新增 /checkout/har/:token(name: HarCheckout),与乐收赢那条同段。
  ★ 为什么是**三段**路径:后端 HarCheckoutLinkService#buildUrl 的 PATH_PREFIX 是
    /#/checkout/har/,har 是路径里的**字面段**不是参数。若照抄乐收赢的
    checkout/:token,"har" 会被当成 token ⇒ 顾客页拿着 "har" 去查链接,一律「链接无效」。
- plugins/permission.ts:PUBLIC_ROUTE_NAMES 加入 "HarCheckout"。
  按名字放行 ⇒ 新增公开页必须有人在这里**显式**写一行(这正是当初不按路径前缀的用意)。
  两处 name 必须一致,否则顾客页静默变回「跳登录页」—— 页面本身没有任何报错。
- main.ts 无需改:标题净化用的是 hash.startsWith("#/checkout/"),已覆盖 #/checkout/har/。

顾客页(新)views/checkout/har/index.vue
- 与乐收赢顾客页同构(同模板、同键盘几何、同四相:加载/无效/可付/跳转),差异都在数据面:
  · GET  /payment/har/checkout/<token>         → 读 merchant_name
  · POST /payment/har/checkout/<token>/orders  → body { price: Number(fenToYuan(fen)) }
  · 取回 pay_url / order_no / price
- ★ 金额单位是**元**(HAR 全通道如此;乐收赢那份是分),下限 1 元 ——
  与后端 HarAmountFloor.MIN 配对(2026-10-06 裁定:下限还是 1)。
  内部仍以分做整数运算(MIN_FEN = 100),只在出网/回显时转元;
  回显价走 echoedYuanToFen(字符串→分,不做浮点乘),转不出来才回退本地分。
- 自己的 axios 实例(不过全局拦截器):管理端那支会附 Authorization 并弹 ElMessage,
  顾客页不能要(顾客页连登录态都没有)。
- defineOptions({ name: "HarCheckout" }) —— 与路由 name 一致(keep-alive 契约)。

商户页 views/module_payment/har/merchant/index.vue
- 操作列 150 → 220,新增「收款二维码」按钮,排在超管专属的 编辑/删除 之前:
  它**对两种身份都显示**(租户取自己名下那行、超管可代取,后端两道数据闸)。
- 弹窗:长期有效提示 + 商户(merchant_code / merchant_name 快照)+ 只读链接输入框 +
  二维码画布(QRCode.toCanvas 240px),底部 复制链接 / 下载二维码 / 关闭。
- ★ 二维码内容与可复制内容都取后端返回的**整条 url**,前端不拼:
  拼第二个副本的后果是「复制的链接」与「二维码里的链接」不是同一条。
- api/module_payment/har.ts:新增 HarCheckoutLinkVO(三格)与 HarAPI.checkoutLink(id),
  POST(首次调用会写一行,幂等返回同一条——这是刻意的,见那里 javadoc)。

验证(本机实跑读数)
- pnpm ts:check:289 错 = 基线 289(logs/_tscheck-har-cashier.txt,12:36 实跑)。
  唯一差分:har/merchant/index.vue 一条既有 TS2322 从 162 行挪到 167 行 ——
  逐行打印两版都是 :value="t.id",同一句被新增行推移。**新增错误 0**。
- pnpm build:✓ built in 57.71s,exit 0(logs/_build-har-checkout.txt)。
- dist 产物核对(不止步于「构建成功」,按路由映射逐条钉):
  · checkout/har/index.vue → dist/js/index.DvK02O4b.js(内含 payment/har/checkout、
    最低、请输入金额、重新获取链接);
  · module_payment/har/merchant/index.vue → dist/js/index.D3PpoOoz.js(内含 收款码_、长期有效);
  · 路由字面量 checkout/har/:token 在 dist/js/index.DKGjoO3J.js。
  chunk mtime 均为 2026-10-06 13:16:33 = 本次构建产物(非旧残留)。

未验证(如实记)
- pay_url 从未被真机扫过;顾客页从未用真实 token 在浏览器里打开过。
  这条要等部署后拿真实商户取链接实测。
alphaH 1 tydzień temu
rodzic
commit
3ae6bf8a9d

+ 39 - 0
frontend/src/api/module_payment/har.ts

@@ -50,6 +50,25 @@ export interface HarMerchantSaveParams {
   description?: string;
 }
 
+/**
+ * 收款链接(后端 HarCheckoutLinkVO)—— 商户页「收款二维码」弹窗的三格。
+ *
+ * ★ 只认这三格,与后端 VO 逐字段对应(租户号、主键、token 都不回给页面 —— 见那个 VO 的 javadoc)。
+ * ★ `merchant_name` 是**链接行上的快照**,不是商户行那一列:商户改过名但还没重新取过
+ *   链接时两者会不同,而操作员看到的名字必须与顾客打开链接时看到的一样。
+ * ★ `url` 整条来自后端 `HarCheckoutLinkService#buildUrl`。**不要**用 `merchant_code`
+ *   或域名自己拼:基址推导只有那一份,拼第二个副本的后果是「复制的链接」与
+ *   「二维码里的链接」不是同一条。
+ */
+export interface HarCheckoutLinkVO {
+  /** 汇安融商户编号 —— 操作员据此确认取的是哪一家 */
+  merchant_code: string;
+  /** 商户显示名(**快照**)。可能为 null:商户行没填名字时不编一个出来 */
+  merchant_name?: string | null;
+  /** 完整收款链接(形如 `https://<域名>/#/checkout/har/<token>`) */
+  url: string;
+}
+
 /** 门店(后端 HarShopVO) */
 export interface HarShopVO {
   id?: string;
@@ -280,6 +299,26 @@ const HarAPI = {
     });
   },
 
+  /**
+   * 取(必要时创建)该商户的**收款链接** —— 商户页操作列那个「收款二维码」按钮。
+   *
+   * ★ 是 **POST** 不是 GET:首次调用会**写一行**(`pay_har_checkout_link`)。这是刻意的 ——
+   *   链接要印出来/贴出去,每取一次换一个 token 等于让客户手上那张作废,
+   *   所以同一个商户反复调用返回**同一条**(服务层的幂等,见 `HarCheckoutLinkService#issue`)。
+   *   前端**不要**自作主张缓存结果或改成 GET。
+   *
+   * ★ `id` 是**字符串** —— 雪花 id 超 JS 安全整数(同 merchantDelete 的说明)。
+   *
+   * ★ 权限串复用商户 `list`(不新增权限点 ⇒ **不需要新的菜单 SQL**),所以能看见这一页的
+   *   角色都点得动;租户也只能取到**自己名下**那一行(后端两道数据闸)。
+   */
+  checkoutLink(id: string) {
+    return request<ApiResponse<HarCheckoutLinkVO>>({
+      url: `${API_PATH}/merchant/${id}/checkout-link`,
+      method: "post",
+    });
+  },
+
   // ---------- 门店 ----------
 
   /** 门店列表(分页) */

+ 5 - 1
frontend/src/plugins/permission.ts

@@ -14,8 +14,12 @@ import { usePermissionStore, useUserStore, useEnterpriseStore } from "@/store";
  *   `IdUtil.fastSimpleUUID()` 这一种令牌形状上,换一种生成方式就等于「所有链接都打不开」。
  * ★ 只放**顾客页**,`/login` 仍走下面的精确清单 —— 它在已登录时要重定向到首页,
  *   语义不同,别合并。
+ *
+ * ★ 第二个名字 `HarCheckout` 是汇安融通道的同款顾客页(`/#/checkout/har/<token>`,
+ *   路径多一段 `har` 字面量)。加它是一次**显式声明** —— 这正是上面第一条注释要的效果:
+ *   按名字放行时,新公开页面必须有人在这里写一行,而不会因为路径恰好长得像就自动开放。
  */
-const PUBLIC_ROUTE_NAMES = new Set(["Checkout"]);
+const PUBLIC_ROUTE_NAMES = new Set(["Checkout", "HarCheckout"]);
 
 function isPublicPage(to: RouteLocationNormalized): boolean {
   return to.matched.some((r) => typeof r.name === "string" && PUBLIC_ROUTE_NAMES.has(r.name));

+ 11 - 0
frontend/src/router/index.ts

@@ -51,6 +51,17 @@ export const constantRoutes: RouteRecordRaw[] = [
     meta: { hidden: true, title: "收款" },
     component: () => import("@/views/checkout/index.vue"),
   },
+  {
+    // 汇安融顾客页(收款链接 `/#/checkout/har/<token>` 的落点)—— 免登录,同上一段。
+    // ★ 为什么是**三段**路径而乐收赢是两段:`HarCheckoutLinkService#buildUrl` 的
+    //   PATH_PREFIX 是 `/#/checkout/har/`,`har` 是路径里的**字面段**,不是参数。
+    //   若按乐收赢写成 `checkout/:token`,`har` 会被当成 token ⇒ 顾客页拿着 "har"
+    //   去查链接,一律「链接无效」。
+    path: "/checkout/har/:token",
+    name: "HarCheckout",
+    meta: { hidden: true, title: "收款" },
+    component: () => import("@/views/checkout/har/index.vue"),
+  },
   {
     path: "/404",
     name: "404",

+ 857 - 0
frontend/src/views/checkout/har/index.vue

@@ -0,0 +1,857 @@
+<template>
+  <div class="checkout">
+    <!-- ① 载入中:占位的形状照着真内容量(商户名 / 标签 / 金额 / 键盘块),不给任何文案
+         —— 顾客看到的第一次「有内容」必须是真内容 -->
+    <template v-if="phase === 'loading'">
+      <header class="panel">
+        <div class="skeleton skeleton--name"></div>
+        <div class="skeleton skeleton--label"></div>
+        <div class="skeleton skeleton--amount"></div>
+      </header>
+      <div class="pad-skeleton"></div>
+    </template>
+
+    <!-- ② 链接不可用:未知 / 空 / 已停用**共用同一句**(服务端就是这么回的,
+         不告诉打开链接的人「这条链接存在但被停用」)。这一步**没有任何操作**可做,
+         所以不放按钮 —— 放一个点了没用的按钮比不放更糟。 -->
+    <template v-else-if="phase === 'invalid'">
+      <header class="panel">
+        <h1 class="panel__name">收款</h1>
+      </header>
+      <div class="notice">
+        <p class="notice__title">收款链接无效或已停用</p>
+        <p class="notice__hint">请向收款方重新获取链接,或直接扫描对方出示的收款码。</p>
+      </div>
+    </template>
+
+    <!-- ③ 正常:商户名 + 金额 + 键盘(最右那一格就是「付款」)。
+         顾客第一眼要确认的是「钱付给谁」,所以商户名排在最上面一行。 -->
+    <template v-else>
+      <header class="panel">
+        <!-- ★ 商户名可能为 null(发链接时那一行没填名字)⇒ 用兜底标题,
+             不显示一个空白标题。文案取自后端 HarCheckoutInfoVO 的注释。 -->
+        <h1 class="panel__name">{{ merchantName || "收款" }}</h1>
+        <p class="panel__label">金额</p>
+
+        <!-- 金额那行同时是**显示**:顾客不需要点它。`inputmode="none"` —— 点它也不弹系统
+             键盘(键盘就在下面),而桌面上的实体键盘照样能直接打字。 -->
+        <div class="amount" :class="{ 'amount--error': !!hint, 'amount--shake': shaking }">
+          <span class="amount__symbol">¥</span>
+          <input
+            ref="amountEl"
+            v-model="amountText"
+            class="amount__input"
+            type="text"
+            inputmode="none"
+            autocomplete="off"
+            aria-label="付款金额(元)"
+            placeholder="请输入金额"
+            :disabled="submitting || redirecting"
+            @input="onInput"
+            @keyup.enter="submit"
+          />
+        </div>
+      </header>
+
+      <!-- 垫在中间的那块空白:把键盘压到屏幕下沿,金额与键盘之间不插别的东西 -->
+      <div class="spacer"></div>
+
+      <p class="hint" :class="{ 'hint--error': !!hint }">
+        {{ hint || `最低 ${MIN_FEN / 100} 元` }}
+      </p>
+
+      <!-- 键盘:4 列,最右一列上面是退格、下面是纵跨三行的「付款」。
+           ★ 付款键**长在键盘里**,不再另起一个通栏按钮 —— 顾客本来就要做两件事
+             (输金额、付款),两件事就长在同一块板上,中间不插第二个可点的东西。
+           ★ 「0」横跨两格:这是国内收银台键盘的固定形状,照做即可。 -->
+      <div class="pad" role="group" aria-label="金额键盘">
+        <button
+          v-for="k in PAD_KEYS"
+          :key="k.key"
+          type="button"
+          class="pad__key"
+          :class="{ 'pad__key--pay': k.pay, 'pad__key--wide': k.wide }"
+          :disabled="submitting || redirecting"
+          :aria-label="
+            k.key === BACKSPACE ? '退格' : k.pay ? '付款' : k.key === '.' ? '小数点' : k.key
+          "
+          @click="press(k.key)"
+        >
+          <!-- 退格用内联 SVG,不用 `⌫` 那个码位:它在部分安卓字体里会掉成方框 -->
+          <svg v-if="k.key === BACKSPACE" class="pad__icon" viewBox="0 0 24 24" aria-hidden="true">
+            <path
+              d="M9 5h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-5.6-7z"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="1.7"
+              stroke-linejoin="round"
+            />
+            <path
+              d="M11.4 9.6l4.8 4.8m0-4.8l-4.8 4.8"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="1.7"
+              stroke-linecap="round"
+            />
+          </svg>
+          <template v-else>{{ k.pay ? (submitting ? "…" : "付款") : k.key }}</template>
+        </button>
+      </div>
+    </template>
+
+    <!-- 跳转中:盖满整屏。顾客此刻已经不该再碰键盘了 —— 键本来就 disabled,
+         再留一个看得见的键盘只会诱他去点。顾客还能看到「付多少」,
+         跳走之后这页就不归我们管了。 -->
+    <div v-if="redirecting" class="redirect">
+      <div class="redirect__spinner"></div>
+      <p class="redirect__text">正在前往支付页面,应付 <strong>¥{{ fenToYuan(paidFen) }}</strong></p>
+      <p v-if="paidOutTradeNo" class="redirect__no">订单号 {{ paidOutTradeNo }}</p>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+/**
+ * 汇安融顾客页 —— 收款链接 `/#/checkout/har/<token>` 的落点。**免登录**。
+ * 逐条对标乐收赢同款页面(`views/checkout/index.vue`),差异只有下面这几处:
+ *
+ * 【与乐收赢那份的差异(都是通道契约,不是风格)】
+ *   · 接口前缀 `/payment/har/checkout/...`;路由多一段 `har` **字面量**
+ *     (后端 `HarCheckoutLinkService#buildUrl` 的 PATH_PREFIX 是 `/#/checkout/har/`);
+ *   · 金额单位是**元**(乐收赢是分):提交发 `{ price: <元> }`,回显读 `price`;
+ *     HAR 全通道如此(同收银台/开放 API)。
+ *   · 下单应答里是 `pay_url` / `order_no`(乐收赢是 `code_url` / `out_trade_no`)。
+ *
+ * 【它做什么】三件事,顺序固定:显示商户名 → 顾客输一个金额 → 发起支付并跳到上游收银台。
+ * 没有第四件。「不含其他功能」是需求原文:这一屏面对的是正要付钱的人,
+ * 每多一个可点的东西就多一次「我该按哪个」的犹豫。
+ *
+ * 【为什么不用 `@/utils/request`】那个实例的两个拦截器都是给**管理端**写的,在顾客页会闯祸:
+ *   · 请求拦截器会自动附上 `Authorization`(这台浏览器里可能存着一个管理端令牌 ——
+ *     顾客页是免登录的,带上它没有意义,徒增一个「谁的令牌」的变量);
+ *   · 响应拦截器用 `ElMessage` **弹窗**报错,且对非 GET 的成功响应弹「操作成功」。
+ *     顾客页的错误必须**写在页面上**(顾客不会去读一个 3 秒后消失的浮层,
+ *     而那正是他付款失败的那一刻)。
+ * 所以这里自建一个实例,**不挂任何拦截器**,每个响应都自己看 `code`。
+ *
+ * 【为什么是 hash 路由】`createWebHashHistory` —— 后端拼的链接形如
+ * `<base>/#/checkout/har/<token>`(`HarCheckoutLinkService#buildUrl` 的 PATH_PREFIX)。
+ * 拼成 `/checkout/har/<token>` 会命中静态路由表末尾的通配 404。
+ */
+defineOptions({
+  name: "HarCheckout",
+  inheritAttrs: false,
+});
+
+import { onMounted, onUnmounted, ref } from "vue";
+import { useRoute } from "vue-router";
+import axios from "axios";
+
+/**
+ * 收款金额下限,单位**分**(100 分 = 1 元)。与后端 `HarAmountFloor.MIN` 是同一个数
+ * —— **改一处必须改另一处**(那条业务线是用户 2026-10-06 裁定的「下限还是1」,
+ * 只挂顾客页这条腿;收银台/后台仍是 0.01)。
+ *
+ * 【为什么顾客页也要拦一道】后端那道是真的闸(这条腿谁都能调),这里只是把话说在
+ * 顾客按下按钮**之前**:不拦的话他收到的是一句报文措辞
+ * (`price 不能低于 1 元`),而那是一位正要付钱的人。
+ */
+const MIN_FEN = 100;
+
+/**
+ * 顾客页的浏览器标题。**有意与后台不同**。
+ *
+ * 【为什么不能靠全局那支】全站标题由 `main.ts` 的 `setTitleAndFavicon()` 从
+ * `sys_web_title`(库里的系统配置)取,值是**管理端**的名字(「公司管理」)——
+ * 那是给操作员看的,不该出现在一位正要付钱的顾客的标签页上。
+ * 而且那个函数是**异步**的(要先等一次配置请求),谁先跑完不确定 ⇒
+ * 光在这里写还不够,`main.ts` 那侧也加了一道同名判据(`hash.startsWith("#/checkout/")`,
+ * 汇安融这条路径也在它覆盖范围内 —— 见那里的注释)。
+ */
+const PAGE_TITLE = "Payment Platform";
+
+type Phase = "loading" | "invalid" | "ready";
+
+/** 只看 `code` 的最小信封形状。`Result.ok` 的成功码是 **0**(不是 200)。 */
+interface Envelope<T> {
+  code?: number;
+  msg?: string;
+  data?: T;
+}
+
+/**
+ * 顾客页专用实例:相对基址(与页面同源),**无拦截器**。
+ * ★ 超时给了 20 秒:这一步背后是「我们建单 + 上游下单」两次网络往返,
+ *   而顾客多半在移动网络下。
+ */
+const http = axios.create({
+  baseURL: import.meta.env.VITE_APP_BASE_API,
+  timeout: 20000,
+  headers: { "Content-Type": "application/json;charset=utf-8" },
+});
+
+const route = useRoute();
+const token = String(route.params.token ?? "");
+
+const phase = ref<Phase>("loading");
+const merchantName = ref<string | null>(null);
+const amountText = ref("");
+const hint = ref("");
+const submitting = ref(false);
+const redirecting = ref(false);
+const shaking = ref(false);
+const paidFen = ref(0);
+const paidOutTradeNo = ref("");
+const amountEl = ref<HTMLInputElement>();
+
+// ────────────────────────────── 金额 ──────────────────────────────
+
+/**
+ * 键盘的键序与摆位。`BACKSPACE` / `PAY` 是两种**非数字**键,各自单独给一个值,
+ * 模板据此换成图标或文案;`wide` / `pay` 只影响栅格占位(见 .pad)。
+ *
+ * ★ 这两样(`0` 横跨两格、`付款` 纵跨最右三行)不是排版偏好,是国内收银台键盘的
+ *   固定形状 —— 形状对了,顾客不用读文案就知道怎么按。
+ */
+const BACKSPACE = "back";
+const PAY = "pay";
+
+interface PadKey {
+  key: string;
+  /** 「0」横跨两格 */
+  wide?: boolean;
+  /** 纵跨最右三行的「付款」键 */
+  pay?: boolean;
+}
+
+const PAD_KEYS: PadKey[] = [
+  { key: "1" },
+  { key: "2" },
+  { key: "3" },
+  { key: BACKSPACE },
+  { key: "4" },
+  { key: "5" },
+  { key: "6" },
+  { key: PAY, pay: true },
+  { key: "7" },
+  { key: "8" },
+  { key: "9" },
+  { key: "0", wide: true },
+  { key: "." },
+];
+
+/**
+ * 整数位上限。**键盘让这件事变成必然**:按住一个键不放,位数会一直被推上去,
+ * 而金额那行的字号是跟视口放大的 —— 位数一多,那行字就顶出纸面(输入框自己会横向滚,
+ * 顾客看到的是一个被裁掉头部的金额)。
+ * 7 位 = 999 万元,够任何线下收款;这个数还与 `--mono` 的字宽对得上(见 .amount__input)。
+ * 上限只写在 `onInput` 这一处 —— 纸键盘与实体键盘共用同一条过滤。
+ */
+const MAX_INT_DIGITS = 7;
+
+/**
+ * 输入过滤:纸键盘与物理键盘**唯一**的入口。
+ * 只留数字与一个小数点,小数位最多两位(多打的那一位直接不显示,而不是事后报错);
+ * 顺手去掉多余的整数前导零("05" → "5"),免得金额行印出个 `05`;
+ * 再把开头那一个孤零零的小数点补成 `0.` —— 顾客先按 `.` 再按数字,要的是 `0.5` 而不是 `.5`
+ * (`yuanToFen` 本来就吃得下 `.5`,但金额行上顶着一个光秃秃的 `.` 是不对的)。
+ */
+function onInput() {
+  const cleaned = amountText.value
+    .replace(/[^\d.]/g, "")
+    .replace(/^(\d*\.?\d{0,2}).*$/, "$1")
+    .replace(/^0+(?=\d)/, "")
+    .replace(/^\./, "0.");
+  const [intPart = "", decPart] = cleaned.split(".");
+  amountText.value =
+    intPart.length > MAX_INT_DIGITS
+      ? intPart.slice(0, MAX_INT_DIGITS) + (decPart === undefined ? "" : `.${decPart}`)
+      : cleaned;
+  hint.value = "";
+  // 顾客一动键盘,上一条错误提示的手感就结束了(见 reject 的注释)
+  shaking.value = false;
+}
+
+/**
+ * 键盘按下了一格:数字键接一位、退格键删一位、付款键就是提交。
+ *
+ * ★ 过滤与上限**只有 `onInput` 一处** —— 两边各写一份,迟早出现
+ *   「物理键盘打不进去、点键盘能打进去」这类只在一种输入下成立的缺陷。
+ */
+function press(key: string) {
+  if (submitting.value || redirecting.value) {
+    return;
+  }
+  if (key === PAY) {
+    submit();
+    return;
+  }
+  amountText.value =
+    key === BACKSPACE ? amountText.value.slice(0, -1) : amountText.value + key;
+  onInput();
+}
+
+/**
+ * 校验没过:把话说在页面上,并让金额行抖一下。
+ *
+ * ★ 「抖」不能靠每次失败都重挂一次 CSS 动画(同一个类连着挂两次,动画不会重放)——
+ *   这里只负责**置真**,由 `onInput()`(顾客的下一次动作)置假:顾客不动它就一直为真,
+ *   也不会再抖第二下,正好。
+ */
+function reject(message: string) {
+  hint.value = message;
+  shaking.value = true;
+  amountEl.value?.focus();
+}
+
+/**
+ * 元 → 分。**按字符串拆,不做浮点乘法。**
+ *
+ * ★ `Math.round(Number("19.99") * 100)` 在这里恰好也对(误差远小于 0.5),但它是**碰巧**对:
+ *   这条式子对「多少分」这个整数结果的正确性,依赖 IEEE754 那条误差上界,而
+ *   `"1.005"` 这类三位小数的结果就取决于先 round 还是先乘。拆字符串没有这个问题,
+ *   而且顺手把「不是合法金额」的输入在**同一个函数**里判掉(`null`)。
+ *
+ * @returns 分;`null` = 输入不是合法金额(空、多个小数点、超过两位小数)
+ */
+function yuanToFen(text: string): number | null {
+  // 容忍「打到一半」的输入,不把它当错误:末尾只挂一个小数点("5.")。
+  // 也吃省略整数位的 ".5"(`Number("")` 是 0,所以拆分那一步天然处理得了),
+  // 但那条**不来自界面** —— `onInput` 会把开头的小数点补成 `0.`,只有直接调用本函数的
+  // 代码才可能递进来。留着它是因为这里本来就是个纯函数,不必依赖调用方的形态。
+  const s = text.trim().replace(/\.$/, "");
+  if (!/^(\d+(\.\d{1,2})?|\.\d{1,2})$/.test(s)) {
+    return null;
+  }
+  const [intPart, decPart = ""] = s.split(".");
+  const fen = Number(intPart) * 100 + Number((decPart + "00").slice(0, 2));
+  // 17 位数字以上已经超出安全整数 —— 这种输入只可能是误触,别拿它去建单
+  return Number.isSafeInteger(fen) ? fen : null;
+}
+
+/** 分 → 元(显示用,固定两位小数)。 */
+function fenToYuan(fen: number): string {
+  return (fen / 100).toFixed(2);
+}
+
+// ────────────────────────────── 两跳 ──────────────────────────────
+
+/** 开屏:拿商户显示名。拿不到(404 / 网络错)都进 `invalid`,**不区分** —— 见模板上的注释。 */
+async function loadInfo() {
+  try {
+    const res = await http.get<Envelope<{ merchant_name?: string | null }>>(
+      `/payment/har/checkout/${encodeURIComponent(token)}`
+    );
+    if (res.data?.code !== 0) {
+      phase.value = "invalid";
+      return;
+    }
+    merchantName.value = res.data.data?.merchant_name ?? null;
+    phase.value = "ready";
+    // ★ 这里**故意不**自动聚焦金额框:键盘已经印在纸上了,自动聚焦只会在手机上
+    //   弹出一块系统软键盘,正好盖住刚画好的那半张纸。
+    //   (聚焦还剩一个用途:校验没过时把光标放回去,好让桌面上的实体键盘能接着打字。)
+  } catch {
+    // ★ 网络不通也归到 `invalid` 是**有意的取舍**:这条腿的失败里,
+    //   「链接坏了」远多于「网络坏了」,而顾客对两者的反应是同一个动作
+    //   (找收款方)。给一句「网络异常,请重试」会让他在一个**永远不会成功**的
+    //   链接上反复重试。真·网络问题在支付那一步会给出可照做的提示。
+    phase.value = "invalid";
+  }
+}
+
+/** 按下「付款」:建单 → 上游下单 → 把浏览器送到上游给的链接。 */
+async function submit() {
+  if (submitting.value || redirecting.value) {
+    return;
+  }
+  const fen = yuanToFen(amountText.value);
+  if (fen === null) {
+    reject("请输入正确的金额,最多两位小数");
+    return;
+  }
+  if (fen < MIN_FEN) {
+    reject(`收款金额最低 ${MIN_FEN / 100} 元`);
+    return;
+  }
+
+  submitting.value = true;
+  hint.value = "";
+  try {
+    // ★ 金额以**元**发送(HAR 全通道如此)。数字由分换算回来(`fenToYuan` 是
+    //   `toFixed(2)` 的字符串再转数)—— 与收银台那条腿同一个形状
+    //   (`Number((fen / 100).toFixed(2))`),不直接做 `fen / 100` 的浮点除法。
+    const res = await http.post<
+      Envelope<{ order_no?: string; pay_url?: string; price?: number | string | null }>
+    >(`/payment/har/checkout/${encodeURIComponent(token)}/orders`, {
+      price: Number(fenToYuan(fen)),
+    });
+
+    const payUrl = res.data?.data?.pay_url;
+    if (res.data?.code !== 0 || !payUrl) {
+      // 服务端的话是可以照做的(例如「支付发起失败,请稍后重试」),原样显示。
+      // 拿不到 pay_url 而 code 是 0 属于契约被破坏,同样当失败处理 —— 否则顾客会停在
+      // 一个「按钮点了没反应」的页面上,而控制台里什么都没有。
+      hint.value = res.data?.msg || "支付发起失败,请稍后重试";
+      return;
+    }
+
+    // ★ 跳转前把金额与单号显示出来:这是顾客核对金额的最后一眼,
+    //   单号是他「付了但页面没反应」时报给客服的凭据(见 HarCheckoutOrderVO 的注释)。
+    //   金额优先用服务端回显的那一个(订单的真正金额);回显不可解析时退回首部算出的 fen。
+    paidFen.value = echoedYuanToFen(res.data.data?.price) ?? fen;
+    paidOutTradeNo.value = res.data.data?.order_no ?? "";
+    redirecting.value = true;
+
+    // ★ 直接送到上游给的链接,**不包短链、不做中转页** —— 多一跳就多一处能把顾客卡住的地方。
+    //   给一帧让上面的提示先渲染出来(否则这一句谁也看不见,页面直接白了)。
+    await new Promise((r) => window.setTimeout(r, 80));
+    window.location.href = payUrl;
+  } catch {
+    hint.value = "网络异常,请检查网络后重试";
+  } finally {
+    submitting.value = false;
+  }
+}
+
+/**
+ * 服务端回显的 `price`(**元**,JSON 里可能是 number)→ 分。
+ *
+ * ★ 走字符串拆分(复用 `yuanToFen`),不做浮点乘法 —— 与提交方向同一条理由。
+ *   回显值是两位小数(就是我们自己发上去的那个数)时恒可解析;万一不是,返回 `null`
+ *   由调用处退回首部算出的 fen,而不是显示一个来路不明的金额。
+ */
+function echoedYuanToFen(price: number | string | null | undefined): number | null {
+  if (price === null || price === undefined) {
+    return null;
+  }
+  return yuanToFen(String(price));
+}
+
+/**
+ * 进页面时换成顾客页自己的标题,**离开时还原**。
+ *
+ * ★ 还原不是洁癖:这是一个 SPA,浏览器后退键会把人送回后台那一屏,
+ *   而 `main.ts` 只在应用启动时设一次标题 —— 不还原的话,后台的标签页会一直挂着
+ *   「Payment Platform」,而那个名字对操作员没有任何意义。
+ * ★ 自己之前的标题由**自己**存一份再写回(不是写死一个常量回填):将来若全站标题
+ *   改成从别处来,这里不需要跟着改。
+ */
+let titleBefore: string | null = null;
+
+onMounted(() => {
+  titleBefore = document.title;
+  document.title = PAGE_TITLE;
+  loadInfo();
+});
+
+onUnmounted(() => {
+  if (titleBefore !== null) {
+    document.title = titleBefore;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+/* ══════════════════════════════════════════════════════════════════════
+   顾客页的视觉参照是**国内收银台**(微信/支付宝那一路的扫码付款页)。
+   本页与乐收赢同款页面(`views/checkout/index.vue`)用**同一份**样式 ——
+   两条通道的顾客页面对的是同一件事,形状不该有两副。
+
+   2026-09-30 按用户给的码实扫了一页当基准(`https://p.eycard.cn/p?qr=rE3Erq`,
+   要用微信内置浏览器的 UA 才给渲染),390×844 下量到的读数:
+     · 底色 rgba(0,0,0,.02);商户名 16.6px/700 #333
+     · 「金额」标签 14.6px/700;¥ 27px/700;占位文字 30px/500 #dedede
+     · 金额数字 35.4px/700;键 87×52、圆角 5.2px、白底
+     · 键投影 0 2px 2px rgb(0 0 0 / 18%);键上的数字 20.8px/700 #000
+     · 「付款」键 87×166 —— 纵跨最右三行;「0」横跨两格;退格在右上角
+
+   前几版走的是「一张收据」:深桌面、撕口、条码、编号、作废印章。那些都是**比喻**,
+   每个比喻都要额外画一样东西,加起来就成了「设计过的海报」,而不是一个收银台。
+   这一版照抄参照页的是**比例与克制**,不是它的配色:主色仍是我们自己的 --brand。
+   ══════════════════════════════════════════════════════════════════════ */
+.checkout {
+  --brand: #4080ff;      /* = settings.ts 的 themeColor。整页只有「付款」键用它 */
+  --ink: #333;           /* 主文字(照参照页量的 #333,不是纯黑) */
+  --ink-soft: #8a8f99;   /* 次要文字:提示行 */
+  --placeholder: #dedede;
+  --page: #f2f3f5;       /* 底。参照页是 rgba(0,0,0,.02)≈#fafafa,这里压深一点:
+                            白块压在 #fafafa 上几乎分不出边界 */
+  --danger: #e34d59;     /* 校验失败。与后台 Element Plus 的 danger 同色 */
+  --key-shadow: 0 2px 2px rgb(0 0 0 / 18%), 0 1px 1px rgb(0 0 0 / 16%);
+  /* 键行高。定成变量是因为**两处**要用它:键盘自己,和加载态骨架
+     (骨架高度必须等于键区高度,否则回复一到整页会跳一下)。
+     宽屏那段会覆盖它 —— 见文件末尾的 @media。 */
+  --key-row: 66px;
+
+  /* ★ 宽度必须在这里定死,不能留给内容去决定。祖先链 html/body/#app 三层**全是
+     display:flex** ⇒ 这一层是个 flex 项,而 flex 项的 min-width:auto 落在 min-content 上:
+     内容一宽就把它顶出去,在 390 的手机上实测 scrollWidth 410(右侧半个按钮在屏幕外)。
+     width:100% 给定尺(这个页面本来就该铺满),min-width:0 允许它跟着容器收缩。 */
+  width: 100%;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  box-sizing: border-box;
+  min-height: 100vh;
+  /* 中文用系统字体 —— 顾客多半在移动网络下,不引外链字体 */
+  font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB",
+    "Microsoft YaHei", "Noto Sans SC", sans-serif;
+  color: var(--ink);
+  background: var(--page);
+
+  /* ── 宽屏:整页收成一根手机宽的柱子居中 ──
+     这一屏真正的入口是扫码,顾客一定在手机上;但**商户自己会拿电脑点开这条链接试**,
+     而不管的话 1440 下键盘就铺满整屏(实测键宽 350px),一眼就是「没管过桌面」。
+     柱子宽度取 480:比任何手机的竖屏都宽,所以 390~430 这一段**一个像素都不变**
+     (实测 390 下 panel/pad/键 的框与未加这段时逐格相同,scrollWidth 仍是 390)。
+     祖先链 html/body/#app 全是 flex ⇒ 本层是 flex 项,align-items:center 是它的交叉轴对齐,
+     子项横向居中;柱子给子项而不是给自己,页面底色才是出血的(自己收窄会在两侧留一道白边)。 */
+  align-items: center;
+
+  > *:not(.redirect) {
+    width: 100%;
+    max-width: 480px;
+  }
+}
+
+/* ── 顶部信息块:商户名 +「金额」+ 金额行,三样压在同一块白底上 ──
+   参照页也是这么排的:商户名下面直接跟「金额」标签,中间不加分隔线。 */
+.panel {
+  padding: 14px 16px 18px;
+  background: #fff;
+}
+
+.panel__name {
+  margin: 0;
+  font-size: 17px;
+  font-weight: 700;
+  line-height: 1.35;
+  word-break: break-all;
+}
+
+.panel__label {
+  margin: 18px 0 0;
+  font-size: 15px;
+  font-weight: 700;
+}
+
+/* ── 金额行:¥ 小一号、数字大,左对齐 ── */
+.amount {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 6px;
+  min-height: 62px;
+}
+
+.amount__symbol {
+  font-size: 32px;
+  font-weight: 700;
+}
+
+/* ★ 数字用 tabular-nums(**不是**等宽字体栈):位数变化时字宽不变,光标不跳。
+   参照页的金额也是系统字体加粗 —— 等宽字体是前几版「收据」那个比喻带进来的。 */
+.amount__input {
+  flex: 1;
+  min-width: 0;
+  padding: 0;
+  font-family: inherit;
+  font-size: 46px;
+  font-weight: 700;
+  color: var(--ink);
+  font-variant-numeric: tabular-nums;
+  background: transparent;
+  border: none;
+  outline: none;
+
+  /* 占位比真值小一号、细一档 —— 参照页就是这么分的(30px/500 对 35px/700,
+     这里是它那个比例放大后的 36px/500 对 46px/700) */
+  &::placeholder {
+    font-size: 36px;
+    font-weight: 500;
+    color: var(--placeholder);
+  }
+
+  /* iOS 上 disabled 会把字压成灰的,而这一格是顾客刚输入的金额,不许变淡 */
+  &:disabled {
+    color: var(--ink);
+    -webkit-text-fill-color: var(--ink);
+    opacity: 1;
+  }
+}
+
+/* 校验没过:¥ 与金额一起变红。只红这一行 —— 键盘不参与报错。 */
+.amount--error .amount__symbol,
+.amount--error .amount__input {
+  color: var(--danger);
+}
+
+/* 垫在中间的那块空白:把键盘压到屏幕下沿 */
+.spacer {
+  flex: 1;
+  min-height: 16px;
+}
+
+/* 提示行:安静时是「最低 1 元」,出错时是被拒的原因。
+   位置就在键盘正上方 —— 与参照页那行品牌字同格,顾客的眼从键盘往上一格就能读到。 */
+.hint {
+  min-height: 19px;
+  margin: 0 16px 10px;
+  font-size: 13px;
+  text-align: center;
+  color: var(--ink-soft);
+}
+
+.hint--error {
+  color: var(--danger);
+}
+
+/* ── 键盘:4 列 × 4 行,最右一列上面退格、下面「付款」 ──
+   ★ 白键压在同色底上、彼此留缝:键是**浮起来的卡片**,不是格线。
+   前几版画的是出血的表格线,那是「印在纸上的表」那个比喻带来的;
+   国内收银台一律是这种方块键。 */
+.pad {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  grid-auto-rows: var(--key-row);
+  gap: 6px;
+  padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
+}
+
+.pad__key {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  font-family: inherit;
+  font-size: 24px;
+  font-weight: 700;
+  color: #000;
+  cursor: pointer;
+  background: #fff;
+  border: none;
+  border-radius: 5px;
+  box-shadow: var(--key-shadow);
+  /* 触摸端:去掉系统那层灰色高光(它会在键上留一块脏印)、去掉长按选中,
+     也去掉双击缩放带来的那 300ms 迟疑 */
+  -webkit-tap-highlight-color: transparent;
+  touch-action: manipulation;
+  user-select: none;
+  transition: background 0.12s;
+
+  &:active:not(:disabled) {
+    background: #f0f0f0;
+  }
+
+  &:disabled {
+    cursor: default;
+    opacity: 0.5;
+  }
+
+  &:focus-visible {
+    outline: 2px solid var(--brand);
+    outline-offset: -2px;
+  }
+}
+
+.pad__key--wide {
+  grid-column: span 2;
+}
+
+/* 「付款」:纵跨最右三行。整页唯一的亮色块,也是唯一一个非白键。 */
+.pad__key--pay {
+  grid-column: 4;
+  grid-row: 2 / span 3;
+  font-size: 21px;
+  color: #fff;
+  background: var(--brand);
+
+  /* 禁用态不用 opacity:蓝底压到 50% 会变成一块脏灰蓝。参照页的做法是换成同一个色的
+     浅版(它那个绿按钮禁用时就是浅绿)。 */
+  &:disabled {
+    background: #a8c4ff;
+    opacity: 1;
+  }
+
+  &:active:not(:disabled) {
+    background: #3572e6;
+  }
+}
+
+.pad__icon {
+  width: 26px;
+  height: 26px;
+  color: #333;
+}
+
+/* ── 宽屏:空白交给键盘吃掉 ──
+   这一屏在桌面上没有「拇指够不够得着」这回事,中间那块空白唯一的用处就是「别太挤」;
+   可不管它的话它会跟着显示器一起长 —— 柱子收到 480 之后,1440×900 实测空出 428px,
+   整页像一条被拉长的收据。让它长在键盘上:键一大,这块空自然就短了。
+
+   行高取 clamp 而不是定值。定 112 的话,1440×700 这种半屏窗口上内容会到 675 而窗口
+   只有 700 —— 再矮一点就平白长出滚动条;12vh 让它跟着窗口缩。下限 66 就是手机那一档
+   (窗口再矮也不小于手机),上限 112 = 键宽(键 112 宽时正好见方,再高就拉长了)。
+   键上的字要一起放大,否则一格 112 里蹲着个 24px 的数字,看着发虚。
+
+   ★ 这一段只挂在 ≥600px。390×844 下逐格不变:键仍 89×66、scrollHeight 仍 844
+   (实测,改前改后同一批读数)。 */
+@media (min-width: 600px) {
+  .checkout {
+    --key-row: clamp(66px, 12vh, 112px);
+  }
+
+  .pad__key {
+    font-size: 30px;
+  }
+
+  .pad__key--pay {
+    font-size: 26px;
+  }
+
+  .pad__icon {
+    width: 34px;
+    height: 34px;
+  }
+}
+
+/* ── 跳转中:盖满整屏 ── */
+.redirect {
+  position: fixed;
+  inset: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 0 32px;
+  text-align: center;
+  background: #fff;
+}
+
+.redirect__spinner {
+  width: 22px;
+  height: 22px;
+  margin-bottom: 12px;
+  border: 2px solid #d8e4ff;
+  border-top-color: var(--brand);
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.redirect__text {
+  margin: 0;
+  font-size: 14px;
+  color: var(--ink);
+}
+
+.redirect__no {
+  margin: 8px 0 0;
+  font-size: 12px;
+  color: var(--ink-soft);
+  word-break: break-all;
+}
+
+/* ── 链接不可用:一句话 + 一条可照做的建议。没有别的可点 ── */
+.notice {
+  margin: 40px 24px 0;
+  text-align: center;
+}
+
+.notice__title {
+  margin: 0 0 10px;
+  font-size: 17px;
+  font-weight: 600;
+}
+
+.notice__hint {
+  margin: 0;
+  font-size: 13px;
+  line-height: 1.7;
+  color: var(--ink-soft);
+}
+
+/* ── 载入占位:形状照着真内容量(名字 / 标签 / 金额 / 键盘块)。
+     占位与真内容差得远的话,回复一到整页会跳一下。 ── */
+.skeleton {
+  background: #eef0f2;
+  border-radius: 3px;
+  animation: shimmer 1.6s ease-in-out infinite;
+}
+
+.skeleton--name {
+  width: 52%;
+  height: 17px;
+}
+
+.skeleton--label {
+  width: 12%;
+  height: 15px;
+  margin-top: 18px;
+}
+
+.skeleton--amount {
+  width: 42%;
+  height: 46px;
+  margin-top: 10px;
+}
+
+/* 键盘那块占位压在屏幕下沿(margin-top:auto)。高度对齐的是**键区**、不是整个键盘块:
+   4 行 + 3 个 6px 缝 = 4×行高 + 18,正好是真键盘第一排键的上沿到最后一排键的下沿。
+   键盘块自己的上下各 8px 是贴着页面底色的留白,占位不该连它一起画 —— 那样会差 16px。
+   用 `var(--key-row)` 而不是写死:宽屏下行高会变,写死的话骨架与真键区就对不上了。 */
+.pad-skeleton {
+  height: calc(var(--key-row) * 4 + 18px);
+  margin: auto 8px calc(8px + env(safe-area-inset-bottom));
+  background: #e9ebee;
+  border-radius: 5px;
+  animation: shimmer 1.6s ease-in-out infinite;
+}
+
+@keyframes shimmer {
+  50% {
+    opacity: 0.5;
+  }
+}
+
+/* 校验没过:金额行左右抖一下。用动画而不是 JS 逐帧 —— 它在 reduced-motion 下被一并
+   关掉,而信息不丢:那时金额变红、提示那句照样写在键盘上方。 */
+@keyframes shake {
+  20% {
+    transform: translateX(-5px);
+  }
+
+  45% {
+    transform: translateX(4px);
+  }
+
+  70% {
+    transform: translateX(-3px);
+  }
+
+  90% {
+    transform: translateX(2px);
+  }
+}
+
+.amount--shake {
+  animation: shake 0.36s cubic-bezier(0.36, 0.07, 0.19, 0.97);
+}
+
+/* ★ 减少动效:只关掉动的东西,元素停在**静态形态**(占位是满不透明的),不是消失
+   —— 这一点靠的是动画只写在关键帧里,而不是另写一套覆盖规则。 */
+@media (prefers-reduced-motion: reduce) {
+  .amount--shake,
+  .skeleton,
+  .pad-skeleton,
+  .redirect__spinner {
+    animation: none;
+  }
+}
+</style>

+ 165 - 3
frontend/src/views/module_payment/har/merchant/index.vue

@@ -115,8 +115,13 @@
             {{ row.updated_time ? dayjs(row.updated_time).format("YYYY-MM-DD HH:mm:ss") : "-" }}
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="150" fixed="right">
+        <!-- ★ 「收款二维码」对**两种身份都显示**:后端那个端点挂的是权限串(复用本页 list),
+             而且两道数据闸只允许取到自己名下的那一行 —— 租户给自己取链接正是它的主用途。
+             这与旁边两个按钮(只认超管身份)是两种门禁,别照着其中一个去改另一个,
+             见后端 `HarMerchantController` 的类 javadoc。 -->
+        <el-table-column label="操作" width="220" fixed="right">
           <template #default="{ row }">
+            <el-button size="small" @click="openLink(row)">收款二维码</el-button>
             <el-button v-if="isSuperAdmin" size="small" type="primary" @click="openEdit(row)">
               编辑
             </el-button>
@@ -206,14 +211,51 @@
         <el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
       </template>
     </el-dialog>
+
+    <!-- 收款链接(复制 / 二维码 / 下载)—— 一切以「拿走直接用」为准:
+         操作员拿到它是要去打印桌牌、贴到收银台、发给顾客的,所以三件都要在场。 -->
+    <el-dialog v-model="linkVisible" title="收款链接" width="620px">
+      <el-alert
+        type="success"
+        :closable="false"
+        style="margin-bottom: 16px"
+        title="这条链接长期有效、可以无限次使用。把它打印成桌牌、贴到收银台,或直接用微信/支付宝扫码——顾客打开后自己输入金额即可付款。"
+      />
+      <el-form label-width="90px">
+        <el-form-item label="商户">
+          <span>{{ link.merchant_code }}</span>
+          <span v-if="link.merchant_name" class="link-name">({{ link.merchant_name }})</span>
+        </el-form-item>
+        <el-form-item label="链接">
+          <div class="link-row">
+            <el-input :model-value="link.url" readonly />
+            <div class="qrcode-wrapper">
+              <!-- ★ 尺寸给到 240:这一块是要**被扫**的,160 在小屏手机上难扫;
+                   下载出来的 PNG 与屏幕上这块是同一个函数画的,不会两副样子。 -->
+              <canvas ref="qrcodeCanvas" class="qrcode-canvas"></canvas>
+            </div>
+          </div>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button :disabled="!link.url" @click="handleCopyLink">复制链接</el-button>
+        <el-button :disabled="!link.url" @click="handleDownloadQr">下载二维码</el-button>
+        <el-button type="primary" @click="linkVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, reactive, ref } from "vue";
+import { computed, nextTick, onMounted, reactive, ref } from "vue";
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
 import dayjs from "dayjs";
-import HarAPI, { type HarMerchantSaveParams, type HarMerchantVO } from "@/api/module_payment/har";
+import QRCode from "qrcode";
+import HarAPI, {
+  type HarCheckoutLinkVO,
+  type HarMerchantSaveParams,
+  type HarMerchantVO,
+} from "@/api/module_payment/har";
 import TenantAPI, { type TenantTable } from "@/api/module_system/tenant";
 import { useEnterpriseStore, useUserStore } from "@/store";
 
@@ -371,6 +413,92 @@ async function handleDelete(row: HarMerchantVO) {
   load();
 }
 
+// ────────────────────────── 收款链接 ──────────────────────────
+
+/**
+ * 链接弹窗的三格。**只认这三格**,与后端 `HarCheckoutLinkVO` 逐字段对应
+ * (其余一概不取:租户号、主键、token 都不回给页面 —— 见那个 VO 的 javadoc)。
+ *
+ * ★ `merchant_name` 是**链接行上的快照**,不是表格里那一列:商户改过名但还没重新取过链接时
+ *   两者会不同,而操作员看到的名字必须与顾客打开链接时看到的一样。
+ * ★ `url` 整条来自后端 `buildUrl`。**不要**在这里用 `merchant_code` 或域名自己拼:
+ *   基址推导只有那一份,拼第二个副本的后果是「复制的链接」与「二维码里的链接」不是同一条。
+ */
+const link = ref<Partial<HarCheckoutLinkVO>>({});
+const linkVisible = ref(false);
+const qrcodeCanvas = ref<HTMLCanvasElement>();
+
+/**
+ * 打开链接弹窗:**先取数据、拿到之后再开窗**。
+ *
+ * ★ 顺序是有意的:这个端点是 POST 且会**写一行**(首次调用建链接),
+ *   先开窗会让操作员盯着一个空框等一次网络往返,失败时更是一个空弹窗配一句 toast。
+ *   取不到就压根不弹 —— 列表页上的 toast 已经把原因说清楚了。
+ */
+async function openLink(row: HarMerchantVO) {
+  link.value = {};
+  try {
+    const res = await HarAPI.checkoutLink(row.id as string);
+    // 两层 data:响应拦截器 `return response`,**不解包**(同 load 的既有形状)。
+    link.value = res.data.data || {};
+  } catch {
+    // 拦截器已经用服务端的原话弹过 toast(404 商户不存在 / 403 无归属租户)。
+    // 这里什么都不做:留在列表页,操作员可以直接再点一次。
+    return;
+  }
+  linkVisible.value = true;
+  await nextTick();
+  await drawQr();
+}
+
+/** 画二维码。★ 传**整条 url**(不是 token)—— 顾客扫出来的必须就是那条链接。 */
+async function drawQr() {
+  if (!qrcodeCanvas.value || !link.value.url) {
+    return;
+  }
+  try {
+    await QRCode.toCanvas(qrcodeCanvas.value, link.value.url, { width: 240, margin: 1 });
+  } catch {
+    // 画不出来 = 这一屏少了「扫码」这条腿,必须说出来,不能只留在控制台。
+    // 链接本身还在输入框里,复制那条腿不受影响。
+    ElMessage.error("二维码生成失败,请改用「复制链接」");
+  }
+}
+
+async function handleCopyLink() {
+  try {
+    await navigator.clipboard.writeText(link.value.url || "");
+    ElMessage.success("收款链接已复制");
+  } catch {
+    // 非 https / 无剪贴板权限时 `navigator.clipboard` 不可用(或写入被拒),
+    // 输入框本身是可全选的,所以这里给一句能照做的提示。
+    ElMessage.warning("复制失败,请手动选中链接复制");
+  }
+}
+
+/**
+ * 下载二维码 PNG。
+ *
+ * ★ 从**同一份数据**(`link.value.url`)重画一张,而不是把屏幕上那块 canvas 截图:
+ *   屏幕上的 canvas 受 `devicePixelRatio` 与 CSS 尺寸影响,截出来的图在打印场景里
+ *   会忽大忽小;`toDataURL` 给的是一张尺寸确定的位图,桌牌打印要的正是这个。
+ */
+async function handleDownloadQr() {
+  if (!link.value.url) {
+    return;
+  }
+  try {
+    const dataUrl = await QRCode.toDataURL(link.value.url, { width: 600, margin: 2 });
+    const a = document.createElement("a");
+    a.href = dataUrl;
+    // 文件名带上商户编号:一次导几张时不会互相覆盖,也不会分不清哪张是哪家。
+    a.download = `收款码_${link.value.merchant_code || "商户"}.png`;
+    a.click();
+  } catch {
+    ElMessage.error("二维码下载失败,请改用「复制链接」");
+  }
+}
+
 onMounted(async () => {
   load();
   try {
@@ -402,4 +530,38 @@ onMounted(async () => {
   justify-content: space-between;
   align-items: center;
 }
+
+/* 链接 + 二维码并排:输入框可伸缩,二维码固定 240(见模板里那段尺寸说明) */
+.link-row {
+  display: flex;
+  gap: 16px;
+  align-items: flex-start;
+  width: 100%;
+}
+
+.link-row :deep(.el-input) {
+  flex: 1;
+  min-width: 0;
+}
+
+.qrcode-wrapper {
+  flex: none;
+  padding: 4px;
+  background: #fff;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+/* ★ 显式与 `drawQr` 里那个 `width: 240` 同值:canvas 的**显示尺寸**由这两行决定,
+   而它的位图尺寸由那个参数决定。两者不一致时二维码看着正常、扫起来却可能因缩放发虚。 */
+.qrcode-canvas {
+  display: block;
+  width: 240px;
+  height: 240px;
+}
+
+.link-name {
+  margin-left: 4px;
+  color: var(--el-text-color-secondary);
+}
 </style>