index.vue 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882
  1. <template>
  2. <!-- 单根节点:AppMain 的 <transition mode="out-in"> 要求组件根为元素 -->
  3. <div class="unified-cashier">
  4. <!-- ① 这一笔还没建出来:选模式、填金额、发起收款 -->
  5. <el-card v-if="!order" class="pane">
  6. <template #header>
  7. <div class="card-header">
  8. <span>收银台</span>
  9. <span class="header-hint">主扫 / 被扫 / H5</span>
  10. </div>
  11. </template>
  12. <el-alert
  13. v-if="merchantsError"
  14. class="mb-4"
  15. type="error"
  16. :closable="false"
  17. show-icon
  18. title="商户配置加载失败,请刷新重试"
  19. />
  20. <!-- 本租户没有任何商户配置时,下面所有输入都没有意义:说清楚找谁,而不是留一个报错 -->
  21. <el-alert
  22. v-else-if="!merchantsLoading && merchants.length === 0"
  23. class="mb-4"
  24. type="warning"
  25. :closable="false"
  26. show-icon
  27. title="请联系管理员配置商户"
  28. />
  29. <el-form :model="form" label-width="96px" :disabled="formDisabled">
  30. <el-form-item label="支付模式">
  31. <el-radio-group v-model="mode">
  32. <el-radio v-for="m in MODE_OPTIONS" :key="m.value" :value="m.value">
  33. {{ m.label }}
  34. </el-radio>
  35. </el-radio-group>
  36. </el-form-item>
  37. <!-- 单商户租户不显示下拉:钱进哪个账户没有第二选择,不该多一步 -->
  38. <el-form-item v-if="merchants.length > 1" label="商户">
  39. <el-select v-model="form.mch_no" placeholder="请选择商户" style="width: 100%">
  40. <el-option
  41. v-for="m in merchants"
  42. :key="m.mch_no"
  43. :label="merchantLabel(m)"
  44. :value="m.mch_no"
  45. />
  46. </el-select>
  47. </el-form-item>
  48. <el-form-item label="支付方式">
  49. <el-select v-model="form.way_code" style="width: 100%">
  50. <el-option
  51. v-for="o in wayCodeOptions"
  52. :key="o.value"
  53. :label="o.label"
  54. :value="o.value"
  55. />
  56. </el-select>
  57. </el-form-item>
  58. <el-form-item label="金额(元)">
  59. <el-input v-model="form.amount_text" placeholder="0.00" @keyup.enter="submit" />
  60. </el-form-item>
  61. <el-form-item label="商品标题">
  62. <el-input
  63. v-model="form.subject"
  64. placeholder="展示给顾客的订单标题"
  65. maxlength="128"
  66. show-word-limit
  67. @keyup.enter="submit"
  68. />
  69. </el-form-item>
  70. <el-form-item v-if="mode === 'barcode'" label="付款码">
  71. <!-- ★ 付款码只在这一笔的请求里出现:type=password、提交后立即清空,
  72. 不写 localStorage/sessionStorage,也不打日志。 -->
  73. <el-input
  74. v-model="form.auth_code"
  75. type="password"
  76. autocomplete="off"
  77. placeholder="请扫描或输入顾客付款码"
  78. @keyup.enter="submit"
  79. />
  80. </el-form-item>
  81. <el-form-item>
  82. <el-button
  83. v-hasPerm="['module_payment:unified:cashier:create']"
  84. type="primary"
  85. :loading="creating"
  86. :disabled="submitDisabled"
  87. @click="submit"
  88. >
  89. 发起收款
  90. </el-button>
  91. </el-form-item>
  92. </el-form>
  93. <div v-if="inputHint" class="warn">{{ inputHint }}</div>
  94. </el-card>
  95. <!-- ② 已经建单:展示支付数据与状态,每 3 秒静默查询一次 -->
  96. <el-card v-else class="pane">
  97. <template #header>
  98. <div class="card-header">
  99. <span>收款结果</span>
  100. <el-tag :type="statusTagType">{{ statusText }}</el-tag>
  101. </div>
  102. </template>
  103. <div class="amount-line">
  104. <span class="amount-unit">¥</span>
  105. <span class="amount-value">{{ fenToYuan(order.amount) }}</span>
  106. </div>
  107. <el-descriptions :column="1" border size="small" class="meta">
  108. <el-descriptions-item label="订单号">{{ order.order_no }}</el-descriptions-item>
  109. <el-descriptions-item label="商户订单号">{{ order.out_trade_no }}</el-descriptions-item>
  110. <el-descriptions-item label="支付方式">
  111. {{ wayCodeLabel(order.way_code) }}
  112. </el-descriptions-item>
  113. <el-descriptions-item v-if="order.mch_no" label="商户号">
  114. {{ order.mch_no }}
  115. </el-descriptions-item>
  116. <el-descriptions-item v-if="order.pay_time" label="支付时间">
  117. {{ formatTime(order.pay_time) }}
  118. </el-descriptions-item>
  119. </el-descriptions>
  120. <!--
  121. 主扫:pay_data_type 判等忽略大小写(codeUrl / payUrl / payurl / codeImgUrl)。
  122. 二维码内容是上游给的任意字符串(如 weixin://wxpay/bizpayurl?…),不是导航目标,
  123. 原样交给 QRCode;只有进 <img src> 的 codeImgUrl 才过 isSafeHttpUrl。
  124. -->
  125. <div v-if="isScan" class="pay-area">
  126. <template v-if="qrDataUrl">
  127. <div class="qr-wrap">
  128. <img :src="qrDataUrl" alt="支付二维码" />
  129. </div>
  130. <div class="hint">请顾客使用对应 App 扫码支付</div>
  131. </template>
  132. <template v-else-if="safeCodeImgUrl">
  133. <div class="qr-wrap">
  134. <img :src="safeCodeImgUrl" alt="支付二维码" referrerpolicy="no-referrer" />
  135. </div>
  136. <div class="hint">请顾客使用对应 App 扫码支付</div>
  137. </template>
  138. <pre v-else-if="order.pay_data" class="pay-data-text">{{ order.pay_data }}</pre>
  139. <div v-else class="hint text-muted">上游未返回支付二维码数据</div>
  140. </div>
  141. <!-- 被扫:没有可展示的支付数据,只有状态在动 -->
  142. <div v-else-if="isBarcode" class="pay-area hint">
  143. {{ isPending ? "正在等待上游返回支付结果…" : "本笔已结束" }}
  144. </div>
  145. <!-- H5:只有 http/https 链接才给「打开支付页」;其他内容按文本展示并 warn -->
  146. <div v-else-if="isH5Way" class="pay-area">
  147. <template v-if="h5PayUrl">
  148. <el-button type="primary" @click="openPayPage">打开支付页</el-button>
  149. <div class="link-line">
  150. <span class="link-text">{{ h5PayUrl }}</span>
  151. <CopyButton :text="h5PayUrl" />
  152. </div>
  153. </template>
  154. <pre v-else-if="order.pay_data" class="pay-data-text">{{ order.pay_data }}</pre>
  155. <div v-else class="hint text-muted">上游未返回支付链接</div>
  156. </div>
  157. <div v-if="order.err_code || order.err_msg" class="err-box">
  158. <div v-if="order.err_code">错误码:{{ order.err_code }}</div>
  159. <div v-if="order.err_msg">错误信息:{{ order.err_msg }}</div>
  160. </div>
  161. <div class="poll-line">
  162. <span v-if="pollGone" class="poll-gone">订单不存在或已失效</span>
  163. <template v-else-if="pollTimedOut">已停止自动查询,请到订单页手动同步</template>
  164. <template v-else-if="isPending">正在等待支付结果,每 3 秒自动查询…</template>
  165. </div>
  166. <!--
  167. R72:未终态且订单方式是公开 H5 页可接受的之一时,可以把后台铸造的
  168. 公开支付页链接发给顾客。链接本身同样先过 isSafeHttpUrl 再展示/复制。
  169. -->
  170. <div v-if="canGenerateH5Link || h5Link" class="h5-link">
  171. <div class="h5-link__title">公开 H5 支付页</div>
  172. <el-button
  173. v-if="canGenerateH5Link"
  174. size="small"
  175. :loading="h5LinkLoading"
  176. @click="generateH5Link"
  177. >
  178. 复制 H5 支付链接
  179. </el-button>
  180. <template v-if="h5Link">
  181. <div class="link-line">
  182. <span class="link-text">{{ h5Link }}</span>
  183. <CopyButton v-if="safeH5Link" :text="h5Link" />
  184. </div>
  185. <div v-if="!safeH5Link" class="warn">该链接不是 http/https 地址,只展示不复制</div>
  186. </template>
  187. </div>
  188. <div class="footer">
  189. <el-button @click="reset">再收一笔</el-button>
  190. </div>
  191. </el-card>
  192. </div>
  193. </template>
  194. <script setup lang="ts">
  195. // ★ 与 sys_menu.route_name 逐字一致(`db/unified-menu.sql`):keep-alive 的 include
  196. // 匹配的是组件名,不写这一行会按文件名推成 "index",`keep_alive=true` 静默失效(bf09016)。
  197. defineOptions({
  198. name: "payment-unified-cashier",
  199. inheritAttrs: false,
  200. });
  201. import {
  202. computed,
  203. onActivated,
  204. onBeforeUnmount,
  205. onDeactivated,
  206. onMounted,
  207. reactive,
  208. ref,
  209. watch,
  210. } from "vue";
  211. import { ElMessage } from "element-plus";
  212. import dayjs from "dayjs";
  213. import QRCode from "qrcode";
  214. import UnifiedAPI, {
  215. type UnifiedMerchantVO,
  216. type UnifiedOrderCreateParams,
  217. type UnifiedOrderVO,
  218. } from "@/api/module_payment/unified";
  219. import {
  220. UNIFIED_ORDER_STATUS_TEXT,
  221. UNIFIED_WAY_CODE_OPTIONS,
  222. buildOutTradeNo,
  223. fenToYuan,
  224. isSafeHttpUrl,
  225. shouldPollUnified,
  226. yuanToFen,
  227. } from "@/utils/unifiedPay";
  228. /**
  229. * 统一支付收银台(主扫 / 被扫 / H5)。
  230. *
  231. * 【三件事必须一直成立】
  232. * 1. 金额全程走 `yuanToFen` / `fenToYuan`,页面里不出现第二份 `* 100` / `/ 100`;
  233. * 2. 付款码只活在内存里的一个 `reactive` 字段:`type=password`、提交即清空、
  234. * 不落任何浏览器存储、不进日志;
  235. * 3. 页面不接触 `app_secret` —— 商户列表响应里根本没有这个字段,
  236. * 这里也不提供任何「查看密钥」的入口。
  237. *
  238. * 【轮询】每 3 秒一次、**静默**(R70):失败保留上次已知状态,不弹 toast。
  239. * 终态(2/3/4/5/6)静默停在最终状态;5 分钟仍未收敛才停表并提示去订单页手动同步(R71)。
  240. * keep-alive 之下离开页面是失活不是卸载,`onDeactivated` 也要停表,回来再按剩余时间接上。
  241. */
  242. type PayMode = "scan" | "barcode" | "h5";
  243. type TagType = "primary" | "success" | "warning" | "info" | "danger";
  244. /** 轮询间隔,与后端 `UnifiedCashierService.SNAPSHOT_THROTTLE_SECONDS` 同值(改一处要改另一处) */
  245. const POLL_MS = 3000;
  246. /** 轮询上限,与 `shouldPollUnified` 的 300000 是同一个数(判据仍以 helper 为准,这里只用于提示) */
  247. const POLL_MAX_MS = 300000;
  248. /** 仍在等待的状态(与 `shouldPollUnified` 里的集合一致) */
  249. const PENDING_STATUS = new Set(["0", "1"]);
  250. const MODE_OPTIONS: Array<{ value: PayMode; label: string }> = [
  251. { value: "scan", label: "主扫" },
  252. { value: "barcode", label: "被扫" },
  253. { value: "h5", label: "H5" },
  254. ];
  255. /** 三种模式下收银台允许的 wayCode(spec §4.6 的 16 个值里的子集) */
  256. const WAY_CODES_BY_MODE: Record<PayMode, string[]> = {
  257. scan: ["QR_CASHIER", "ALI_QR", "WX_NATIVE"],
  258. barcode: ["AUTO_BAR", "ALI_BAR", "WX_BAR", "YSF_BAR"],
  259. h5: ["WX_H5"],
  260. };
  261. const DEFAULT_WAY_CODE: Record<PayMode, string> = {
  262. scan: "QR_CASHIER",
  263. barcode: "AUTO_BAR",
  264. h5: "WX_H5",
  265. };
  266. /**
  267. * 公开 H5 支付页可接受的方式(R72)= 收银台可选方式 ∩ 后端 `H5_SUPPORTED`。
  268. * 条码类需要付款码,公开页不收 `channel_extra`,所以不在此列。
  269. */
  270. const H5_LINK_WAY_CODES = new Set(["QR_CASHIER", "ALI_QR", "WX_NATIVE", "WX_H5"]);
  271. /** 订单状态 → 标签色(表外状态按 info 兜底,文本仍显示原值) */
  272. const ORDER_STATUS_TAG: Record<string, TagType> = {
  273. "0": "info",
  274. "1": "warning",
  275. "2": "success",
  276. "3": "danger",
  277. "4": "info",
  278. "5": "warning",
  279. "6": "info",
  280. };
  281. const mode = ref<PayMode>("scan");
  282. const merchants = ref<UnifiedMerchantVO[]>([]);
  283. const merchantsLoading = ref(true);
  284. const merchantsError = ref(false);
  285. const creating = ref(false);
  286. const inputHint = ref("");
  287. const form = reactive({
  288. mch_no: "",
  289. way_code: DEFAULT_WAY_CODE.scan,
  290. amount_text: "",
  291. subject: "",
  292. auth_code: "",
  293. });
  294. const order = ref<UnifiedOrderVO | null>(null);
  295. const qrDataUrl = ref("");
  296. const h5Link = ref("");
  297. const h5LinkLoading = ref(false);
  298. const pollTimedOut = ref(false);
  299. /** R75:快照接口判定订单不存在(body code=404 / HTTP 404)—— 停止态,不再接表 */
  300. const pollGone = ref(false);
  301. let pollTimer: number | undefined;
  302. /** 本单开始轮询的时刻:5 分钟窗口从建单成功那一秒算,不因失活/重连重置 */
  303. let pollStartedAt = 0;
  304. let pollInFlight = false;
  305. /** `out_trade_no` 幂等键:首次点击生成,失败重试/双击复用,创建成功才清空 */
  306. let pendingOutTradeNo = "";
  307. /** 二维码异步生成的代次:迟到的 A 单结果不能渲染到 B 单上 */
  308. let qrSeq = 0;
  309. const wayCodeOptions = computed(() =>
  310. UNIFIED_WAY_CODE_OPTIONS.filter((o) => WAY_CODES_BY_MODE[mode.value].includes(o.value))
  311. );
  312. const formDisabled = computed(() => merchantsLoading.value || merchants.value.length === 0);
  313. /** 没有商户、多商户未选、正在提交 —— 三种情况都不该再发一次建单请求 */
  314. const submitDisabled = computed(
  315. () =>
  316. creating.value ||
  317. merchantsLoading.value ||
  318. merchants.value.length === 0 ||
  319. (merchants.value.length > 1 && !form.mch_no)
  320. );
  321. const payKind = computed(() => (order.value?.pay_data_type || "").toLowerCase());
  322. const payValue = computed(() => order.value?.pay_data || "");
  323. /** `codeImgUrl` 里唯一允许进 `<img src>` 的形状:http/https */
  324. const safeCodeImgUrl = computed(() =>
  325. payKind.value === "codeimgurl" && isSafeHttpUrl(payValue.value) ? payValue.value : ""
  326. );
  327. const isPending = computed(() => !!order.value && PENDING_STATUS.has(order.value.trade_status));
  328. const isScan = computed(
  329. () => !!order.value && WAY_CODES_BY_MODE.scan.includes(order.value.way_code)
  330. );
  331. const isBarcode = computed(
  332. () => !!order.value && WAY_CODES_BY_MODE.barcode.includes(order.value.way_code)
  333. );
  334. const isH5Way = computed(() => order.value?.way_code === "WX_H5");
  335. /** H5 模式的支付链接:只放行 http/https,其他内容由模板降级成文本 */
  336. const h5PayUrl = computed(() => {
  337. const o = order.value;
  338. if (!o || o.way_code !== "WX_H5") {
  339. return "";
  340. }
  341. return isSafeHttpUrl(o.pay_data) ? o.pay_data || "" : "";
  342. });
  343. const statusText = computed(() => {
  344. const st = order.value?.trade_status;
  345. return st ? UNIFIED_ORDER_STATUS_TEXT[st] || st : "-";
  346. });
  347. const statusTagType = computed<TagType>(() => {
  348. const st = order.value?.trade_status;
  349. return (st && ORDER_STATUS_TAG[st]) || "info";
  350. });
  351. /** R72:未终态且该方式能被公开 H5 页受理时,才提供「复制 H5 支付链接」;已失效的单不给入口 */
  352. const canGenerateH5Link = computed(() => {
  353. const o = order.value;
  354. return (
  355. !!o &&
  356. !pollGone.value &&
  357. PENDING_STATUS.has(o.trade_status) &&
  358. H5_LINK_WAY_CODES.has(o.way_code)
  359. );
  360. });
  361. const safeH5Link = computed(() => isSafeHttpUrl(h5Link.value));
  362. const merchantLabel = (m: UnifiedMerchantVO) =>
  363. m.merchant_name ? `${m.merchant_name}(${m.mch_no})` : m.mch_no;
  364. const wayCodeLabel = (v?: string) => {
  365. if (!v) {
  366. return "-";
  367. }
  368. return UNIFIED_WAY_CODE_OPTIONS.find((o) => o.value === v)?.label || v;
  369. };
  370. const formatTime = (v?: string) => (v ? dayjs(v).format("YYYY-MM-DD HH:mm:ss") : "-");
  371. /** 六位随机数字(补零);`buildOutTradeNo` 负责拼时间戳与 `C` 前缀 */
  372. function random6(): string {
  373. return String(Math.floor(Math.random() * 1000000)).padStart(6, "0");
  374. }
  375. async function loadMerchants() {
  376. merchantsLoading.value = true;
  377. merchantsError.value = false;
  378. try {
  379. const res = await UnifiedAPI.merchantPage({ page_no: 1, page_size: 100 });
  380. // 后端分页键是 `list`,全局 TS 声明写的是 `items` —— 两个都读,
  381. // 只读 items 会静默拿到空数组(商户页/订单页同一个坑)。
  382. merchants.value = res.data.data?.list || res.data.data?.items || [];
  383. form.mch_no = merchants.value.length === 1 ? merchants.value[0].mch_no : "";
  384. } catch {
  385. // 拦截器已经弹过后端原话;这里把「加载失败」与「确实 0 条」分开,
  386. // 免得权限/网络问题被说成「没有商户配置」。
  387. merchants.value = [];
  388. merchantsError.value = true;
  389. } finally {
  390. merchantsLoading.value = false;
  391. }
  392. }
  393. function startPolling() {
  394. window.clearInterval(pollTimer);
  395. pollTimer = window.setInterval(poll, POLL_MS);
  396. }
  397. function stopPolling() {
  398. window.clearInterval(pollTimer);
  399. pollTimer = undefined;
  400. }
  401. /** 建单成功后接管屏幕:清掉上一单的一切派生状态,再决定要不要开表 */
  402. function applyOrder(vo: UnifiedOrderVO) {
  403. stopPolling();
  404. order.value = vo;
  405. h5Link.value = "";
  406. h5LinkLoading.value = false;
  407. pollTimedOut.value = false;
  408. pollGone.value = false;
  409. pollStartedAt = Date.now();
  410. if (shouldPollUnified(vo.trade_status, 0)) {
  411. startPolling();
  412. }
  413. }
  414. async function submit() {
  415. if (creating.value) {
  416. return;
  417. }
  418. inputHint.value = "";
  419. if (merchantsLoading.value) {
  420. return;
  421. }
  422. if (merchants.value.length === 0) {
  423. inputHint.value = "请联系管理员配置商户";
  424. return;
  425. }
  426. if (merchants.value.length > 1 && !form.mch_no) {
  427. inputHint.value = "本租户配了多个商户,请先选择商户";
  428. return;
  429. }
  430. const fen = yuanToFen(form.amount_text);
  431. if (fen === null) {
  432. inputHint.value = "金额格式不正确:请输入最多两位小数的数字";
  433. return;
  434. }
  435. if (fen <= 0) {
  436. inputHint.value = "金额必须大于 0";
  437. return;
  438. }
  439. const subject = form.subject.trim();
  440. if (!subject) {
  441. inputHint.value = "请填写商品标题";
  442. return;
  443. }
  444. let authCode = "";
  445. if (mode.value === "barcode") {
  446. authCode = form.auth_code.trim();
  447. if (!authCode) {
  448. inputHint.value = "请扫描或输入顾客付款码";
  449. return;
  450. }
  451. }
  452. // ★ 幂等键只在点击时生成一次:失败重试、双击都复用同一个号,创建成功才清空。
  453. if (!pendingOutTradeNo) {
  454. pendingOutTradeNo = buildOutTradeNo(new Date(), random6());
  455. }
  456. const params: UnifiedOrderCreateParams = {
  457. mch_no: form.mch_no || undefined,
  458. amount: fen,
  459. way_code: form.way_code,
  460. subject,
  461. out_trade_no: pendingOutTradeNo,
  462. };
  463. if (mode.value === "scan") {
  464. // 主扫让上游把支付数据按 codeUrl 返回(spec §4.6 / R33)
  465. params.channel_extra = { payDataType: "codeUrl" };
  466. } else if (mode.value === "barcode") {
  467. params.channel_extra = { auth_code: authCode };
  468. // ★ 付款码提交后立即清空:失败也不回填,重试由操作员重新扫码。
  469. form.auth_code = "";
  470. }
  471. // H5 模式不带 channel_extra:公开/网页支付不需要付款码,后端也不接受多余键。
  472. creating.value = true;
  473. try {
  474. const res = await UnifiedAPI.cashierCreate(params);
  475. const vo = res.data.data;
  476. if (!vo) {
  477. // 没有订单可展示就不要清幂等键:此时再点一次会复用同一个号,避免重复落单。
  478. ElMessage.warning("下单成功但未返回订单信息,请到订单页确认");
  479. return;
  480. }
  481. pendingOutTradeNo = "";
  482. applyOrder(vo);
  483. } catch {
  484. // 拦截器已用后端原话弹过 toast(例如「商户已停用」)。输入保留,直接重试。
  485. } finally {
  486. creating.value = false;
  487. }
  488. }
  489. async function poll() {
  490. if (pollInFlight) {
  491. return;
  492. }
  493. const current = order.value;
  494. if (!current) {
  495. stopPolling();
  496. return;
  497. }
  498. const orderNo = current.order_no;
  499. const elapsed = Date.now() - pollStartedAt;
  500. if (!shouldPollUnified(current.trade_status, elapsed)) {
  501. stopPolling();
  502. if (PENDING_STATUS.has(current.trade_status) && elapsed >= POLL_MAX_MS) {
  503. pollTimedOut.value = true;
  504. }
  505. return;
  506. }
  507. pollInFlight = true;
  508. try {
  509. // R70:轮询必须静默 —— 非静默失败会被响应拦截器每 3 秒弹一次 toast。
  510. const res = await UnifiedAPI.cashierOrder(orderNo, true);
  511. const vo = res.data.data;
  512. // 一个 await 就是一个可以换单的缝:回来先确认屏幕上还是同一单。
  513. if (!order.value || order.value.order_no !== orderNo || !vo) {
  514. return;
  515. }
  516. order.value = vo;
  517. const now = Date.now();
  518. if (!shouldPollUnified(vo.trade_status, now - pollStartedAt)) {
  519. stopPolling();
  520. if (PENDING_STATUS.has(vo.trade_status) && now - pollStartedAt >= POLL_MAX_MS) {
  521. pollTimedOut.value = true;
  522. }
  523. }
  524. } catch (err) {
  525. // R70:静默轮询失败保留上次已知状态,不弹 toast。
  526. // R75:订单不存在不是抖动 —— 后端以 HTTP 200 + body code=404 应答(GlobalExceptionHandler
  527. // 不设状态码),再问也不会有结果。停表并把结果区切到「订单不存在或已失效」。
  528. const e = err as {
  529. data?: { code?: number | string };
  530. response?: { status?: number };
  531. };
  532. if (String(e?.data?.code) === "404" || e?.response?.status === 404) {
  533. // 迟到的 404 不能停掉新单的表:先确认屏幕上还是同一单,再停表/置失效。
  534. if (order.value?.order_no === orderNo) {
  535. stopPolling();
  536. pollGone.value = true;
  537. }
  538. return;
  539. }
  540. // 其他失败:下一个 3 秒继续问。
  541. } finally {
  542. pollInFlight = false;
  543. }
  544. }
  545. /** R72:为当前订单铸造公开 H5 支付页链接;展示/复制前仍校验协议 */
  546. async function generateH5Link() {
  547. const o = order.value;
  548. if (!o || h5LinkLoading.value) {
  549. return;
  550. }
  551. h5LinkLoading.value = true;
  552. try {
  553. const res = await UnifiedAPI.cashierH5Link(o.order_no);
  554. const url = res.data.data?.url || "";
  555. if (url && !isSafeHttpUrl(url)) {
  556. // 不打印链接本身:链接里带签名参数
  557. console.warn("不安全的 H5 支付链接,已按文本展示");
  558. }
  559. // 迟到的响应:期间已经换了单
  560. if (order.value?.order_no === o.order_no) {
  561. h5Link.value = url;
  562. }
  563. } catch {
  564. // 拦截器已提示(例如「商户已停用」)
  565. } finally {
  566. h5LinkLoading.value = false;
  567. }
  568. }
  569. function openPayPage() {
  570. const url = h5PayUrl.value;
  571. if (!url || !isSafeHttpUrl(url)) {
  572. return;
  573. }
  574. window.open(url, "_blank", "noopener,noreferrer");
  575. }
  576. function reset() {
  577. stopPolling();
  578. qrSeq += 1;
  579. qrDataUrl.value = "";
  580. order.value = null;
  581. h5Link.value = "";
  582. h5LinkLoading.value = false;
  583. pollTimedOut.value = false;
  584. pollGone.value = false;
  585. pollStartedAt = 0;
  586. pendingOutTradeNo = "";
  587. inputHint.value = "";
  588. form.amount_text = "";
  589. form.subject = "";
  590. form.auth_code = "";
  591. // mode / way_code / mch_no 保留:柜台连收几笔通常是同一个组合
  592. }
  593. // 切换模式就换回该模式的默认方式,并把上一模式残留的付款码抹掉
  594. watch(mode, (m) => {
  595. form.way_code = DEFAULT_WAY_CODE[m];
  596. form.auth_code = "";
  597. inputHint.value = "";
  598. });
  599. // 主扫:codeUrl / payUrl 生成二维码,codeImgUrl 交给 <img>(computed)。
  600. // seq 用来丢弃迟到的异步结果 —— A 单的码不能渲染到 B 单上。
  601. watch(
  602. () => [order.value?.order_no, order.value?.pay_data, order.value?.pay_data_type] as const,
  603. async () => {
  604. const seq = ++qrSeq;
  605. qrDataUrl.value = "";
  606. const o = order.value;
  607. if (!o || !WAY_CODES_BY_MODE.scan.includes(o.way_code)) {
  608. return;
  609. }
  610. const kind = (o.pay_data_type || "").toLowerCase();
  611. const value = o.pay_data || "";
  612. if (!value) {
  613. return;
  614. }
  615. if (kind === "codeurl" || kind === "payurl") {
  616. // R74:二维码内容不是 URL(WX_NATIVE 返回 weixin://wxpay/bizpayurl?…),
  617. // 不能按 http(s) 白名单拦;它只会被编码进 QR,不会被浏览器导航。
  618. try {
  619. const url = await QRCode.toDataURL(value, { width: 260, margin: 2 });
  620. if (seq === qrSeq) {
  621. qrDataUrl.value = url;
  622. }
  623. } catch (e) {
  624. console.warn("支付二维码生成失败,已按文本展示", e);
  625. }
  626. } else if (kind === "codeimgurl" && !isSafeHttpUrl(value)) {
  627. console.warn("不安全的 codeImgUrl,已按文本展示");
  628. }
  629. },
  630. { immediate: true }
  631. );
  632. // H5:非 http/https 的 pay_data 只按文本展示,并在控制台留一条 WARN
  633. watch(
  634. () => [order.value?.order_no, order.value?.way_code, order.value?.pay_data] as const,
  635. () => {
  636. const o = order.value;
  637. if (!o || o.way_code !== "WX_H5") {
  638. return;
  639. }
  640. const value = o.pay_data || "";
  641. if (value && !isSafeHttpUrl(value)) {
  642. console.warn("不安全的 H5 支付链接,已按文本展示");
  643. }
  644. },
  645. { immediate: true }
  646. );
  647. onMounted(() => {
  648. loadMerchants();
  649. });
  650. // 离开页面必须停表:keep-alive 走的是失活不是卸载,两处都要挂。
  651. onBeforeUnmount(stopPolling);
  652. onDeactivated(stopPolling);
  653. // 回来时若这一单还在等,把表接上(不重置 5 分钟窗口:它是从建单开始算的墙钟)
  654. onActivated(() => {
  655. const o = order.value;
  656. if (!o || pollGone.value) {
  657. return;
  658. }
  659. const elapsed = Date.now() - pollStartedAt;
  660. if (shouldPollUnified(o.trade_status, elapsed)) {
  661. if (pollTimer === undefined) {
  662. startPolling();
  663. }
  664. } else if (PENDING_STATUS.has(o.trade_status) && elapsed >= POLL_MAX_MS) {
  665. pollTimedOut.value = true;
  666. }
  667. });
  668. </script>
  669. <style lang="scss" scoped>
  670. .unified-cashier {
  671. display: flex;
  672. justify-content: center;
  673. }
  674. .pane {
  675. width: 560px;
  676. margin-bottom: 16px;
  677. }
  678. .card-header {
  679. display: flex;
  680. align-items: center;
  681. justify-content: space-between;
  682. }
  683. .header-hint {
  684. font-size: 13px;
  685. color: var(--el-text-color-secondary);
  686. }
  687. .warn {
  688. margin-top: 8px;
  689. font-size: 13px;
  690. color: var(--el-color-warning);
  691. }
  692. .amount-line {
  693. display: flex;
  694. align-items: baseline;
  695. justify-content: center;
  696. margin: 4px 0 16px;
  697. .amount-unit {
  698. font-size: 22px;
  699. color: var(--el-text-color-regular);
  700. }
  701. .amount-value {
  702. margin-left: 4px;
  703. font-size: 34px;
  704. font-weight: 600;
  705. color: var(--el-text-color-primary);
  706. font-variant-numeric: tabular-nums;
  707. }
  708. }
  709. .meta {
  710. margin-bottom: 16px;
  711. }
  712. .pay-area {
  713. margin: 16px 0;
  714. text-align: center;
  715. }
  716. .qr-wrap {
  717. display: inline-block;
  718. padding: 10px;
  719. line-height: 0;
  720. background: #fff;
  721. border: 1px solid var(--el-border-color);
  722. }
  723. .pay-data-text {
  724. max-height: 220px;
  725. padding: 8px 12px;
  726. margin: 0;
  727. overflow: auto;
  728. font-family: inherit;
  729. font-size: 12px;
  730. line-height: 1.6;
  731. color: var(--el-text-color-regular);
  732. word-break: break-all;
  733. white-space: pre-wrap;
  734. background: var(--el-fill-color-light);
  735. border-radius: 4px;
  736. }
  737. .hint {
  738. margin-top: 10px;
  739. font-size: 13px;
  740. color: var(--el-text-color-secondary);
  741. }
  742. .text-muted {
  743. color: var(--el-text-color-secondary);
  744. }
  745. .err-box {
  746. padding: 8px 12px;
  747. margin-top: 12px;
  748. font-size: 13px;
  749. line-height: 1.7;
  750. color: var(--el-color-danger);
  751. background: var(--el-color-danger-light-9);
  752. border-radius: 4px;
  753. }
  754. .poll-line {
  755. margin-top: 12px;
  756. font-size: 13px;
  757. color: var(--el-text-color-secondary);
  758. text-align: center;
  759. }
  760. .poll-gone {
  761. color: var(--el-color-danger);
  762. }
  763. .h5-link {
  764. padding-top: 12px;
  765. margin-top: 16px;
  766. border-top: 1px dashed var(--el-border-color);
  767. }
  768. .h5-link__title {
  769. margin-bottom: 8px;
  770. font-size: 13px;
  771. font-weight: 600;
  772. color: var(--el-text-color-regular);
  773. }
  774. .link-line {
  775. display: flex;
  776. gap: 6px;
  777. align-items: center;
  778. margin-top: 8px;
  779. font-size: 12px;
  780. color: var(--el-text-color-secondary);
  781. }
  782. .link-text {
  783. flex: 1;
  784. word-break: break-all;
  785. }
  786. .footer {
  787. margin-top: 18px;
  788. text-align: center;
  789. }
  790. </style>