重构 monorepo 并完善网页端订阅与首页体验

- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构
- 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐
- 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作
- 新增管理后台、支付与云端文件 API,更新 README 与项目规范

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24kycj
2026-06-08 18:17:39 +08:00
parent 5814b7bc0e
commit 88c6ce8ccc
511 changed files with 189528 additions and 22804 deletions
+464
View File
@@ -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, '&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 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,
};
})();