Files
SoonDesign/frontend-web/js/common/member-pay-core.js
T
24kycj 4d19d7e814 优化支付宝支付流程与 Web 端体验
- 支付宝新窗口跳转,轮询与手动刷新增加加载反馈
- 支付回跳先校验订单状态,避免未付款误报成功
- 门户登录回调统一,云文件打开加载与版本冲突同步
- 精简本地 start.bat 与 README 说明

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:27:45 +08:00

647 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
'use strict';
var POLL_TIMEOUT_MS = 5 * 60 * 1000;
var PAY_STEP_LABELS = ['选择支付', '扫码付款', '激活生效'];
var _displayChannels = ['alipay'];
var _settingsPromise = null;
var CHANNEL_UI = {
alipay: { name: '支付宝', hint: '网页支付', icon: '支', iconCls: 'alipay' },
wechat: { name: '微信支付', hint: '扫码付款', icon: '微', iconCls: 'wechat' },
};
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function apiBase() {
var cfg = (typeof window !== 'undefined' && window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base);
if (cfg) return String(cfg).replace(/\/+$/, '');
if (typeof window !== 'undefined' && window.location) {
return window.location.origin + '/api/v1';
}
return '/api/v1';
}
function apiFetch(url, opts) {
if (typeof window.soonAuthedFetch === 'function') return window.soonAuthedFetch(url, opts || {});
opts = opts || {};
var token = localStorage.getItem('soon_access') || '';
opts.headers = Object.assign({}, opts.headers || {});
if (token) opts.headers.Authorization = 'Bearer ' + token;
return fetch(url, opts);
}
function parseApiJson(r) {
return r.text().then(function (text) {
try {
return JSON.parse(text);
} catch (e) {
return { ok: false, message: '服务暂时不可用,请稍后重试' };
}
});
}
function apiGet(p) {
return apiFetch(apiBase() + p).then(function (r) {
if (!r.ok && r.status === 401) return { ok: false, message: '未登录或会话已过期' };
return parseApiJson(r);
});
}
function apiPost(p, body) {
return apiFetch(apiBase() + p, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
}).then(function (r) {
if (!r.ok && r.status === 401) return { ok: false, message: '未登录或会话已过期' };
return parseApiJson(r);
});
}
function isSafeAlipayGateway(url) {
if (!url || typeof url !== 'string') return false;
try {
var u = new URL(url);
return u.protocol === 'https:'
&& (u.hostname === 'openapi.alipay.com' || u.hostname === 'openapi.alipaydev.com');
} catch (e) {
return false;
}
}
var ALIPAY_WINDOW_NAME = 'soon_alipay_pay';
function openAlipayPayWindow() {
try {
var w = window.open('about:blank', ALIPAY_WINDOW_NAME);
if (w) {
w.document.title = '支付宝收银台';
w.document.body.innerHTML =
'<p style="font-family:sans-serif;text-align:center;padding:48px 24px;color:#333">正在跳转支付宝…</p>';
}
return w;
} catch (e) {
return null;
}
}
function submitAlipayForm(payForm, payWindow) {
if (!payForm || !payForm.gateway || !payForm.params) return false;
if (!isSafeAlipayGateway(payForm.gateway)) return false;
var win = payWindow;
if (!win || win.closed) {
win = openAlipayPayWindow();
}
if (!win) return false;
var form = document.createElement('form');
form.method = 'POST';
form.action = payForm.gateway;
form.target = ALIPAY_WINDOW_NAME;
form.acceptCharset = 'UTF-8';
form.style.display = 'none';
Object.keys(payForm.params).forEach(function (key) {
var val = payForm.params[key];
if (val == null || val === '') return;
var input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = String(val);
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
setTimeout(function () {
try {
document.body.removeChild(form);
} catch (e) { /* ignore */ }
}, 0);
return true;
}
function parseDisplayChannels(raw) {
if (Array.isArray(raw)) {
var list = raw.filter(function (ch) { return ch === 'alipay' || ch === 'wechat'; });
return list.length ? list : ['alipay'];
}
var parts = String(raw || 'alipay').split(/[\s,]+/).filter(Boolean);
var out = [];
parts.forEach(function (ch) {
if ((ch === 'alipay' || ch === 'wechat') && out.indexOf(ch) < 0) out.push(ch);
});
return out.length ? out : ['alipay'];
}
function loadDisplayChannels() {
if (_settingsPromise) return _settingsPromise;
_settingsPromise = apiGet('/settings').then(function (j) {
if (j.ok && j.data) {
if (j.data.payment_display_channels) {
_displayChannels = parseDisplayChannels(j.data.payment_display_channels);
} else if (j.data['payment.display_channels'] != null) {
_displayChannels = parseDisplayChannels(j.data['payment.display_channels']);
}
}
return _displayChannels;
}).catch(function () {
_displayChannels = ['alipay'];
return _displayChannels;
});
return _settingsPromise;
}
function displayChannels() {
return _displayChannels.slice();
}
function defaultChannel(channels) {
var list = channels || _displayChannels;
return list[0] || 'alipay';
}
function resolveChannel(requested, channels) {
var list = channels || _displayChannels;
if (requested && list.indexOf(requested) >= 0) return requested;
return defaultChannel(list);
}
function channelCardHtml(ch, active, selectable) {
var ui = CHANNEL_UI[ch];
if (!ui) return '';
var cls = 'soon-pay-channel' + (active ? ' is-active' : '') + (selectable ? '' : ' is-fixed');
var inner = '<span class="soon-pay-channel__radio" aria-hidden="true"></span>' +
'<span class="soon-pay-channel__icon soon-pay-channel__icon--' + ui.iconCls + '">' + ui.icon + '</span>' +
'<span class="soon-pay-channel__text"><span class="soon-pay-channel__name">' + ui.name + '</span>' +
'<span class="soon-pay-channel__hint">' + ui.hint + '</span></span>';
if (selectable) {
return '<button type="button" class="' + cls + '" data-ch="' + ch + '">' + inner + '</button>';
}
return '<div class="' + cls + '" data-ch="' + ch + '">' + inner + '</div>';
}
function payChannelsHtml(activeChannel, channels, opts) {
opts = opts || {};
channels = channels || _displayChannels;
var showChannel = activeChannel || defaultChannel(channels);
var selectable = opts.selectable !== false && channels.length > 1;
var list = selectable ? channels : [showChannel];
var cards = list.map(function (ch) {
return channelCardHtml(ch, ch === showChannel, selectable);
}).join('');
if (!cards) return '';
return '<section class="soon-pay-section soon-pay-section--channels' +
(selectable ? '' : ' soon-pay-section--channels-fixed') + '" id="payChannels">' +
'<h4 class="soon-pay-section__title">支付方式</h4>' +
'<div class="soon-pay-channels' + (list.length === 1 ? ' soon-pay-channels--single' : '') + '">' +
cards + '</div></section>';
}
function payStepLabel(step, channel) {
if (step === 2 && channel === 'alipay') return '网页支付';
if (step === 2 && channel === 'wechat') return '扫码付款';
return PAY_STEP_LABELS[step - 1];
}
function payStepperHtml(activeStep, channel) {
var parts = [];
for (var i = 1; i <= 3; i++) {
if (i > 1) {
var bridgeCls = 'soon-pay-stepper__bridge';
if (i - 1 < activeStep) bridgeCls += ' is-done';
parts.push('<li class="' + bridgeCls + '" aria-hidden="true"></li>');
}
var cls = 'soon-pay-stepper__item';
if (i < activeStep) cls += ' is-done';
else if (i === activeStep) cls += ' is-active';
parts.push('<li class="' + cls + '">' +
'<span class="soon-pay-stepper__dot">' + (i < activeStep ? '✓' : String(i)) + '</span>' +
'<span class="soon-pay-stepper__label">' + payStepLabel(i, channel) + '</span></li>');
}
return '<ol class="soon-pay-stepper" id="payStepper">' + parts.join('') + '</ol>';
}
function updatePayStep(root, step, channel) {
if (!root) return;
var html = payStepperHtml(step, channel || defaultChannel());
var old = root.querySelector('#payStepper');
if (old) old.outerHTML = html;
}
function payEmptyStateHtml(channel) {
var ui = CHANNEL_UI[channel] || CHANNEL_UI[defaultChannel()];
return '<div class="soon-pay-checkout-empty">' +
'<div class="soon-pay-checkout-empty__icon" aria-hidden="true">' +
'<svg viewBox="0 0 48 48" fill="none"><rect x="8" y="12" width="32" height="24" rx="4" stroke="currentColor" stroke-width="1.5"/>' +
'<path d="M8 20h32" stroke="currentColor" stroke-width="1.5"/>' +
'<circle cx="32" cy="30" r="3" fill="currentColor" opacity="0.5"/></svg></div>' +
'<p class="soon-pay-checkout-empty__title">等待生成付款信息</p>' +
'<p class="soon-pay-checkout-empty__desc">点击下方按钮,将使用' + esc(ui.name) + '' + esc(ui.hint) + '</p></div>';
}
function renderPayPayload(data, qrEl, orderNoEl, launchOpts) {
launchOpts = launchOpts || {};
if (orderNoEl && data.order_no) {
orderNoEl.textContent = '订单号 ' + data.order_no;
orderNoEl.style.display = '';
}
if (data.pay_form && data.pay_form.gateway) {
if (!isSafeAlipayGateway(data.pay_form.gateway)) return '支付网关无效';
var payForm = data.pay_form;
var launched = launchOpts.launched === true;
qrEl.innerHTML =
'<div class="soon-pay-checkout-ready soon-pay-checkout-ready--alipay">' +
'<div class="soon-pay-checkout-ready__brand soon-pay-checkout-ready__brand--alipay">支</div>' +
'<p class="soon-pay-checkout-ready__title">支付宝电脑网站支付</p>' +
'<p class="soon-pay-checkout-ready__desc">' +
(launched
? '支付窗口已打开,请在新窗口完成付款;本页将自动检测支付结果'
: '未能自动打开支付窗口,请允许弹窗后点击下方按钮') +
'</p>' +
'<button type="button" class="soon-btn soon-pay-checkout-ready__btn" id="goAlipay">' +
(launched ? '重新打开支付页面' : '打开支付宝付款') +
'</button></div>';
var alipayBtn = qrEl.querySelector('#goAlipay');
if (alipayBtn) {
alipayBtn.onclick = function () {
if (!submitAlipayForm(payForm)) {
if (typeof window.soonToast === 'function') {
window.soonToast('无法打开支付窗口,请允许浏览器弹窗', 'warn');
}
}
};
}
return '';
}
if (data.qrcode) {
qrEl.innerHTML =
'<div class="soon-pay-checkout-ready">' +
'<div class="soon-pay-checkout-ready__brand soon-pay-checkout-ready__brand--wechat">微</div>' +
'<p class="soon-pay-checkout-ready__title">微信扫码支付</p>' +
'<div class="soon-pay-checkout-ready__qr" id="payQrCanvas"></div>' +
'<p class="soon-pay-checkout-ready__desc">请使用微信扫一扫,有效期约 2 小时</p></div>';
if (typeof QRCode !== 'undefined') {
new QRCode(qrEl.querySelector('#payQrCanvas'), { text: data.qrcode, width: 188, height: 188 });
}
return '';
}
return '未获取到支付信息';
}
function paySheetHtml(opts) {
opts = opts || {};
var resumeOrderNo = opts.orderNo || null;
var channels = opts.displayChannels || _displayChannels;
var channel = (resumeOrderNo && opts.channel)
? opts.channel
: resolveChannel(opts.channel, channels);
var embedded = opts.variant === 'embedded';
var backBtn = !embedded && opts.showBack
? '<button type="button" class="soon-btn soon-btn--ghost soon-btn--sm soon-pay-sheet__back" id="payBack">返回选择方案</button>'
: '';
var headBlock = embedded ? '' :
'<header class="soon-pay-sheet__head">' +
'<div class="soon-pay-sheet__head-main">' +
'<span class="soon-pay-sheet__eyebrow">' + (resumeOrderNo ? '继续支付' : '确认激活') + '</span>' +
'<h3 class="soon-pay-sheet__title">' + esc(opts.planName || '') + '</h3>' +
'<p class="soon-pay-sheet__order" id="payOrderNo"' +
(resumeOrderNo ? '' : ' style="display:none"') + '>' +
(resumeOrderNo ? '订单号 ' + esc(resumeOrderNo) : '') + '</p></div>' +
'<div class="soon-pay-sheet__head-price">' +
'<span class="soon-pay-sheet__currency">¥</span>' +
'<span class="soon-pay-sheet__amount">' + esc(opts.priceDisplay || '') + '</span></div></header>';
var orderInBody = embedded
? '<p class="soon-pay-sheet__order soon-pay-sheet__order--inline" id="payOrderNo"' +
(resumeOrderNo ? '' : ' style="display:none"') + '>' +
(resumeOrderNo ? '订单号 ' + esc(resumeOrderNo) : '') + '</p>'
: '';
return '<div class="soon-pay-sheet' + (embedded ? ' soon-pay-sheet--embedded' : '') + '">' +
(backBtn ? '<div class="soon-pay-sheet__toolbar">' + backBtn + '</div>' : '') +
headBlock +
'<div class="soon-pay-sheet__body">' +
orderInBody +
payStepperHtml(1, channel) +
payChannelsHtml(channel, channels, { selectable: !resumeOrderNo && channels.length > 1 }) +
'<section class="soon-pay-section soon-pay-section--checkout">' +
'<div class="soon-pay-qr-panel" id="payQr">' + payEmptyStateHtml(channel) + '</div></section>' +
'<div class="soon-pay-sheet__foot">' +
'<p class="soon-pay-status" id="payStatus"></p>' +
'<div class="soon-pay-poll" id="payPollHint" aria-live="polite">' +
'<span class="soon-pay-poll__spin" aria-hidden="true"></span>' +
'<span class="soon-pay-poll__text">正在检测支付结果</span></div>' +
'<button type="button" class="soon-btn soon-pay-sheet__cta" id="payConfirm">' +
(resumeOrderNo ? '继续支付' : '确认并支付') + '</button>' +
'<button type="button" class="soon-btn soon-btn--ghost soon-btn--sm soon-pay-sheet__cta soon-pay-sheet__cta--secondary" id="payManualRefresh" style="display:none">已完成支付,刷新状态</button>' +
'</div></div></div>';
}
function bindPaySheet(sheetRoot, opts) {
opts = opts || {};
var planId = opts.planId;
var resumeOrderNo = opts.orderNo || null;
var channels = opts.displayChannels || _displayChannels;
var channel = (resumeOrderNo && opts.channel)
? opts.channel
: resolveChannel(opts.channel, channels);
var onPaid = typeof opts.onPaid === 'function' ? opts.onPaid : function () {};
var pollTimer = null;
var pollStartedAt = 0;
var orderNo = resumeOrderNo;
var paymentReady = false;
if (!sheetRoot) return { destroy: function () {} };
var manualBtn = sheetRoot.querySelector('#payManualRefresh');
var statusEl = sheetRoot.querySelector('#payStatus');
var pollHint = sheetRoot.querySelector('#payPollHint');
var confirmBtn = sheetRoot.querySelector('#payConfirm');
var qrEl = sheetRoot.querySelector('#payQr');
var orderNoEl = sheetRoot.querySelector('#payOrderNo');
var backBtn = sheetRoot.querySelector('#payBack');
var checking = false;
var pollTickTimer = null;
var MANUAL_REFRESH_LABEL = '已完成支付,刷新状态';
function setStatus(text, type) {
if (!statusEl) return;
statusEl.textContent = text || '';
statusEl.className = 'soon-pay-status' + (type ? ' soon-pay-status--' + type : '');
statusEl.style.display = text ? 'block' : 'none';
}
function setPolling(active) {
sheetRoot.classList.toggle('soon-pay-sheet--polling', !!active);
if (!active && pollHint) pollHint.classList.remove('is-tick');
}
function flashPollTick() {
if (!pollHint) return;
pollHint.classList.add('is-tick');
if (pollTickTimer) clearTimeout(pollTickTimer);
pollTickTimer = setTimeout(function () {
pollHint.classList.remove('is-tick');
}, 450);
}
function setStep(step) {
updatePayStep(sheetRoot, step, channel);
sheetRoot.classList.toggle('soon-pay-sheet--checkout', step >= 2);
var chSec = sheetRoot.querySelector('#payChannels');
if (chSec) chSec.style.display = step >= 2 ? 'none' : '';
}
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
setPolling(false);
}
function startPoll() {
stopPoll();
setPolling(true);
pollStartedAt = Date.now();
checkOrderStatus(false);
pollTimer = setInterval(function () {
if (Date.now() - pollStartedAt > POLL_TIMEOUT_MS) {
stopPoll();
setStatus('自动检测超时,如已支付请点击下方按钮', 'err');
return;
}
checkOrderStatus(false);
}, 3000);
}
function handlePaid() {
stopPoll();
setStep(3);
setStatus('支付成功,正在更新会员状态…', 'ok');
setTimeout(onPaid, 600);
}
function checkOrderStatus(manual) {
if (!orderNo || checking) return;
checking = true;
if (statusEl) statusEl.classList.add('is-checking');
if (manual && manualBtn) {
manualBtn.disabled = true;
manualBtn.classList.add('is-loading');
manualBtn.textContent = '查询中…';
} else {
flashPollTick();
}
apiGet('/pay/orders/' + encodeURIComponent(orderNo)).then(function (st) {
if (st.ok && st.data && st.data.status === 'paid') {
handlePaid();
return;
}
if (manual) {
setStatus('暂未收到支付结果,请完成付款后再试', 'warn');
}
}).finally(function () {
checking = false;
if (statusEl) statusEl.classList.remove('is-checking');
if (manual && manualBtn) {
manualBtn.disabled = false;
manualBtn.classList.remove('is-loading');
manualBtn.textContent = MANUAL_REFRESH_LABEL;
}
});
}
if (manualBtn) manualBtn.onclick = function () { checkOrderStatus(true); };
function resetPaymentView() {
paymentReady = false;
stopPoll();
setStep(1);
if (manualBtn) manualBtn.style.display = 'none';
if (confirmBtn) confirmBtn.style.display = '';
qrEl.innerHTML = payEmptyStateHtml(channel);
if (orderNoEl && !resumeOrderNo) {
orderNoEl.textContent = '';
orderNoEl.style.display = 'none';
}
setStatus('');
}
function applyCheckout(data, payWindow) {
orderNo = data.order_no;
var launched = false;
if (channel === 'alipay' && data.pay_form && data.pay_form.gateway) {
launched = submitAlipayForm(data.pay_form, payWindow);
}
var err = renderPayPayload(data, qrEl, orderNoEl, { launched: launched });
if (err) {
setStatus(err, 'err');
return;
}
paymentReady = true;
setStep(2);
if (confirmBtn) confirmBtn.style.display = 'none';
if (manualBtn) manualBtn.style.display = '';
if (channel === 'alipay') {
setStatus(
launched ? '支付窗口已打开,请在新窗口完成付款' : '未能自动打开支付窗口,请允许弹窗或点击下方按钮',
launched ? 'ok' : 'err'
);
} else {
setStatus('请扫码完成支付,本页将自动检测支付结果', 'ok');
}
startPoll();
}
function submitCheckout() {
if (confirmBtn) confirmBtn.disabled = true;
setStatus('正在生成付款信息…');
qrEl.innerHTML = '<div class="soon-pay-checkout-loading"><div class="soon-skeleton soon-skeleton--h200"></div>' +
'<p>正在连接支付渠道…</p></div>';
var payWindow = channel === 'alipay' ? openAlipayPayWindow() : null;
var req;
if (resumeOrderNo || orderNo) {
var existingNo = orderNo || resumeOrderNo;
req = apiPost('/pay/orders/' + encodeURIComponent(existingNo) + '/checkout', { channel: channel });
} else {
req = apiPost('/pay/orders', { plan_id: planId, channel: channel });
}
req.then(function (j) {
if (confirmBtn) confirmBtn.disabled = false;
if (!j.ok) {
if (payWindow && !payWindow.closed) payWindow.close();
setStatus(j.message || '下单失败', 'err');
qrEl.innerHTML = payEmptyStateHtml(channel);
return;
}
applyCheckout(j.data, payWindow);
}).catch(function () {
if (confirmBtn) confirmBtn.disabled = false;
if (payWindow && !payWindow.closed) payWindow.close();
setStatus('网络错误,请重试', 'err');
qrEl.innerHTML = payEmptyStateHtml(channel);
});
}
sheetRoot.querySelectorAll('.soon-pay-channel:not(.is-fixed)').forEach(function (el) {
el.onclick = function () {
if (el.classList.contains('is-active')) return;
sheetRoot.querySelectorAll('.soon-pay-channel').forEach(function (x) { x.classList.remove('is-active'); });
el.classList.add('is-active');
channel = el.dataset.ch;
if (paymentReady) resetPaymentView();
};
});
if (confirmBtn) confirmBtn.onclick = submitCheckout;
if (backBtn && typeof opts.onBack === 'function') backBtn.onclick = opts.onBack;
function destroy() {
stopPoll();
if (pollTickTimer) {
clearTimeout(pollTickTimer);
pollTickTimer = null;
}
paymentReady = false;
orderNo = resumeOrderNo;
}
return {
destroy: destroy,
reset: resetPaymentView,
};
}
function openPayModal(opts) {
opts = opts || {};
if (typeof layer === 'undefined' || !layer.open) return null;
var channels = _displayChannels;
var channel = (opts.orderNo && opts.channel)
? opts.channel
: resolveChannel(opts.channel, channels);
var html = paySheetHtml({
planName: opts.planName,
priceDisplay: opts.priceDisplay,
orderNo: opts.orderNo,
channel: channel,
displayChannels: channels,
});
var payCtrl = null;
var width = Math.min(480, window.innerWidth - 32);
var idx = layer.open({
type: 1,
skin: 'soon-layer',
title: false,
closeBtn: 1,
fixed: true,
offset: 'auto',
shade: [0.72, '#000'],
shadeClose: !!opts.shadeClose,
maxWidth: width,
area: [width + 'px', 'auto'],
content: html,
success: function (layero, index) {
var layerEl = layero && layero[0] ? layero[0] : layero;
if (layerEl && layerEl.classList) {
layerEl.classList.add('soon-layer--pay');
var layerContent = layerEl.querySelector('.layui-layer-content');
if (layerContent) {
layerContent.style.padding = '0';
layerContent.style.overflow = 'visible';
layerContent.style.maxHeight = 'none';
layerContent.style.background = 'transparent';
}
}
var sheet = layerEl.querySelector('.soon-pay-sheet');
payCtrl = bindPaySheet(sheet, {
planId: opts.planId,
planName: opts.planName,
priceDisplay: opts.priceDisplay,
orderNo: opts.orderNo,
channel: channel,
displayChannels: channels,
onPaid: function () {
layer.close(idx);
if (typeof opts.onPaid === 'function') opts.onPaid();
},
onBack: opts.onBack,
});
requestAnimationFrame(function () {
if (typeof layer.style === 'function') layer.style(index);
});
},
end: function () {
if (payCtrl) payCtrl.destroy();
if (typeof opts.onClose === 'function') opts.onClose();
},
});
return idx;
}
loadDisplayChannels();
window.SoonMemberPay = {
apiBase: apiBase,
apiGet: apiGet,
apiPost: apiPost,
esc: esc,
paySheetHtml: paySheetHtml,
bindPaySheet: bindPaySheet,
openPayModal: openPayModal,
payEmptyStateHtml: payEmptyStateHtml,
renderPayPayload: renderPayPayload,
submitAlipayForm: submitAlipayForm,
openAlipayPayWindow: openAlipayPayWindow,
loadDisplayChannels: loadDisplayChannels,
displayChannels: displayChannels,
defaultChannel: defaultChannel,
resolveChannel: resolveChannel,
POLL_TIMEOUT_MS: POLL_TIMEOUT_MS,
};
})();