Переглянути джерело

feat(收款): 扫码收款页 —— 一个金额框、一张码、变绿就完事

views/module_payment/collection/cashier/index.vue,三屏:
输入(金额 + 选填备注)→ 等待(大字二维码 + 倒计时 + 撤销)→ 结果。

傻瓜式的具体做法:

- **没有单号要填、没有商户要选、没有场景要选**。屏幕上只有一个输入和一个可选项。
- 进页面光标已经在金额框里,回车即收款;出结果后光标自动回到金额框。
- 顾客付完**自动变绿**,操作员不用点任何「查询」。
- 金额在二维码**旁边**用 34px 大字显示 —— 这是操作员核对「有没有多打一个 0」
  的那一眼。★ 如实说:这个做法**不拦**手滑,只是让它在付款**之前**可见;
  本页**没有**二次确认弹窗,也**没有**金额上限校验(范围外,见计划「明确不做」)。

几处不显眼但会咬人的地方:

- `defineOptions({ name: "CollectionCashier" })` —— 名字必须与菜单 route_name 逐字相同:
  keep-alive 的 include 匹配的是**组件名**,tags-view 推的是**路由名**,对不上时
  vue 不报错、只是永不缓存。这个名字是**查活库查出来的**(活库命名空间是 Collection*),
  不是照邻页推的 —— 邻页源码里写的 Leshouying* 恰恰与活库对不上(那是既有缺陷,未顺手改)。
- 轮询跨 `await` 之后要重新确认「还是不是同一单」:操作员可能在应答回来之前按了
  「再收一笔」。少了这一步,上一单的状态会被铺到屏幕上,页面莫名从输入屏跳到结果屏,
  而且显示的是**上一笔**的金额。
- 失败时把间隔从 3 秒逐步拉长到 15 秒:服务端已经降级成「按本地状态返回」,
  但**不能**保持 3 秒一次 —— 拦截器每次失败都弹 toast,断网会变成刷屏。
- 撤销失败**不清屏**:这一单还在等钱。改为立刻再问一次状态 —— 若顾客刚好付了,
  屏幕应当马上变绿,而不是停在二维码上让操作员以为还能撤。
- 离开页面必须停表(onBeforeUnmount),否则 keep-alive 下的旧定时器会一直问下去,
  而且问的是上一个操作员那一单。

api:collection.ts 只加这一组(60 行,符号实测只有 createCashierQr / getCashierOrder /
closeCashierOrder / CashierOrderVo)。响应拦截器**不解包**,页面按 res.data.data 取。

读数:
- `pnpm build` rc=0(✓ built in 1m 11s)。
- `pnpm ts:check` 前后**逐行相同**(287 行,排序集 sha 00a886b38047e0e7;
  时序 before 18:27:45 < 编辑 < after 18:33:20)⇒ 零新增类型错误。
  本仓该命令有历史遗留基线、**恒 exit 2**,所以只能说「零新增」,不能说「跑绿了」。
  量具敏感度有现成正控:注入一处错误时同一支脚本读到 288 行、sha 不同。
- 产物核对:本页 6 条独有中文串**全部**落在同一个 chunk(js/index.Cs2OUF6W.js);
  同谓词正控 2 条命中、反对照 0 命中。dist 最新 mtime = 2026-09-23 18:29:18。
alphaH 3 тижнів тому
батько
коміт
123891c01d

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

@@ -81,3 +81,63 @@ export function saveLsyMerchant(data: Record<string, unknown>) {
 export function deleteLsyMerchant(id: string) {
   return request({ url: `/payment/collection/merchant/${id}`, method: "delete" });
 }
+
+// ==================== 扫码收款(收银台) ====================
+
+/**
+ * 收银台那一单的对外形状 —— 与后端 `LsyCashierOrderVO` **逐字段对应**。
+ *
+ * ★ 键名一律 snake_case(见文件头 ★ 2)。唯一一个单词字段是 `status`,两种写法看不出来,
+ *   所以它是这一类拼写错误里**唯一查不出**的一个:写完照后端 VO 再核一遍。
+ * ★ `out_trade_no` 是**字符串**(服务端生成,形如 `SK` + 19 位数字)—— 不是 number。
+ *   它虽然没进 `@JsonSerialize(ToStringSerializer)`(本 VO 没给 id),但**声明成 string 是有意的**:
+ *   哪天这个 VO 补上 `id`,number 声明会在 `JSON.parse` 那一刻丢精度。
+ */
+export interface CashierOrderVo {
+  /** 本单的号。收银员不需要理解它,但页面靠它继续轮询 */
+  out_trade_no: string;
+  /**
+   * `CREATED` / `PAYING` = 还没付;`SUCCESS` = 收款成功;
+   * `CANCELED` / `CLOSED` / `EXPIRED` = 已撤销 / 已关闭 / 已过期;
+   * `FAIL` = 未成功;`PARTIAL_REFUNDED` / `REFUNDED` = 有退款。
+   *
+   * ★ 判断「还要不要继续轮询」用的是**集合**(`CREATED`/`PAYING`),不是 `!== "SUCCESS"`:
+   *   后者会让一笔 `FAIL` 的单被永远轮询下去。
+   */
+  status: string;
+  /** 应收(分) */
+  total_amount: number | null;
+  /** 实收(分)。顾客付款前上游不给这个数 ⇒ 此刻为 null,不显示 0.00 */
+  receipt_amount: number | null;
+  /** 二维码内容;下单没成功时为空 */
+  code_url: string | null;
+  /** 二维码剩余秒数;无过期时间时为 null(页面据此不显示倒计时) */
+  expire_seconds: number | null;
+  message: string | null;
+}
+
+/**
+ * 发起一笔收款。
+ *
+ * ★ `amount` 的单位是**分**,不是元;页面里 `Math.round(元 * 100)` 之后再传。
+ * ★ 单号、商户号、交易时间全部由服务端生成 —— 这个报文里只有金额和备注两个键。
+ */
+export function createCashierQr(data: { amount: number; remark?: string }) {
+  return request({ url: "/payment/collection/cashier/qrcode", method: "post", data });
+}
+
+/** 查这一单当前的状态。页面每 3 秒调一次;服务端自己会节流并主动向上游查一次。 */
+export function getCashierOrder(outTradeNo: string) {
+  return request({ url: `/payment/collection/cashier/${outTradeNo}`, method: "get" });
+}
+
+/**
+ * 撤销(顾客没付、这一单不要了)。
+ *
+ * ★ 失败**不是**异常出口:后端按 `Result.of(500, "撤销未成功,请稍后重试")` 返回,
+ *   经响应拦截器变成一次 error toast + reject ⇒ 调用方 `catch` 里**不要清屏**,
+ *   让操作员还看得见当前这一单。
+ */
+export function closeCashierOrder(outTradeNo: string) {
+  return request({ url: `/payment/collection/cashier/${outTradeNo}/close`, method: "post" });
+}

+ 451 - 0
frontend/src/views/module_payment/collection/cashier/index.vue

@@ -0,0 +1,451 @@
+<template>
+  <div class="app-container cashier">
+    <!-- ① 还没开始:只有一个金额框 + 一个按钮 -->
+    <el-card v-if="phase === 'input'" shadow="never" class="pane">
+      <div class="ask">收多少钱?</div>
+
+      <div class="amount-line">
+        <span class="unit">¥</span>
+        <el-input
+          ref="amountEl"
+          v-model="amountText"
+          class="amount-box"
+          size="large"
+          placeholder="0.00"
+          @keyup.enter="start"
+        />
+        <span class="unit">元</span>
+      </div>
+
+      <el-input
+        v-model="remark"
+        class="remark-box"
+        size="large"
+        maxlength="128"
+        show-word-limit
+        placeholder="备注(选填)—— 顾客账单上会显示这行字"
+        @keyup.enter="start"
+      />
+
+      <el-button type="primary" size="large" class="go" :loading="creating" @click="start">
+        收 款
+      </el-button>
+      <div v-if="inputHint" class="warn">{{ inputHint }}</div>
+    </el-card>
+
+    <!-- ② 等顾客扫码 -->
+    <el-card v-else-if="phase === 'waiting'" shadow="never" class="pane">
+      <div class="wait-title">请让顾客扫码支付</div>
+      <!-- 金额在二维码**旁边**、大字显示:这是操作员核对「有没有多打一个 0」的那一眼 -->
+      <div class="wait-amount">¥ {{ fen2yuan(order?.total_amount) }}</div>
+
+      <div class="qr-wrap" :class="{ dim: expired }">
+        <canvas ref="qrEl" />
+      </div>
+
+      <div v-if="expired" class="warn">
+        二维码已过期 —— 请撤销后重新发起;若顾客刚扫过,下面会自动变绿
+      </div>
+      <div v-else-if="remaining != null" class="countdown">二维码剩余 {{ mmss(remaining) }}</div>
+      <div v-else class="countdown">正在等待顾客付款…</div>
+
+      <el-button size="large" :loading="closing" @click="doClose">撤 销</el-button>
+      <div class="hint">顾客没付、这笔不要了,才点撤销</div>
+    </el-card>
+
+    <!-- ③ 出结果了 -->
+    <el-card v-else shadow="never" class="pane">
+      <div class="result" :class="result.tone">
+        <div class="result-icon">{{ result.icon }}</div>
+        <div class="result-title">{{ result.title }}</div>
+        <div v-if="result.money" class="result-money">¥ {{ result.money }}</div>
+        <div v-if="result.detail" class="result-detail">{{ result.detail }}</div>
+      </div>
+      <el-button type="primary" size="large" class="go" @click="reset">再收一笔</el-button>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+// ★ 组件名必须与菜单 route_name **逐字相同**:keep-alive 的 `include` 匹配的是**组件名**,
+//   而 tags-view 推进 `cachedViews` 的是**路由名**。不写这一行,`<script setup>` 会按
+//   文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
+//
+// ★ 取值 `CollectionCashier` 是**查活库查出来的**,不是照邻页推的(2026-09-23,只读查询):
+//   收付款目录(id=268)下四个子行的 route_name 依次是
+//   `CollectionOrder` / `CollectionRefund` / `CollectionMerchant` / `CollectionOnboarding`
+//   —— 活库的命名空间是 `Collection*`。
+//   ★ 但 order / refund / merchant **三个页面的源码里写的是 `Leshouying*`** ⇒ 那三页的
+//     keep_alive 今天静默失效(名称对不上,vue 不报错,只是不缓存)。那是**既有缺陷**,
+//     不在本批次范围内,没有顺手改(用户令:不相关的不动)。此处只保证本页自洽。
+defineOptions({
+  name: "CollectionCashier",
+  inheritAttrs: false,
+});
+
+import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
+// ElInput 是为了 `InstanceType<typeof ElInput>` —— 模板里的 `<el-input>` 由自动导入解析,
+// 但脚本里取组件实例类型必须真 import(照 layouts/.../LockPage.vue:130 的写法)。
+import { ElInput, ElMessage } from "element-plus";
+import QRCode from "qrcode";
+import {
+  closeCashierOrder,
+  createCashierQr,
+  getCashierOrder,
+  type CashierOrderVo,
+} from "@/api/module_payment/collection";
+
+/**
+ * 扫码收款 —— 一个金额框、一个按钮、一张码。
+ *
+ * 【为什么这一页是这个形状】
+ * 使用者是柜台上的操作员,不是开发。他面前站着顾客,流程越长越容易出错。
+ * 所以:**没有单号要填、没有商户要选、没有场景要选** —— 单号与商户全部由服务端决定,
+ * 屏幕上从头到尾只有一个输入(金额)和一个可选项(备注)。
+ *
+ * 【★ 这一页不监听任何推送】
+ * 「顾客付完变绿」靠的是每 3 秒问一次后端(见 `createCashierQr` 那组接口的说明)。
+ * 那一跳的失败在服务端被降级成「按本地状态返回」,所以这里**不会**因为一次网络抖动
+ * 就把二维码从屏幕上抹掉。
+ */
+
+/** 还没收到钱的状态。判断「要不要继续问」只认这个集合 —— 不认 `!== "SUCCESS"`(那会让 FAIL 被永远轮询下去) */
+const PENDING = new Set(["CREATED", "PAYING"]);
+
+/** 轮询间隔,与后端节流那颗常量同值(3 秒 = 「顾客付完最迟 3 秒变绿」这条承诺的上界) */
+const POLL_MS = 3000;
+/** 连不上时把间隔拉长到多少为止 —— 否则一次断网会每 3 秒弹一次 toast */
+const POLL_MS_MAX = 15000;
+
+type Phase = "input" | "waiting" | "done";
+
+const phase = ref<Phase>("input");
+const amountText = ref("");
+const remark = ref("");
+const creating = ref(false);
+const closing = ref(false);
+const inputHint = ref("");
+const order = ref<CashierOrderVo | null>(null);
+const remaining = ref<number | null>(null);
+// 组件实例类型照本仓既有写法取(LockPage.vue:130),不自己手搓 `{ focus?: () => void }`
+// —— 后者在 vue-tsc 的模板 ref 检查下会被判成与 ElInput 实例不兼容。
+const amountEl = ref<InstanceType<typeof ElInput>>();
+const qrEl = ref<HTMLCanvasElement>();
+
+let pollTimer: number | undefined;
+let tickTimer: number | undefined;
+/** 二维码到点的绝对时刻。倒计时从它算,不从「上一秒减一」算 —— 后者会越走越慢 */
+let deadline = 0;
+let failStreak = 0;
+
+const expired = computed(() => remaining.value != null && remaining.value <= 0);
+
+function fen2yuan(fen?: number | null) {
+  return fen == null ? "-" : (fen / 100).toFixed(2);
+}
+
+function mmss(sec: number) {
+  const m = Math.floor(sec / 60);
+  return `${m}:${String(sec % 60).padStart(2, "0")}`;
+}
+
+/** 终态 → 屏幕上那几行字。**表外的状态不猜**(见下面的 computed)。 */
+const RESULT: Record<string, { title: string; tone: string; icon: string; detail?: string }> = {
+  SUCCESS: { title: "收款成功", tone: "ok", icon: "✓" },
+  FAIL: { title: "未收到款", tone: "bad", icon: "✕", detail: "这一笔没有收到钱,可以重新发起" },
+  CANCELED: { title: "已撤销", tone: "muted", icon: "—" },
+  CLOSED: { title: "已关闭", tone: "muted", icon: "—" },
+  EXPIRED: { title: "已过期", tone: "muted", icon: "—", detail: "二维码超时未付款,这一笔已自动结束" },
+  PARTIAL_REFUNDED: { title: "已部分退款", tone: "muted", icon: "—" },
+  REFUNDED: { title: "已全额退款", tone: "muted", icon: "—" },
+};
+
+const result = computed(() => {
+  const st = order.value?.status ?? "";
+  // 成功时上游会带回**实收**(`receiptAmount`,扣掉优惠之后的数);它为空才退回应收。
+  const money = st === "SUCCESS"
+    ? fen2yuan(order.value?.receipt_amount ?? order.value?.total_amount)
+    : null;
+  const known = RESULT[st];
+  if (known) return { ...known, money };
+  // 表外的状态:如实把码给出来,不编一句人话 —— 编错了比不认识更糟。
+  return { title: st || "已结束", tone: "muted", icon: "—", money, detail: null };
+});
+
+/** 把服务端那一次应答铺到屏幕上。**唯一的入口** —— 轮询、撤销、下单三处都走它。 */
+function apply(vo: CashierOrderVo | null | undefined) {
+  if (!vo) return;
+  order.value = vo;
+  const secs = vo.expire_seconds;
+  remaining.value = secs == null ? null : secs;
+  deadline = secs == null ? 0 : Date.now() + secs * 1000;
+  if (!PENDING.has(vo.status)) {
+    stopTimers();
+    phase.value = "done";
+  }
+}
+
+async function drawQr() {
+  const url = order.value?.code_url;
+  if (!qrEl.value || !url) return;
+  try {
+    await QRCode.toCanvas(qrEl.value, url, { width: 260, margin: 2 });
+  } catch {
+    // 画不出来 = 顾客扫不了。这件事必须说出来,不能只留在控制台。
+    ElMessage.error("二维码显示失败,请撤销这一笔后重试");
+  }
+}
+
+function schedulePoll(ms: number) {
+  window.clearTimeout(pollTimer);
+  pollTimer = window.setTimeout(poll, ms);
+}
+
+function startTicking() {
+  window.clearInterval(tickTimer);
+  tickTimer = window.setInterval(() => {
+    if (!deadline) return;
+    remaining.value = Math.max(Math.ceil((deadline - Date.now()) / 1000), 0);
+  }, 1000);
+}
+
+function stopTimers() {
+  window.clearTimeout(pollTimer);
+  window.clearInterval(tickTimer);
+  pollTimer = undefined;
+  tickTimer = undefined;
+}
+
+async function start() {
+  inputHint.value = "";
+  // 单位是**分**:先用元收输入,取整到分再发(`Math.round` 而不是 `*100` 直接截断,
+  // 否则 19.99 会算成 1998)。
+  const fen = Math.round(Number(amountText.value) * 100);
+  if (!Number.isFinite(fen) || fen <= 0) {
+    inputHint.value = "请先填一个大于 0 的金额";
+    amountEl.value?.focus();
+    return;
+  }
+
+  creating.value = true;
+  try {
+    const res: any = await createCashierQr({
+      amount: fen,
+      remark: remark.value.trim() || undefined,
+    });
+    // ★ 两层 data:响应拦截器 `return response`,**不解包**(同 order/index.vue 的实测)。
+    //   写成 res.data 会把整个 {code,msg,data} 信封当成订单 —— 页面不报错,只是永远空白。
+    apply(res.data.data);
+    if (phase.value === "input") {
+      // apply 见到的还是 CREATED/PAYING ⇒ 没把它推进 done,正常开局
+      phase.value = "waiting";
+      await nextTick();
+      await drawQr();
+      failStreak = 0;
+      schedulePoll(POLL_MS);
+      startTicking();
+    }
+  } catch {
+    // 拦截器已经用服务端的原话弹过 toast 了(例如「二维码生成失败,请重新发起收款」)。
+    // 这里什么都不做:留在输入屏,金额还在框里,操作员直接再按一次即可。
+  } finally {
+    creating.value = false;
+  }
+}
+
+async function poll() {
+  if (phase.value !== "waiting" || !order.value) return;
+  // ★ 记下**发请求那一刻**是哪一个单。应答回来时屏幕上可能已经不是这一单了
+  //   (操作员按了「再收一笔」,或撤销成功之后 reset 过)—— 那时把上一单的状态铺上去,
+  //   页面会莫名其妙地从输入屏跳到结果屏,而且显示的是**上一笔**的金额。
+  //   一个 `await` 就是一个可以换人的缝,跨过缝的读数必须重新问一次「还是不是同一单」。
+  const otn = order.value.out_trade_no;
+  const stillMine = () => phase.value === "waiting" && order.value?.out_trade_no === otn;
+  try {
+    const res: any = await getCashierOrder(otn);
+    if (!stillMine()) return;
+    failStreak = 0;
+    apply(res.data.data);
+    if (phase.value === "waiting") schedulePoll(POLL_MS);
+  } catch {
+    if (!stillMine()) return;
+    // 一次失败不代表这一单出事(服务端那一侧也会降级成「按本地状态返回」)。
+    // 但**不能**保持 3 秒一次 —— 拦截器每次都会弹 toast,断网时会变成刷屏。
+    failStreak += 1;
+    schedulePoll(Math.min(POLL_MS * (failStreak + 1), POLL_MS_MAX));
+  }
+}
+
+async function doClose() {
+  if (!order.value) return;
+  closing.value = true;
+  try {
+    const res: any = await closeCashierOrder(order.value.out_trade_no);
+    apply(res.data.data);
+  } catch {
+    // ★ 不清屏、不改状态:撤销**没成功**,这一单还在等钱。
+    //   服务端那句话(「撤销未成功,请稍后重试」/「顾客已经付款了,不能撤销」)
+    //   已由拦截器弹出。这里再问一次状态 —— 若顾客刚好付了,屏幕应当马上变绿,
+    //   而不是停在二维码上让操作员以为还能撤。
+    poll();
+  } finally {
+    closing.value = false;
+  }
+}
+
+function reset() {
+  stopTimers();
+  failStreak = 0;
+  order.value = null;
+  remaining.value = null;
+  deadline = 0;
+  amountText.value = "";
+  remark.value = "";
+  inputHint.value = "";
+  phase.value = "input";
+  nextTick(() => amountEl.value?.focus());
+}
+
+onMounted(() => amountEl.value?.focus());
+// 离开页面必须停表:否则 keep-alive 之下的旧定时器会一直问下去
+//(而且问的是上一个操作员那一单)。两种缓存方式都覆盖:卸载 + 组件失活。
+onBeforeUnmount(stopTimers);
+</script>
+
+<style scoped lang="scss">
+.cashier {
+  display: flex;
+  justify-content: center;
+}
+
+.pane {
+  width: 520px;
+  text-align: center;
+  padding: 8px 0 4px;
+}
+
+.ask {
+  font-size: 20px;
+  color: #303133;
+  margin-bottom: 18px;
+}
+
+.amount-line {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+
+  .unit {
+    font-size: 26px;
+    color: #606266;
+  }
+
+  .amount-box {
+    width: 300px;
+
+    :deep(.el-input__inner) {
+      font-size: 30px;
+      height: 64px;
+      text-align: center;
+      font-variant-numeric: tabular-nums;
+    }
+  }
+}
+
+.remark-box {
+  width: 420px;
+  margin-top: 16px;
+}
+
+.go {
+  width: 420px;
+  height: 52px;
+  font-size: 20px;
+  letter-spacing: 6px;
+  margin-top: 22px;
+}
+
+.warn {
+  margin-top: 12px;
+  color: #e6a23c;
+  font-size: 13px;
+}
+
+.hint {
+  margin-top: 10px;
+  color: #909399;
+  font-size: 13px;
+}
+
+.wait-title {
+  font-size: 20px;
+  color: #303133;
+}
+
+.wait-amount {
+  font-size: 34px;
+  font-weight: 600;
+  color: #303133;
+  margin: 6px 0 14px;
+  font-variant-numeric: tabular-nums;
+}
+
+.qr-wrap {
+  display: inline-block;
+  padding: 10px;
+  border: 1px solid #ebeef5;
+  background: #fff;
+  line-height: 0;
+
+  &.dim {
+    opacity: 0.25;
+  }
+}
+
+.countdown {
+  margin: 14px 0;
+  color: #909399;
+  font-size: 14px;
+  font-variant-numeric: tabular-nums;
+}
+
+.result {
+  padding: 18px 0 6px;
+
+  .result-icon {
+    font-size: 56px;
+    line-height: 1;
+  }
+
+  .result-title {
+    font-size: 24px;
+    margin-top: 10px;
+  }
+
+  .result-money {
+    font-size: 34px;
+    font-weight: 600;
+    margin-top: 8px;
+    font-variant-numeric: tabular-nums;
+  }
+
+  .result-detail {
+    margin-top: 10px;
+    color: #909399;
+    font-size: 14px;
+  }
+
+  &.ok {
+    color: #67c23a;
+  }
+
+  &.bad {
+    color: #f56c6c;
+  }
+
+  &.muted {
+    color: #909399;
+  }
+}
+</style>