| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
- <meta name="referrer" content="no-referrer">
- <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'">
- <title>聚合支付</title>
- <style>
- :root {
- --bg: #f4f5f7;
- --card: #ffffff;
- --text: #1f2329;
- --muted: #6b7280;
- --line: #e5e7eb;
- --brand: #1677ff;
- --brand-press: #0f5fd6;
- --danger: #d93026;
- --radius: 14px;
- }
- * { box-sizing: border-box; }
- /* UA 的 [hidden] 会被下面 ID 选择器里的 display:block 盖过:空「去支付」链接与坏图会露出来 */
- [hidden] { display: none !important; }
- html, body { margin: 0; padding: 0; }
- body {
- min-height: 100vh;
- background: var(--bg);
- color: var(--text);
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
- "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
- font-size: 15px;
- line-height: 1.5;
- padding: env(safe-area-inset-top) 16px calc(24px + env(safe-area-inset-bottom));
- }
- main {
- max-width: 420px;
- margin: 32px auto 0;
- }
- h1 {
- font-size: 18px;
- font-weight: 600;
- margin: 0 0 16px;
- text-align: center;
- }
- .card {
- background: var(--card);
- border: 1px solid var(--line);
- border-radius: var(--radius);
- padding: 20px;
- box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
- }
- .summary { margin: 0 0 18px; }
- .summary > div {
- display: flex;
- justify-content: space-between;
- gap: 16px;
- padding: 9px 0;
- border-bottom: 1px solid var(--line);
- }
- .summary > div:last-child { border-bottom: 0; }
- dt { color: var(--muted); flex: none; }
- dd {
- margin: 0;
- text-align: right;
- word-break: break-all;
- font-variant-numeric: tabular-nums;
- }
- button {
- width: 100%;
- border: 0;
- border-radius: 10px;
- background: var(--brand);
- color: #fff;
- font-size: 16px;
- font-weight: 600;
- padding: 12px 16px;
- cursor: pointer;
- }
- button:active { background: var(--brand-press); }
- button:disabled { opacity: 0.6; cursor: default; }
- #copy-code {
- width: auto;
- margin-top: 10px;
- padding: 7px 14px;
- font-size: 13px;
- background: transparent;
- color: var(--brand);
- border: 1px solid var(--brand);
- border-radius: 8px;
- }
- #copy-code:active { background: #eef4ff; }
- .hint { margin: 12px 0 0; color: var(--muted); font-size: 13px; }
- .error { margin: 0; color: var(--danger); }
- #pay-image {
- display: block;
- width: min(240px, 100%);
- height: auto;
- margin: 4px auto 10px;
- border-radius: 8px;
- }
- #pay-image-hint { margin: 0 0 12px; text-align: center; color: var(--muted); }
- #pay-link {
- display: block;
- margin: 4px 0 12px;
- padding: 12px 16px;
- text-align: center;
- text-decoration: none;
- color: #fff;
- background: var(--brand);
- border-radius: 10px;
- font-weight: 600;
- }
- #pay-link:active { background: var(--brand-press); }
- #code-block { margin: 4px 0 12px; }
- #pay-code {
- display: block;
- padding: 10px 12px;
- background: #f8fafc;
- border: 1px solid var(--line);
- border-radius: 8px;
- font-size: 12px;
- line-height: 1.45;
- word-break: break-all;
- white-space: pre-wrap;
- }
- </style>
- </head>
- <body>
- <main>
- <h1>收银台</h1>
- <section id="mode-loading" class="card">
- <p class="hint">加载中…</p>
- </section>
- <section id="mode-error" class="card" hidden>
- <p class="error">链接不完整或已失效,请返回商户页面重新发起支付。</p>
- </section>
- <section id="mode-create" class="card" hidden>
- <dl class="summary">
- <div><dt>商户号</dt><dd id="create-mch-no"></dd></div>
- <div><dt>订单号</dt><dd id="create-out-trade-no"></dd></div>
- <div><dt>金额</dt><dd id="create-amount"></dd></div>
- <div><dt>标题</dt><dd id="create-subject"></dd></div>
- <div><dt>支付方式</dt><dd id="create-way-code"></dd></div>
- </dl>
- <button id="confirm-pay" type="button">确认支付</button>
- <p class="hint" id="create-error" hidden></p>
- </section>
- <section id="mode-result" class="card" hidden>
- <dl class="summary">
- <div><dt>订单号</dt><dd id="result-order-no"></dd></div>
- <div><dt>金额</dt><dd id="result-amount"></dd></div>
- <div><dt>标题</dt><dd id="result-subject"></dd></div>
- <div><dt>状态</dt><dd id="result-status"></dd></div>
- </dl>
- <div id="pay-panel">
- <a id="pay-link" target="_blank" rel="noopener noreferrer" hidden>去支付</a>
- <img id="pay-image" alt="支付二维码" hidden>
- <p id="pay-image-hint" hidden>请使用手机扫码支付</p>
- <div id="code-block" hidden>
- <code id="pay-code"></code>
- <button id="copy-code" type="button">复制</button>
- </div>
- <p id="pay-other" hidden>请返回商户页面完成支付</p>
- </div>
- <p class="hint" id="result-error" hidden></p>
- </section>
- </main>
- <script>
- (function () {
- "use strict";
- var API_BASE = location.origin + "/api/v1";
- var H5_WAY_CODES = ["QR_CASHIER", "ALI_QR", "WX_NATIVE", "WX_H5", "ALI_WAP", "ALI_PC", "ALI_APP", "WX_APP"];
- var CREATE_KEYS = ["mch_no", "out_trade_no", "amount", "subject", "way_code", "sign"];
- var BODY_KEYS = CREATE_KEYS.concat(["body", "return_url", "notify_url", "ext_param"]);
- var STOPPED_STATUS = ["2", "3", "4", "5", "6"];
- var STATUS_TEXT = {
- "0": "订单生成,等待支付",
- "1": "支付中",
- "2": "支付成功",
- "3": "支付失败",
- "4": "已撤销",
- "5": "已退款",
- "6": "订单关闭"
- };
- var POLL_INTERVAL_MS = 3000;
- var POLL_MAX_MS = 30 * 60 * 1000;
- var STORAGE_KEY = "unified_h5_order";
- var MODES = ["mode-loading", "mode-error", "mode-create", "mode-result"];
- var params = new URLSearchParams(location.search);
- var currentOrderNo = null;
- var currentToken = null;
- var pollTimer = null;
- var pollStartedAt = 0;
- function show(mode) {
- MODES.forEach(function (name) {
- document.getElementById(name).hidden = name !== mode;
- });
- }
- function setText(id, value) {
- var node = document.getElementById(id);
- if (node) {
- node.textContent = value == null ? "" : String(value);
- }
- }
- function formatAmount(raw) {
- var fen = Number(raw);
- if (raw === null || raw === "" || !isFinite(fen)) {
- return raw == null ? "" : String(raw);
- }
- return (fen / 100).toFixed(2) + " 元";
- }
- function isSafeHttpUrl(raw) {
- if (!raw) {
- return false;
- }
- try {
- var parsed = new URL(raw);
- return parsed.protocol === "http:" || parsed.protocol === "https:";
- } catch (e) {
- return false;
- }
- }
- function readStored() {
- try {
- var raw = sessionStorage.getItem(STORAGE_KEY);
- if (!raw) {
- return null;
- }
- var parsed = JSON.parse(raw);
- if (!parsed || !parsed.orderNo || !parsed.token) {
- return null;
- }
- return parsed;
- } catch (e) {
- return null;
- }
- }
- function writeStored(value) {
- try {
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify(value));
- } catch (e) {
- /* 私密模式:读写抛异常时页面照常工作,只是刷新后不能恢复上一单 */
- }
- }
- function hasCreateParams() {
- return CREATE_KEYS.every(function (key) {
- var value = params.get(key);
- return value !== null && value.trim() !== "";
- });
- }
- // ────────────────────────── 建单模式 ──────────────────────────
- function startCreate() {
- setText("create-mch-no", params.get("mch_no"));
- setText("create-out-trade-no", params.get("out_trade_no"));
- setText("create-amount", formatAmount(params.get("amount")));
- setText("create-subject", params.get("subject"));
- setText("create-way-code", params.get("way_code"));
- show("mode-create");
- document.getElementById("confirm-pay").addEventListener("click", submitCreate);
- }
- function submitCreate() {
- var button = document.getElementById("confirm-pay");
- if (button.disabled) {
- return;
- }
- button.disabled = true;
- var errorBox = document.getElementById("create-error");
- errorBox.hidden = true;
- var body = {};
- BODY_KEYS.forEach(function (key) {
- var value = params.get(key);
- if (value !== null) {
- body[key] = value;
- }
- });
- fetch(API_BASE + "/payment/h5/unified/order", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- credentials:"omit"
- }).then(function (response) {
- return response.json().catch(function () {
- return {};
- }).then(function (json) {
- return { ok: response.ok, json: json };
- });
- }).then(function (out) {
- var json = out.json || {};
- var data = json.data || {};
- if (!out.ok || json.code !== 0 || !data.order_no || !data.token) {
- throw new Error(json.msg || "支付创建失败,请稍后重试");
- }
- writeStored({ orderNo: data.order_no, token: data.token });
- startResult(data.order_no, data.token, data);
- }).catch(function (error) {
- button.disabled = false;
- errorBox.textContent = (error && error.message) || "支付创建失败,请稍后重试";
- errorBox.hidden = false;
- });
- }
- // ────────────────────────── 结果模式 ──────────────────────────
- function startResult(orderNo, token, initialData) {
- currentOrderNo = orderNo;
- currentToken = token;
- pollStartedAt = Date.now();
- setText("result-order-no", orderNo);
- show("mode-result");
- hidePayNodes();
- if (initialData) {
- renderOrder(initialData);
- } else {
- setText("result-amount", "");
- setText("result-subject", "");
- setText("result-status", "查询中…");
- }
- stopPolling();
- if (initialData && STOPPED_STATUS.indexOf(String(initialData.trade_status)) >= 0) {
- return;
- }
- pollTimer = setInterval(poll, POLL_INTERVAL_MS);
- if (!initialData) {
- poll();
- }
- }
- function stopPolling() {
- if (pollTimer) {
- clearInterval(pollTimer);
- pollTimer = null;
- }
- }
- function poll() {
- if (!currentOrderNo || !currentToken) {
- return;
- }
- if (Date.now() - pollStartedAt > POLL_MAX_MS) {
- stopPolling();
- return;
- }
- var url = API_BASE + "/payment/h5/unified/order/"
- + encodeURIComponent(currentOrderNo) + "?token=" + encodeURIComponent(currentToken);
- fetch(url, {
- method: "GET",
- credentials:"omit"
- }).then(function (response) {
- return response.json().catch(function () {
- return {};
- }).then(function (json) {
- return { status: response.status, json: json };
- });
- }).then(function (out) {
- var json = out.json || {};
- // 终态的真实信号是 HTTP 200 + body code=404(全局异常处理器不带 @ResponseStatus);
- // HTTP 404 也照收,代理/边缘层可能改写状态码。继续轮询只会每 3 秒重复一次无效请求
- if (out.status === 404 || json.code === 404) {
- stopPolling();
- showResultError(json.msg || "订单不存在或链接已过期");
- return;
- }
- if (out.status !== 200 || json.code !== 0 || !json.data) {
- throw new Error(json.msg || "查询订单失败");
- }
- renderOrder(json.data);
- if (STOPPED_STATUS.indexOf(String(json.data.trade_status)) >= 0) {
- stopPolling();
- }
- }).catch(function (error) {
- showResultError((error && error.message) || "网络异常,稍后自动重试");
- });
- }
- function showResultError(message) {
- var errorBox = document.getElementById("result-error");
- errorBox.textContent = message;
- errorBox.hidden = false;
- }
- function renderOrder(data) {
- if (!data) {
- return;
- }
- var errorBox = document.getElementById("result-error");
- errorBox.hidden = true;
- setText("result-order-no", data.order_no);
- setText("result-amount", formatAmount(data.amount));
- setText("result-subject", data.subject);
- setText("result-status", STATUS_TEXT[String(data.trade_status)] || "");
- renderPayData(data.pay_data_type, data.pay_data);
- }
- function hidePayNodes() {
- document.getElementById("pay-link").hidden = true;
- document.getElementById("pay-image").hidden = true;
- document.getElementById("pay-image-hint").hidden = true;
- document.getElementById("code-block").hidden = true;
- document.getElementById("pay-other").hidden = true;
- }
- function renderPayData(type, raw) {
- var kind = type ? String(type) : "";
- var value = raw ? String(raw) : "";
- hidePayNodes();
- if ((kind === "payUrl" || kind === "payurl") && isSafeHttpUrl(value)) {
- var link = document.getElementById("pay-link");
- link.href = value;
- link.hidden = false;
- return;
- }
- if (kind === "codeImgUrl" && isSafeHttpUrl(value)) {
- var image = document.getElementById("pay-image");
- image.src = value;
- image.hidden = false;
- document.getElementById("pay-image-hint").hidden = false;
- return;
- }
- if (kind === "codeUrl") {
- setText("pay-code", value);
- document.getElementById("code-block").hidden = false;
- return;
- }
- document.getElementById("pay-other").hidden = false;
- }
- function copyText(text) {
- var button = document.getElementById("copy-code");
- var done = function () {
- button.textContent = "已复制";
- };
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(text).then(done).catch(function () {
- fallbackCopy(text, done);
- });
- return;
- }
- fallbackCopy(text, done);
- }
- function fallbackCopy(text, done) {
- var area = document.createElement("textarea");
- area.value = text;
- area.setAttribute("readonly", "");
- area.style.position = "fixed";
- area.style.opacity = "0";
- document.body.appendChild(area);
- area.select();
- try {
- document.execCommand("copy");
- done();
- } catch (e) {
- /* 复制失败时保留原文,用户可手动选中 */
- }
- document.body.removeChild(area);
- }
- document.getElementById("copy-code").addEventListener("click", function () {
- copyText(document.getElementById("pay-code").textContent || "");
- });
- // ────────────────────────── 模式选择(优先级固定) ──────────────────────────
- var urlOrderNo = params.get("order_no");
- var urlToken = params.get("token");
- if (urlOrderNo && urlToken) {
- startResult(urlOrderNo, urlToken, null);
- } else if (hasCreateParams()) {
- startCreate();
- } else {
- var stored = readStored();
- if (stored && stored.orderNo && stored.token) {
- startResult(stored.orderNo, stored.token, null);
- } else {
- show("mode-error");
- }
- }
- })();
- </script>
- </body>
- </html>
|