|
|
@@ -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>
|