unified-h5.html 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  6. <meta name="referrer" content="no-referrer">
  7. <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src http: https:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'">
  8. <title>聚合支付</title>
  9. <style>
  10. :root {
  11. --bg: #f4f5f7;
  12. --card: #ffffff;
  13. --text: #1f2329;
  14. --muted: #6b7280;
  15. --line: #e5e7eb;
  16. --brand: #1677ff;
  17. --brand-press: #0f5fd6;
  18. --danger: #d93026;
  19. --radius: 14px;
  20. }
  21. * { box-sizing: border-box; }
  22. /* UA 的 [hidden] 会被下面 ID 选择器里的 display:block 盖过:空「去支付」链接与坏图会露出来 */
  23. [hidden] { display: none !important; }
  24. html, body { margin: 0; padding: 0; }
  25. body {
  26. min-height: 100vh;
  27. background: var(--bg);
  28. color: var(--text);
  29. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
  30. "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
  31. font-size: 15px;
  32. line-height: 1.5;
  33. padding: env(safe-area-inset-top) 16px calc(24px + env(safe-area-inset-bottom));
  34. }
  35. main {
  36. max-width: 420px;
  37. margin: 32px auto 0;
  38. }
  39. h1 {
  40. font-size: 18px;
  41. font-weight: 600;
  42. margin: 0 0 16px;
  43. text-align: center;
  44. }
  45. .card {
  46. background: var(--card);
  47. border: 1px solid var(--line);
  48. border-radius: var(--radius);
  49. padding: 20px;
  50. box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
  51. }
  52. .summary { margin: 0 0 18px; }
  53. .summary > div {
  54. display: flex;
  55. justify-content: space-between;
  56. gap: 16px;
  57. padding: 9px 0;
  58. border-bottom: 1px solid var(--line);
  59. }
  60. .summary > div:last-child { border-bottom: 0; }
  61. dt { color: var(--muted); flex: none; }
  62. dd {
  63. margin: 0;
  64. text-align: right;
  65. word-break: break-all;
  66. font-variant-numeric: tabular-nums;
  67. }
  68. button {
  69. width: 100%;
  70. border: 0;
  71. border-radius: 10px;
  72. background: var(--brand);
  73. color: #fff;
  74. font-size: 16px;
  75. font-weight: 600;
  76. padding: 12px 16px;
  77. cursor: pointer;
  78. }
  79. button:active { background: var(--brand-press); }
  80. button:disabled { opacity: 0.6; cursor: default; }
  81. #copy-code {
  82. width: auto;
  83. margin-top: 10px;
  84. padding: 7px 14px;
  85. font-size: 13px;
  86. background: transparent;
  87. color: var(--brand);
  88. border: 1px solid var(--brand);
  89. border-radius: 8px;
  90. }
  91. #copy-code:active { background: #eef4ff; }
  92. .hint { margin: 12px 0 0; color: var(--muted); font-size: 13px; }
  93. .error { margin: 0; color: var(--danger); }
  94. #pay-image {
  95. display: block;
  96. width: min(240px, 100%);
  97. height: auto;
  98. margin: 4px auto 10px;
  99. border-radius: 8px;
  100. }
  101. #pay-image-hint { margin: 0 0 12px; text-align: center; color: var(--muted); }
  102. #pay-link {
  103. display: block;
  104. margin: 4px 0 12px;
  105. padding: 12px 16px;
  106. text-align: center;
  107. text-decoration: none;
  108. color: #fff;
  109. background: var(--brand);
  110. border-radius: 10px;
  111. font-weight: 600;
  112. }
  113. #pay-link:active { background: var(--brand-press); }
  114. #code-block { margin: 4px 0 12px; }
  115. #pay-code {
  116. display: block;
  117. padding: 10px 12px;
  118. background: #f8fafc;
  119. border: 1px solid var(--line);
  120. border-radius: 8px;
  121. font-size: 12px;
  122. line-height: 1.45;
  123. word-break: break-all;
  124. white-space: pre-wrap;
  125. }
  126. </style>
  127. </head>
  128. <body>
  129. <main>
  130. <h1>收银台</h1>
  131. <section id="mode-loading" class="card">
  132. <p class="hint">加载中…</p>
  133. </section>
  134. <section id="mode-error" class="card" hidden>
  135. <p class="error">链接不完整或已失效,请返回商户页面重新发起支付。</p>
  136. </section>
  137. <section id="mode-create" class="card" hidden>
  138. <dl class="summary">
  139. <div><dt>商户号</dt><dd id="create-mch-no"></dd></div>
  140. <div><dt>订单号</dt><dd id="create-out-trade-no"></dd></div>
  141. <div><dt>金额</dt><dd id="create-amount"></dd></div>
  142. <div><dt>标题</dt><dd id="create-subject"></dd></div>
  143. <div><dt>支付方式</dt><dd id="create-way-code"></dd></div>
  144. </dl>
  145. <button id="confirm-pay" type="button">确认支付</button>
  146. <p class="hint" id="create-error" hidden></p>
  147. </section>
  148. <section id="mode-result" class="card" hidden>
  149. <dl class="summary">
  150. <div><dt>订单号</dt><dd id="result-order-no"></dd></div>
  151. <div><dt>金额</dt><dd id="result-amount"></dd></div>
  152. <div><dt>标题</dt><dd id="result-subject"></dd></div>
  153. <div><dt>状态</dt><dd id="result-status"></dd></div>
  154. </dl>
  155. <div id="pay-panel">
  156. <a id="pay-link" target="_blank" rel="noopener noreferrer" hidden>去支付</a>
  157. <img id="pay-image" alt="支付二维码" hidden>
  158. <p id="pay-image-hint" hidden>请使用手机扫码支付</p>
  159. <div id="code-block" hidden>
  160. <code id="pay-code"></code>
  161. <button id="copy-code" type="button">复制</button>
  162. </div>
  163. <p id="pay-other" hidden>请返回商户页面完成支付</p>
  164. </div>
  165. <p class="hint" id="result-error" hidden></p>
  166. </section>
  167. </main>
  168. <script>
  169. (function () {
  170. "use strict";
  171. var API_BASE = location.origin + "/api/v1";
  172. var H5_WAY_CODES = ["QR_CASHIER", "ALI_QR", "WX_NATIVE", "WX_H5", "ALI_WAP", "ALI_PC", "ALI_APP", "WX_APP"];
  173. var CREATE_KEYS = ["mch_no", "out_trade_no", "amount", "subject", "way_code", "sign"];
  174. var BODY_KEYS = CREATE_KEYS.concat(["body", "return_url", "notify_url", "ext_param"]);
  175. var STOPPED_STATUS = ["2", "3", "4", "5", "6"];
  176. var STATUS_TEXT = {
  177. "0": "订单生成,等待支付",
  178. "1": "支付中",
  179. "2": "支付成功",
  180. "3": "支付失败",
  181. "4": "已撤销",
  182. "5": "已退款",
  183. "6": "订单关闭"
  184. };
  185. var POLL_INTERVAL_MS = 3000;
  186. var POLL_MAX_MS = 30 * 60 * 1000;
  187. var STORAGE_KEY = "unified_h5_order";
  188. var MODES = ["mode-loading", "mode-error", "mode-create", "mode-result"];
  189. var params = new URLSearchParams(location.search);
  190. var currentOrderNo = null;
  191. var currentToken = null;
  192. var pollTimer = null;
  193. var pollStartedAt = 0;
  194. function show(mode) {
  195. MODES.forEach(function (name) {
  196. document.getElementById(name).hidden = name !== mode;
  197. });
  198. }
  199. function setText(id, value) {
  200. var node = document.getElementById(id);
  201. if (node) {
  202. node.textContent = value == null ? "" : String(value);
  203. }
  204. }
  205. function formatAmount(raw) {
  206. var fen = Number(raw);
  207. if (raw === null || raw === "" || !isFinite(fen)) {
  208. return raw == null ? "" : String(raw);
  209. }
  210. return (fen / 100).toFixed(2) + " 元";
  211. }
  212. function isSafeHttpUrl(raw) {
  213. if (!raw) {
  214. return false;
  215. }
  216. try {
  217. var parsed = new URL(raw);
  218. return parsed.protocol === "http:" || parsed.protocol === "https:";
  219. } catch (e) {
  220. return false;
  221. }
  222. }
  223. function readStored() {
  224. try {
  225. var raw = sessionStorage.getItem(STORAGE_KEY);
  226. if (!raw) {
  227. return null;
  228. }
  229. var parsed = JSON.parse(raw);
  230. if (!parsed || !parsed.orderNo || !parsed.token) {
  231. return null;
  232. }
  233. return parsed;
  234. } catch (e) {
  235. return null;
  236. }
  237. }
  238. function writeStored(value) {
  239. try {
  240. sessionStorage.setItem(STORAGE_KEY, JSON.stringify(value));
  241. } catch (e) {
  242. /* 私密模式:读写抛异常时页面照常工作,只是刷新后不能恢复上一单 */
  243. }
  244. }
  245. function hasCreateParams() {
  246. return CREATE_KEYS.every(function (key) {
  247. var value = params.get(key);
  248. return value !== null && value.trim() !== "";
  249. });
  250. }
  251. // ────────────────────────── 建单模式 ──────────────────────────
  252. function startCreate() {
  253. setText("create-mch-no", params.get("mch_no"));
  254. setText("create-out-trade-no", params.get("out_trade_no"));
  255. setText("create-amount", formatAmount(params.get("amount")));
  256. setText("create-subject", params.get("subject"));
  257. setText("create-way-code", params.get("way_code"));
  258. show("mode-create");
  259. document.getElementById("confirm-pay").addEventListener("click", submitCreate);
  260. }
  261. function submitCreate() {
  262. var button = document.getElementById("confirm-pay");
  263. if (button.disabled) {
  264. return;
  265. }
  266. button.disabled = true;
  267. var errorBox = document.getElementById("create-error");
  268. errorBox.hidden = true;
  269. var body = {};
  270. BODY_KEYS.forEach(function (key) {
  271. var value = params.get(key);
  272. if (value !== null) {
  273. body[key] = value;
  274. }
  275. });
  276. fetch(API_BASE + "/payment/h5/unified/order", {
  277. method: "POST",
  278. headers: { "Content-Type": "application/json" },
  279. body: JSON.stringify(body),
  280. credentials:"omit"
  281. }).then(function (response) {
  282. return response.json().catch(function () {
  283. return {};
  284. }).then(function (json) {
  285. return { ok: response.ok, json: json };
  286. });
  287. }).then(function (out) {
  288. var json = out.json || {};
  289. var data = json.data || {};
  290. if (!out.ok || json.code !== 0 || !data.order_no || !data.token) {
  291. throw new Error(json.msg || "支付创建失败,请稍后重试");
  292. }
  293. writeStored({ orderNo: data.order_no, token: data.token });
  294. startResult(data.order_no, data.token, data);
  295. }).catch(function (error) {
  296. button.disabled = false;
  297. errorBox.textContent = (error && error.message) || "支付创建失败,请稍后重试";
  298. errorBox.hidden = false;
  299. });
  300. }
  301. // ────────────────────────── 结果模式 ──────────────────────────
  302. function startResult(orderNo, token, initialData) {
  303. currentOrderNo = orderNo;
  304. currentToken = token;
  305. pollStartedAt = Date.now();
  306. setText("result-order-no", orderNo);
  307. show("mode-result");
  308. hidePayNodes();
  309. if (initialData) {
  310. renderOrder(initialData);
  311. } else {
  312. setText("result-amount", "");
  313. setText("result-subject", "");
  314. setText("result-status", "查询中…");
  315. }
  316. stopPolling();
  317. if (initialData && STOPPED_STATUS.indexOf(String(initialData.trade_status)) >= 0) {
  318. return;
  319. }
  320. pollTimer = setInterval(poll, POLL_INTERVAL_MS);
  321. if (!initialData) {
  322. poll();
  323. }
  324. }
  325. function stopPolling() {
  326. if (pollTimer) {
  327. clearInterval(pollTimer);
  328. pollTimer = null;
  329. }
  330. }
  331. function poll() {
  332. if (!currentOrderNo || !currentToken) {
  333. return;
  334. }
  335. if (Date.now() - pollStartedAt > POLL_MAX_MS) {
  336. stopPolling();
  337. return;
  338. }
  339. var url = API_BASE + "/payment/h5/unified/order/"
  340. + encodeURIComponent(currentOrderNo) + "?token=" + encodeURIComponent(currentToken);
  341. fetch(url, {
  342. method: "GET",
  343. credentials:"omit"
  344. }).then(function (response) {
  345. return response.json().catch(function () {
  346. return {};
  347. }).then(function (json) {
  348. return { status: response.status, json: json };
  349. });
  350. }).then(function (out) {
  351. var json = out.json || {};
  352. // 终态的真实信号是 HTTP 200 + body code=404(全局异常处理器不带 @ResponseStatus);
  353. // HTTP 404 也照收,代理/边缘层可能改写状态码。继续轮询只会每 3 秒重复一次无效请求
  354. if (out.status === 404 || json.code === 404) {
  355. stopPolling();
  356. showResultError(json.msg || "订单不存在或链接已过期");
  357. return;
  358. }
  359. if (out.status !== 200 || json.code !== 0 || !json.data) {
  360. throw new Error(json.msg || "查询订单失败");
  361. }
  362. renderOrder(json.data);
  363. if (STOPPED_STATUS.indexOf(String(json.data.trade_status)) >= 0) {
  364. stopPolling();
  365. }
  366. }).catch(function (error) {
  367. showResultError((error && error.message) || "网络异常,稍后自动重试");
  368. });
  369. }
  370. function showResultError(message) {
  371. var errorBox = document.getElementById("result-error");
  372. errorBox.textContent = message;
  373. errorBox.hidden = false;
  374. }
  375. function renderOrder(data) {
  376. if (!data) {
  377. return;
  378. }
  379. var errorBox = document.getElementById("result-error");
  380. errorBox.hidden = true;
  381. setText("result-order-no", data.order_no);
  382. setText("result-amount", formatAmount(data.amount));
  383. setText("result-subject", data.subject);
  384. setText("result-status", STATUS_TEXT[String(data.trade_status)] || "");
  385. renderPayData(data.pay_data_type, data.pay_data);
  386. }
  387. function hidePayNodes() {
  388. document.getElementById("pay-link").hidden = true;
  389. document.getElementById("pay-image").hidden = true;
  390. document.getElementById("pay-image-hint").hidden = true;
  391. document.getElementById("code-block").hidden = true;
  392. document.getElementById("pay-other").hidden = true;
  393. }
  394. function renderPayData(type, raw) {
  395. var kind = type ? String(type) : "";
  396. var value = raw ? String(raw) : "";
  397. hidePayNodes();
  398. if ((kind === "payUrl" || kind === "payurl") && isSafeHttpUrl(value)) {
  399. var link = document.getElementById("pay-link");
  400. link.href = value;
  401. link.hidden = false;
  402. return;
  403. }
  404. if (kind === "codeImgUrl" && isSafeHttpUrl(value)) {
  405. var image = document.getElementById("pay-image");
  406. image.src = value;
  407. image.hidden = false;
  408. document.getElementById("pay-image-hint").hidden = false;
  409. return;
  410. }
  411. if (kind === "codeUrl") {
  412. setText("pay-code", value);
  413. document.getElementById("code-block").hidden = false;
  414. return;
  415. }
  416. document.getElementById("pay-other").hidden = false;
  417. }
  418. function copyText(text) {
  419. var button = document.getElementById("copy-code");
  420. var done = function () {
  421. button.textContent = "已复制";
  422. };
  423. if (navigator.clipboard && navigator.clipboard.writeText) {
  424. navigator.clipboard.writeText(text).then(done).catch(function () {
  425. fallbackCopy(text, done);
  426. });
  427. return;
  428. }
  429. fallbackCopy(text, done);
  430. }
  431. function fallbackCopy(text, done) {
  432. var area = document.createElement("textarea");
  433. area.value = text;
  434. area.setAttribute("readonly", "");
  435. area.style.position = "fixed";
  436. area.style.opacity = "0";
  437. document.body.appendChild(area);
  438. area.select();
  439. try {
  440. document.execCommand("copy");
  441. done();
  442. } catch (e) {
  443. /* 复制失败时保留原文,用户可手动选中 */
  444. }
  445. document.body.removeChild(area);
  446. }
  447. document.getElementById("copy-code").addEventListener("click", function () {
  448. copyText(document.getElementById("pay-code").textContent || "");
  449. });
  450. // ────────────────────────── 模式选择(优先级固定) ──────────────────────────
  451. var urlOrderNo = params.get("order_no");
  452. var urlToken = params.get("token");
  453. if (urlOrderNo && urlToken) {
  454. startResult(urlOrderNo, urlToken, null);
  455. } else if (hasCreateParams()) {
  456. startCreate();
  457. } else {
  458. var stored = readStored();
  459. if (stored && stored.orderNo && stored.token) {
  460. startResult(stored.orderNo, stored.token, null);
  461. } else {
  462. show("mode-error");
  463. }
  464. }
  465. })();
  466. </script>
  467. </body>
  468. </html>