Files
SoonDesign/frontend-web/js/common/member-pay-core.js
T
24kycj 4ce4486b26 Web 端本地缓存、首页与会员弹窗体验优化
新增 IndexedDB 与最近文件 L1 缓存、云保存/打开链路;首页支持下载与清空本地缓存;修复清空 storage 后语言初始化报错;优化 design2 背景加载与侧栏标签样式;完善会员激活/支付弹层样式;后端补充文件缩略图字段与 thumb 接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 11:05:44 +08:00

547 lines
20 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;
}
}
function submitAlipayForm(payForm) {
if (!payForm || !payForm.gateway || !payForm.params) return false;
if (!isSafeAlipayGateway(payForm.gateway)) return false;
var form = document.createElement('form');
form.method = 'POST';
form.action = payForm.gateway;
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();
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) {
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 '支付网关无效';
sessionStorage.setItem('soon_pay_return', '1');
var payForm = data.pay_form;
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">点击下方按钮跳转至支付宝收银台,支付完成后将自动返回</p>' +
'<button type="button" class="soon-btn soon-pay-checkout-ready__btn" id="goAlipay">前往支付宝付款</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>' +
'<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 confirmBtn = sheetRoot.querySelector('#payConfirm');
var qrEl = sheetRoot.querySelector('#payQr');
var orderNoEl = sheetRoot.querySelector('#payOrderNo');
var backBtn = sheetRoot.querySelector('#payBack');
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 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;
}
}
function startPoll() {
stopPoll();
pollStartedAt = Date.now();
pollTimer = setInterval(function () {
if (Date.now() - pollStartedAt > POLL_TIMEOUT_MS) {
stopPoll();
setStatus('自动检测超时,如已支付请点击下方按钮', 'err');
return;
}
checkOrderStatus();
}, 3000);
}
function handlePaid() {
stopPoll();
setStep(3);
setStatus('支付成功,正在更新会员状态…', 'ok');
setTimeout(onPaid, 600);
}
function checkOrderStatus() {
if (!orderNo) return;
apiGet('/pay/orders/' + encodeURIComponent(orderNo)).then(function (st) {
if (st.ok && st.data && st.data.status === 'paid') handlePaid();
});
}
if (manualBtn) manualBtn.onclick = checkOrderStatus;
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) {
orderNo = data.order_no;
var err = renderPayPayload(data, qrEl, orderNoEl);
if (err) {
setStatus(err, 'err');
return;
}
paymentReady = true;
setStep(2);
if (confirmBtn) confirmBtn.style.display = 'none';
if (manualBtn) manualBtn.style.display = '';
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 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) {
setStatus(j.message || '下单失败', 'err');
qrEl.innerHTML = payEmptyStateHtml(channel);
return;
}
applyCheckout(j.data);
}).catch(function () {
if (confirmBtn) confirmBtn.disabled = false;
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();
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,
loadDisplayChannels: loadDisplayChannels,
displayChannels: displayChannels,
defaultChannel: defaultChannel,
resolveChannel: resolveChannel,
POLL_TIMEOUT_MS: POLL_TIMEOUT_MS,
};
})();