unified-h5.html 14 KB

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