| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882 |
- <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)。
- 二维码内容是上游给的任意字符串(如 weixin://wxpay/bizpayurl?…),不是导航目标,
- 原样交给 QRCode;只有进 <img src> 的 codeImgUrl 才过 isSafeHttpUrl。
- -->
- <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">
- <span v-if="pollGone" class="poll-gone">订单不存在或已失效</span>
- <template v-else-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);
- /** R75:快照接口判定订单不存在(body code=404 / HTTP 404)—— 停止态,不再接表 */
- const pollGone = 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 &&
- !pollGone.value &&
- 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;
- pollGone.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 (err) {
- // R70:静默轮询失败保留上次已知状态,不弹 toast。
- // R75:订单不存在不是抖动 —— 后端以 HTTP 200 + body code=404 应答(GlobalExceptionHandler
- // 不设状态码),再问也不会有结果。停表并把结果区切到「订单不存在或已失效」。
- const e = err as {
- data?: { code?: number | string };
- response?: { status?: number };
- };
- if (String(e?.data?.code) === "404" || e?.response?.status === 404) {
- // 迟到的 404 不能停掉新单的表:先确认屏幕上还是同一单,再停表/置失效。
- if (order.value?.order_no === orderNo) {
- stopPolling();
- pollGone.value = true;
- }
- return;
- }
- // 其他失败:下一个 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;
- pollGone.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") {
- // R74:二维码内容不是 URL(WX_NATIVE 返回 weixin://wxpay/bizpayurl?…),
- // 不能按 http(s) 白名单拦;它只会被编码进 QR,不会被浏览器导航。
- 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 || pollGone.value) {
- 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;
- }
- .poll-gone {
- color: var(--el-color-danger);
- }
- .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>
|