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