重构 monorepo 并完善网页端订阅与首页体验
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var POLL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
var PAY_STEP_LABELS = ['选择支付', '扫码付款', '订阅生效'];
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 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 || 'wechat');
|
||||
var old = root.querySelector('#payStepper');
|
||||
if (old) old.outerHTML = html;
|
||||
}
|
||||
|
||||
function payEmptyStateHtml() {
|
||||
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">确认支付方式后,将在此显示二维码或跳转链接</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 monthlyPlan(items) {
|
||||
var found = null;
|
||||
(items || []).forEach(function (p) {
|
||||
if (p.code === 'member_monthly' || p.code === 'pro_monthly') found = p;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function billingSavePercent(items, plan) {
|
||||
var monthly = monthlyPlan(items);
|
||||
if (!monthly || monthly.price_cents <= 0 || !plan || plan.duration_days <= 30) return null;
|
||||
var months = plan.duration_days / 30;
|
||||
var full = monthly.price_cents * months;
|
||||
if (full <= plan.price_cents) return null;
|
||||
return Math.round((1 - plan.price_cents / full) * 100);
|
||||
}
|
||||
|
||||
function paySheetHtml(opts) {
|
||||
opts = opts || {};
|
||||
var resumeOrderNo = opts.orderNo || null;
|
||||
var channel = opts.channel || 'wechat';
|
||||
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) +
|
||||
'<section class="soon-pay-section soon-pay-section--channels" id="payChannels">' +
|
||||
'<h4 class="soon-pay-section__title">支付方式</h4>' +
|
||||
'<div class="soon-pay-channels">' +
|
||||
'<button type="button" class="soon-pay-channel' + (channel === 'wechat' ? ' is-active' : '') + '" data-ch="wechat">' +
|
||||
'<span class="soon-pay-channel__radio" aria-hidden="true"></span>' +
|
||||
'<span class="soon-pay-channel__icon soon-pay-channel__icon--wechat">微</span>' +
|
||||
'<span class="soon-pay-channel__text"><span class="soon-pay-channel__name">微信支付</span>' +
|
||||
'<span class="soon-pay-channel__hint">扫码付款</span></span></button>' +
|
||||
'<button type="button" class="soon-pay-channel' + (channel === 'alipay' ? ' is-active' : '') + '" data-ch="alipay">' +
|
||||
'<span class="soon-pay-channel__radio" aria-hidden="true"></span>' +
|
||||
'<span class="soon-pay-channel__icon soon-pay-channel__icon--alipay">支</span>' +
|
||||
'<span class="soon-pay-channel__text"><span class="soon-pay-channel__name">支付宝</span>' +
|
||||
'<span class="soon-pay-channel__hint">网页支付</span></span></button></div></section>' +
|
||||
'<section class="soon-pay-section soon-pay-section--checkout">' +
|
||||
'<div class="soon-pay-qr-panel" id="payQr">' + payEmptyStateHtml() + '</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 channel = opts.channel || 'wechat';
|
||||
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();
|
||||
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();
|
||||
return;
|
||||
}
|
||||
applyCheckout(j.data);
|
||||
}).catch(function () {
|
||||
if (confirmBtn) confirmBtn.disabled = false;
|
||||
setStatus('网络错误,请重试', 'err');
|
||||
qrEl.innerHTML = payEmptyStateHtml();
|
||||
});
|
||||
}
|
||||
|
||||
sheetRoot.querySelectorAll('.soon-pay-channel').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 html = paySheetHtml({
|
||||
planName: opts.planName,
|
||||
priceDisplay: opts.priceDisplay,
|
||||
orderNo: opts.orderNo,
|
||||
channel: opts.channel || 'wechat',
|
||||
});
|
||||
var payCtrl = null;
|
||||
var idx = layer.open({
|
||||
type: 1,
|
||||
skin: 'soon-layer',
|
||||
title: false,
|
||||
closeBtn: 1,
|
||||
shadeClose: !!opts.shadeClose,
|
||||
area: ['480px'],
|
||||
content: html,
|
||||
success: function (layero) {
|
||||
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.overflow = 'visible';
|
||||
layerContent.style.maxHeight = 'none';
|
||||
}
|
||||
}
|
||||
var sheet = layerEl.querySelector('.soon-pay-sheet');
|
||||
payCtrl = bindPaySheet(sheet, {
|
||||
planId: opts.planId,
|
||||
planName: opts.planName,
|
||||
priceDisplay: opts.priceDisplay,
|
||||
orderNo: opts.orderNo,
|
||||
channel: opts.channel || 'wechat',
|
||||
onPaid: function () {
|
||||
layer.close(idx);
|
||||
if (typeof opts.onPaid === 'function') opts.onPaid();
|
||||
},
|
||||
onBack: opts.onBack,
|
||||
});
|
||||
},
|
||||
end: function () {
|
||||
if (payCtrl) payCtrl.destroy();
|
||||
if (typeof opts.onClose === 'function') opts.onClose();
|
||||
},
|
||||
});
|
||||
return idx;
|
||||
}
|
||||
|
||||
window.SoonMemberPay = {
|
||||
apiBase: apiBase,
|
||||
apiGet: apiGet,
|
||||
apiPost: apiPost,
|
||||
esc: esc,
|
||||
monthlyPlan: monthlyPlan,
|
||||
billingSavePercent: billingSavePercent,
|
||||
paySheetHtml: paySheetHtml,
|
||||
bindPaySheet: bindPaySheet,
|
||||
openPayModal: openPayModal,
|
||||
payEmptyStateHtml: payEmptyStateHtml,
|
||||
renderPayPayload: renderPayPayload,
|
||||
submitAlipayForm: submitAlipayForm,
|
||||
POLL_TIMEOUT_MS: POLL_TIMEOUT_MS,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user