Bläddra i källkod

feat(乐收赢): 商户页操作列加「收款链接」按钮 —— 链接 + 二维码,复制与下载都能直接用

用户 2026-09-30:「checkout链接获取的按钮可以做到系统页面上,顺便封装成二维码,
链接和二维码都可以拿走直接用」。

按钮点开一个弹窗,里面四样东西:链接文本、二维码(`qrcode`,依赖里本来就有)、
「复制链接」、「下载二维码」。下载是 600px 的 PNG,文件名 `收款码_<商户号>.png`。

两处刻意的做法:
- **先取数据、后开窗**:`openLink` 拿到 `{url}` 才 `linkVisible = true`,中途失败弹的是一次
  error toast 而不是一个空弹窗 —— 弹窗里留一个空白框,操作员只会当成「链接就是没生成」。
- **页面不自己拼 URL**:链接、二维码、复制三处用的都是后端返回的 `url` 那一格。
  基址推导(配置优先,否则按请求 Host)在服务端只有 `buildUrl` 一份;前端再拼一份的后果是
  「页面上复制的」与「二维码里的」不是同一条 —— 而这两条正是要印出去用的那两条。

`id` 按**字符串**往下传(雪花 id 超 JS 安全整数,同 `deleteLsyMerchant` 的说明)。
下载路径走的是 `QRCode.toDataURL` + 造一个 `a` 元素 click,不经 `window.open`
—— 本机 Chromium 关了弹窗拦截,这条路的读数不可信(见记忆 playwright-popup-blocker-blind-spot)。

验证台 65 条断言里覆盖了:复制的文本 == 二维码编码的 == 后端那条、下载的 href/文件名/
click 次数、成功下载不弹 toast、屏幕 canvas 位图宽 == CSS 宽(240)。
变异 M5(二维码只编码 token 而非整条 url)实测红 1 条。

权限串复用商户 `list`,**不需要新的菜单 SQL**;租户也只能取到自己名下那一行。
alphaH 2 veckor sedan
förälder
incheckning
e6035c06b5

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

@@ -126,6 +126,29 @@ export function deleteLsyMerchant(id: string) {
   return request({ url: `/payment/collection/merchant/${id}`, method: "delete" });
 }
 
+/**
+ * 取(必要时创建)该商户的**收款链接** —— 商户页操作列那个「收款链接」按钮。
+ *
+ * ★ 是 **POST** 不是 GET:首次调用会**写一行**(`pay_lsy_checkout_link`)。这是刻意的 ——
+ *   链接要印出来/贴出去,每取一次换一个 token 等于让客户手上那张作废,
+ *   所以同一个商户反复调用返回**同一条**(服务层的幂等,见 `LsyCheckoutLinkService#issue`)。
+ *   前端**不要**自作主张缓存结果或改成 GET。
+ *
+ * ★ `id` 是**字符串** —— 同 `deleteLsyMerchant` 的说明(雪花 id 超 JS 安全整数)。
+ *
+ * ★ 响应 `data` 是 `{ mch_no, merchant_name, url }`,三格都是 snake_case:
+ *   · `merchant_name` 是**链接行上的快照**,不是商户行那一列 —— 两者会在「商户改名后
+ *     还没重新取过链接」时不同,而操作员看到的名字必须与顾客打开链接时看到的一样;
+ *   · 页面**不要**自己拿 `mch_no` / 域名去拼 URL:基址推导只有 `buildUrl` 那一份,
+ *     拼第二个副本的后果是「页面上复制的链接」与「二维码里的链接」不是同一条。
+ *
+ * ★ 权限串复用商户 `list`(不新增权限点 ⇒ **不需要新的菜单 SQL**),
+ *   所以能看见这一页的角色都点得动;租户也只能取到**自己名下**那一行(后端两道闸)。
+ */
+export function getLsyCheckoutLink(id: string) {
+  return request({ url: `/payment/collection/merchant/${id}/checkout-link`, method: "post" });
+}
+
 // ==================== 扫码收款(收银台) ====================
 
 /**

+ 162 - 5
frontend/src/views/module_payment/collection/merchant/index.vue

@@ -53,10 +53,13 @@
           </template>
         </el-table-column>
         <el-table-column prop="created_time" label="创建时间" width="180" />
-        <el-table-column label="操作" width="140" fixed="right">
+        <!-- ★ 「收款链接」对**两种身份都显示**:后端那个端点挂的是权限串(复用本页 list),
+             而且两道数据闸只允许取到自己名下的那一行 —— 租户给自己取链接正是它的主用途。
+             这与旁边两个按钮(只认超管身份)是两种门禁,别照着其中一个去改另一个,
+             见后端 `LeshouyingMerchantController` 的类 javadoc。 -->
+        <el-table-column label="操作" width="210" fixed="right">
           <template #default="{ row }">
-            <!-- 两个按钮都挂 isSuperAdmin:后端 save/delete 已经只认超管身份,
-                 非超管点下去必 403 —— 显示一个点了必然失败的入口比不显示更糟。 -->
+            <el-button link type="primary" @click="openLink(row)">收款链接</el-button>
             <el-button v-if="isSuperAdmin" link type="primary" @click="openForm(row)">
               编辑
             </el-button>
@@ -126,6 +129,38 @@
         <el-button type="primary" @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.mch_no }}</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>
 
@@ -140,12 +175,14 @@ defineOptions({
   inheritAttrs: false,
 });
 
-import { computed, onMounted, reactive, ref } from "vue";
-import { ElMessageBox, type FormRules } from "element-plus";
+import { computed, nextTick, onMounted, reactive, ref } from "vue";
+import { ElMessage, ElMessageBox, type FormRules } from "element-plus";
+import QRCode from "qrcode";
 import {
   getLsyMerchantPage,
   saveLsyMerchant,
   deleteLsyMerchant,
+  getLsyCheckoutLink,
 } from "@/api/module_payment/collection";
 import TenantAPI, { type TenantTable } from "@/api/module_system/tenant";
 import { useUserStore } from "@/store";
@@ -318,6 +355,92 @@ async function handleSubmit() {
   loadData();
 }
 
+// ────────────────────────── 收款链接 ──────────────────────────
+
+/**
+ * 链接弹窗的三格。**只认这三格**,与后端 `LsyCheckoutLinkVO` 逐字段对应
+ * (其余一概不取:租户号、门店号、主键、token 都不回给页面 —— 见那个 VO 的 javadoc)。
+ *
+ * ★ `merchant_name` 是**链接行上的快照**,不是表格里那一列:商户改过名但还没重新取过链接时
+ *   两者会不同,而操作员看到的名字必须与顾客打开链接时看到的一样。
+ * ★ `url` 整条来自后端 `buildUrl`。**不要**在这里用 `mch_no` 或域名自己拼:
+ *   基址推导只有那一份,拼第二个副本的后果是「复制的链接」与「二维码里的链接」不是同一条。
+ */
+const link = ref<{ mch_no?: string; merchant_name?: string | null; url?: string }>({});
+const linkVisible = ref(false);
+const qrcodeCanvas = ref<HTMLCanvasElement>();
+
+/**
+ * 打开链接弹窗:**先取数据、拿到之后再开窗**。
+ *
+ * ★ 顺序是有意的:这个端点是 POST 且会**写一行**(首次调用建链接),
+ *   先开窗会让操作员盯着一个空框等一次网络往返,失败时更是一个空弹窗配一句 toast。
+ *   取不到就压根不弹 —— 列表页上的 toast 已经把原因说清楚了。
+ */
+async function openLink(row: any) {
+  link.value = {};
+  try {
+    const res: any = await getLsyCheckoutLink(row.id);
+    // 两层 data:响应拦截器 `return response`,**不解包**(同 loadData 的实测)。
+    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.mch_no || "商户"}.png`;
+    a.click();
+  } catch {
+    ElMessage.error("二维码下载失败,请改用「复制链接」");
+  }
+}
+
 async function handleDelete(row: any) {
   // ★ 2026-09-30 去企业化:这里原先是 `row.enterprise_id ? \`企业「…」\` : "本租户"`。
   //   企业那一维度没了,措辞改成被删的那一行自己的两项身份(商户号 + 归属租户),
@@ -366,4 +489,38 @@ onMounted(async () => {
   color: var(--el-text-color-placeholder);
   font-size: 12px;
 }
+
+/* 链接 + 二维码并排:输入框可伸缩,二维码固定 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>