152228d41f
- 页面直接引用 local.js 设置 SOON_DEPLOY_CONFIG,移除 deploy-config - Docker sync-config 生成 local.js;更新 README 与 agent-core 说明 - 模板库自动扫描 .soon;新增后端部署/种子/排查脚本 - 完善支付配置、订阅弹窗与后台支付管理页 Co-authored-by: Cursor <cursoragent@cursor.com>
473 lines
19 KiB
JavaScript
473 lines
19 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || '/api/v1';
|
||
var currentMembership = null;
|
||
var orderState = { page: 1, size: 8, total: 0 };
|
||
|
||
var esc = typeof soonEscapeHtml === 'function' ? soonEscapeHtml : function (s) {
|
||
if (s == null) return '';
|
||
return String(s);
|
||
};
|
||
|
||
var STATUS_LABEL = {
|
||
pending: '待支付',
|
||
paid: '已支付',
|
||
cancelled: '已取消',
|
||
refunded: '已退款',
|
||
};
|
||
|
||
var CHANNEL_LABEL = { alipay: '支付宝', wechat: '微信' };
|
||
|
||
function apiFetch(url, opts) {
|
||
if (typeof soonAuthedFetch === 'function') return 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(base + p).then(function (r) {
|
||
if (!r.ok && r.status === 401) return { ok: false, message: '未登录或会话已过期' };
|
||
return parseApiJson(r);
|
||
});
|
||
}
|
||
|
||
function apiPost(p, body) {
|
||
return apiFetch(base + 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 usageMetrics(usage, quotaMb) {
|
||
var bytes = (usage && usage.storage_bytes) || 0;
|
||
var totalBytes = (quotaMb || 0) * 1024 * 1024;
|
||
var pctNum = totalBytes > 0 ? (bytes / totalBytes) * 100 : 0;
|
||
var pctLabel = pctNum > 0 && pctNum < 1 ? '<1' : String(Math.min(100, Math.round(pctNum)));
|
||
var barWidth = bytes > 0 ? Math.max(pctNum < 1 ? 0.8 : pctNum, 0.8) : 0;
|
||
return {
|
||
pctNum: pctNum,
|
||
pctLabel: pctLabel,
|
||
barWidth: Math.min(100, barWidth),
|
||
usedDisplay: (usage && usage.used_display) || '0 MB',
|
||
};
|
||
}
|
||
|
||
function showPayBanner() {
|
||
var b = document.getElementById('payBanner');
|
||
if (!b) return;
|
||
b.classList.add('is-visible');
|
||
setTimeout(function () { b.classList.remove('is-visible'); }, 5000);
|
||
}
|
||
|
||
function initLanguageSelect() {
|
||
var sel = document.getElementById('language_select');
|
||
if (!sel) return;
|
||
sel.value = localStorage.getItem('lang') || 'zh';
|
||
sel.onchange = function () { localStorage.setItem('lang', sel.value); };
|
||
}
|
||
|
||
function isPaidMember(m) {
|
||
if (!m) return false;
|
||
if (m.is_member === true) return true;
|
||
return m.tier === 'member' || m.tier === 'pro';
|
||
}
|
||
|
||
function statusPill(sub, isMember) {
|
||
if (!isMember) {
|
||
return '<span class="soon-member-status-pill soon-member-status-pill--free">免费版</span>';
|
||
}
|
||
var st = (sub && sub.status) || 'active';
|
||
var cls = 'soon-member-status-pill--active';
|
||
var text = '生效中';
|
||
if (st === 'expiring') { cls = 'soon-member-status-pill--expiring'; text = '即将到期'; }
|
||
return '<span class="soon-member-status-pill ' + cls + '">' + text + '</span>';
|
||
}
|
||
|
||
function storageRing(metrics, warn) {
|
||
var r = 52;
|
||
var c = 2 * Math.PI * r;
|
||
var offset = c - (c * Math.min(metrics.pctNum, 100) / 100);
|
||
var fillCls = warn ? ' soon-member-storage-ring__fill--warn' : '';
|
||
return '<div class="soon-member-storage-ring">' +
|
||
'<svg viewBox="0 0 120 120" aria-hidden="true">' +
|
||
'<defs>' +
|
||
'<linearGradient id="memberRingGrad" x1="0%" y1="0%" x2="100%" y2="0%">' +
|
||
'<stop offset="0%" stop-color="#00897b"/><stop offset="100%" stop-color="#4db6ac"/>' +
|
||
'</linearGradient>' +
|
||
'<linearGradient id="memberRingWarn" x1="0%" y1="0%" x2="100%" y2="0%">' +
|
||
'<stop offset="0%" stop-color="#f57c00"/><stop offset="100%" stop-color="#ffb74d"/>' +
|
||
'</linearGradient></defs>' +
|
||
'<circle class="soon-member-storage-ring__track" cx="60" cy="60" r="' + r + '"/>' +
|
||
'<circle class="soon-member-storage-ring__fill' + fillCls + '" cx="60" cy="60" r="' + r + '" ' +
|
||
'stroke-dasharray="' + c + '" stroke-dashoffset="' + offset + '"/></svg>' +
|
||
'<div class="soon-member-storage-ring__center">' +
|
||
'<span class="soon-member-storage-ring__pct">' + metrics.pctLabel + '%</span>' +
|
||
'<span class="soon-member-storage-ring__label">已使用</span></div></div>';
|
||
}
|
||
|
||
function renderMyPlan(m) {
|
||
var el = document.getElementById('myPlan');
|
||
if (!el) return;
|
||
if (!m) {
|
||
el.innerHTML = '<div class="soon-member-dash"><div class="soon-member-dash__body">' +
|
||
'<p class="soon-member-dash__desc">加载失败,请重试</p>' +
|
||
'<button type="button" class="soon-btn soon-btn--sm" id="myPlanRetry">重试</button></div></div>';
|
||
var retry = document.getElementById('myPlanRetry');
|
||
if (retry) retry.onclick = loadMembership;
|
||
return;
|
||
}
|
||
currentMembership = m;
|
||
var isMember = isPaidMember(m);
|
||
if (typeof window.soonApplyMembership === 'function') {
|
||
window.soonApplyMembership(m);
|
||
} else if (typeof window.soonLoadMembership === 'function') {
|
||
window.soonLoadMembership(true);
|
||
}
|
||
var usage = m.usage || {};
|
||
var sub = m.subscription || {};
|
||
|
||
if (!isMember) {
|
||
el.innerHTML =
|
||
'<div class="soon-member-dash">' +
|
||
'<div class="soon-member-dash__body soon-member-dash__body--guest">' +
|
||
'<div class="soon-member-dash__badge-row">' +
|
||
'<span class="soon-member-tier soon-member-tier--free">免费版</span>' + statusPill(sub, false) + '</div>' +
|
||
'<h2 class="soon-member-dash__title">' + esc(m.name || '免费版') + '</h2>' +
|
||
'<p class="soon-member-dash__desc">' + esc(m.description || '免费体验设计与编辑') + '</p>' +
|
||
'<p class="soon-member-dash__hint">订阅后可预览、打印、保存并导出作品</p>' +
|
||
'<div class="soon-member-dash__actions">' +
|
||
'<button type="button" class="soon-btn soon-btn--sm" id="myPlanGoPlans">查看订阅方案</button>' +
|
||
'</div></div></div>';
|
||
var goPlans = document.getElementById('myPlanGoPlans');
|
||
if (goPlans) {
|
||
goPlans.onclick = function () {
|
||
var sec = document.getElementById('plans');
|
||
if (sec) sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
};
|
||
}
|
||
return;
|
||
}
|
||
|
||
var quota = m.quota_mb || 2048;
|
||
var metrics = usageMetrics(usage, quota);
|
||
var warn = metrics.pctNum >= 90;
|
||
var barCls = warn ? 'soon-member-progress__bar soon-member-progress__bar--warn' : 'soon-member-progress__bar';
|
||
var expires = sub.expires_at
|
||
? esc(String(sub.expires_at).slice(0, 16).replace('T', ' '))
|
||
: '—';
|
||
var days = sub.days_remaining != null ? sub.days_remaining + ' 天' : '—';
|
||
|
||
el.innerHTML =
|
||
'<div class="soon-member-dash">' +
|
||
'<div class="soon-member-dash__body">' +
|
||
'<div class="soon-member-dash__top">' +
|
||
'<div><div class="soon-member-dash__badge-row">' +
|
||
'<span class="soon-member-tier">订阅版</span>' + statusPill(sub, true) + '</div>' +
|
||
'<h2 class="soon-member-dash__title">' + esc(m.name) + '</h2>' +
|
||
'<p class="soon-member-dash__desc">' + esc(m.description || '已解锁完整交付能力') + '</p>' +
|
||
'<p class="soon-member-dash__expire">有效期至 <strong>' + expires + '</strong> · 剩余 ' + esc(days) + '</p></div>' +
|
||
storageRing(metrics, warn) + '</div>' +
|
||
'<div class="soon-member-dash__usage">' +
|
||
'<div class="soon-member-dash__usage-head"><span>云端存储占用</span><strong>' +
|
||
esc(metrics.usedDisplay) + ' / ' + esc(m.quota_display || quota + ' MB') +
|
||
'(' + metrics.pctLabel + '%)</strong></div>' +
|
||
'<div class="soon-member-progress"><div class="' + barCls + '" style="width:' + metrics.barWidth + '%"></div></div>' +
|
||
'</div></div>' +
|
||
'<div class="soon-member-dash__stats">' +
|
||
'<div class="soon-member-dash__stat"><div class="soon-member-dash__stat-value">' + (usage.files_count || 0) +
|
||
'</div><div class="soon-member-dash__stat-label">云端文件</div></div>' +
|
||
'<div class="soon-member-dash__stat"><div class="soon-member-dash__stat-value">' + esc(days) +
|
||
'</div><div class="soon-member-dash__stat-label">剩余天数</div></div>' +
|
||
'</div></div>';
|
||
}
|
||
|
||
function orderBadge(status, refundStatus) {
|
||
if (refundStatus === 'pending') {
|
||
return '<span class="soon-member-order-badge soon-member-order-badge--pending">退款审核</span>';
|
||
}
|
||
var cls = 'soon-member-order-badge--' + (status || 'pending');
|
||
return '<span class="soon-member-order-badge ' + cls + '">' + esc(STATUS_LABEL[status] || status) + '</span>';
|
||
}
|
||
|
||
function orderPagerHtml() {
|
||
var page = orderState.page;
|
||
var size = orderState.size;
|
||
var total = orderState.total;
|
||
var pages = Math.max(1, Math.ceil(total / size));
|
||
if (total === 0) return '';
|
||
return '<div class="soon-member-pager">' +
|
||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="orderPrev"' +
|
||
(page <= 1 ? ' disabled' : '') + '>上一页</button>' +
|
||
'<span>第 ' + page + ' / ' + pages + ' 页(共 ' + total + ' 条)</span>' +
|
||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="orderNext"' +
|
||
(page >= pages ? ' disabled' : '') + '>下一页</button></div>';
|
||
}
|
||
|
||
function bindOrderPager() {
|
||
var prev = document.getElementById('orderPrev');
|
||
var next = document.getElementById('orderNext');
|
||
if (prev && !prev.disabled) {
|
||
prev.onclick = function () {
|
||
orderState.page = Math.max(1, orderState.page - 1);
|
||
loadOrders();
|
||
};
|
||
}
|
||
if (next && !next.disabled) {
|
||
next.onclick = function () {
|
||
var pages = Math.max(1, Math.ceil(orderState.total / orderState.size));
|
||
orderState.page = Math.min(pages, orderState.page + 1);
|
||
loadOrders();
|
||
};
|
||
}
|
||
}
|
||
|
||
function orderActionCell(o) {
|
||
var actions = [];
|
||
if (o.status === 'pending') {
|
||
actions.push('<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-member-order-act" ' +
|
||
'data-act="pay" data-no="' + esc(o.order_no) + '" data-plan="' + esc(o.plan_name) + '" data-price="' +
|
||
(o.amount_cents / 100).toFixed(2) + '" data-channel="' + esc(o.channel) + '">继续支付</button>');
|
||
actions.push('<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-member-order-act soon-member-order-act--muted" ' +
|
||
'data-act="cancel" data-no="' + esc(o.order_no) + '">取消</button>');
|
||
} else if (o.status === 'paid' && (!o.refund_status || o.refund_status === 'none')) {
|
||
actions.push('<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-member-order-act" ' +
|
||
'data-act="refund" data-no="' + esc(o.order_no) + '">申请退款</button>');
|
||
}
|
||
if (!actions.length) return '<span class="soon-member-order-act--empty">—</span>';
|
||
return '<div class="soon-member-order-actions">' + actions.join('') + '</div>';
|
||
}
|
||
|
||
function bindOrderActions() {
|
||
document.querySelectorAll('.soon-member-order-act[data-act]').forEach(function (btn) {
|
||
btn.onclick = function () {
|
||
var act = btn.dataset.act;
|
||
var no = btn.dataset.no;
|
||
if (act === 'pay') {
|
||
openPayModal(null, btn.dataset.plan, btn.dataset.price, {
|
||
orderNo: no,
|
||
channel: btn.dataset.channel || (window.SoonMemberPay && SoonMemberPay.defaultChannel
|
||
? SoonMemberPay.defaultChannel() : 'alipay'),
|
||
});
|
||
return;
|
||
}
|
||
if (act === 'cancel') {
|
||
layer.confirm('确定取消该待支付订单?', { skin: 'soon-layer', title: '取消订单' }, function (idx) {
|
||
apiPost('/pay/orders/' + encodeURIComponent(no) + '/cancel', {}).then(function (r) {
|
||
layer.close(idx);
|
||
if (r.ok) {
|
||
soonToast('订单已取消', 'success');
|
||
loadOrders();
|
||
} else {
|
||
soonToast(r.message || '取消失败', 'warn');
|
||
}
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
if (act === 'refund') {
|
||
layer.prompt({
|
||
skin: 'soon-layer',
|
||
title: '申请退款',
|
||
formType: 2,
|
||
value: '',
|
||
maxlength: 200,
|
||
}, function (reason, idx) {
|
||
reason = (reason || '').trim();
|
||
if (!reason) {
|
||
soonToast('请填写退款原因', 'warn');
|
||
return;
|
||
}
|
||
apiPost('/pay/orders/' + encodeURIComponent(no) + '/refund-request', { reason: reason }).then(function (r) {
|
||
layer.close(idx);
|
||
if (r.ok) {
|
||
soonToast('退款申请已提交', 'success');
|
||
loadOrders();
|
||
} else {
|
||
soonToast(r.message || '提交失败', 'warn');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
function renderOrders(orders) {
|
||
var el = document.getElementById('orderHistory');
|
||
if (!el) return;
|
||
if (!orders || !orders.length) {
|
||
el.innerHTML = '<div class="soon-member-orders-empty">暂无订单,订阅后将显示在这里</div>' +
|
||
orderPagerHtml();
|
||
bindOrderPager();
|
||
return;
|
||
}
|
||
var rows = orders.map(function (o) {
|
||
return '<tr><td>' + esc(o.order_no) + '</td><td>' + esc(o.plan_name) + '</td><td class="soon-member-orders__amount">¥' +
|
||
(o.amount_cents / 100).toFixed(2) + '</td><td>' + esc(CHANNEL_LABEL[o.channel] || o.channel) +
|
||
'</td><td>' + orderBadge(o.status, o.refund_status) + '</td><td>' +
|
||
esc(String(o.paid_at || o.created_at || '').slice(0, 16).replace('T', ' ')) + '</td><td>' +
|
||
orderActionCell(o) + '</td></tr>';
|
||
}).join('');
|
||
el.innerHTML = '<table><thead><tr><th>订单号</th><th>方案</th><th>金额</th><th>渠道</th><th>状态</th><th>时间</th><th>操作</th></tr></thead><tbody>' +
|
||
rows + '</tbody></table>' + orderPagerHtml();
|
||
bindOrderPager();
|
||
bindOrderActions();
|
||
}
|
||
|
||
function loadOrders() {
|
||
apiGet('/pay/orders?page=' + orderState.page + '&size=' + orderState.size).then(function (res) {
|
||
if (!res.ok) {
|
||
renderOrders([]);
|
||
return;
|
||
}
|
||
var data = res.data || {};
|
||
orderState.total = data.total || 0;
|
||
orderState.page = data.page || orderState.page;
|
||
orderState.size = data.size || orderState.size;
|
||
renderOrders(data.items || []);
|
||
}).catch(function () {
|
||
renderOrders([]);
|
||
});
|
||
}
|
||
|
||
function renderPlans(items) {
|
||
var grid = document.getElementById('plans');
|
||
if (!grid || !window.SoonMemberPlan) return;
|
||
var paid = items.filter(function (p) { return p.code !== 'free' && p.price_cents > 0; });
|
||
var currentCode = currentMembership && isPaidMember(currentMembership) ? currentMembership.code : '';
|
||
if (!paid.length) {
|
||
grid.innerHTML = '<div class="soon-member-orders-empty" style="grid-column:1/-1">暂无可用订阅方案</div>';
|
||
return;
|
||
}
|
||
grid.innerHTML = window.SoonMemberPlan.plansGridHtml(paid, {
|
||
allPlans: paid,
|
||
currentCode: currentCode,
|
||
isMember: isPaidMember(currentMembership),
|
||
mode: 'page',
|
||
});
|
||
grid.querySelectorAll('.soon-plan-card__cta[data-id]').forEach(function (btn) {
|
||
btn.onclick = function () {
|
||
openPayModal(Number(btn.dataset.id), btn.dataset.name, btn.dataset.price);
|
||
};
|
||
});
|
||
}
|
||
|
||
function renderPlansError(message, onRetry) {
|
||
var grid = document.getElementById('plans');
|
||
if (!grid) return;
|
||
grid.innerHTML = '<div class="soon-member-orders-empty" style="grid-column:1/-1">' +
|
||
esc(message || '方案加载失败') +
|
||
'<br><button type="button" class="soon-btn soon-btn--sm" style="margin-top:14px" id="plansRetry">重试</button></div>';
|
||
var btn = document.getElementById('plansRetry');
|
||
if (btn && onRetry) btn.onclick = onRetry;
|
||
}
|
||
|
||
function loadPlans() {
|
||
apiGet('/plans').then(function (ps) {
|
||
if (!ps.ok) {
|
||
renderPlansError(ps.message || '方案加载失败', loadPlans);
|
||
return;
|
||
}
|
||
renderPlans((ps.data && ps.data.items) || []);
|
||
}).catch(function () {
|
||
renderPlansError('网络错误,请稍后重试', loadPlans);
|
||
});
|
||
}
|
||
|
||
function openPayModal(planId, planName, priceDisplay, opts) {
|
||
opts = opts || {};
|
||
var payCore = window.SoonMemberPay;
|
||
if (!payCore || !payCore.openPayModal) {
|
||
soonToast('支付模块未加载,请刷新页面', 'warn');
|
||
return;
|
||
}
|
||
payCore.openPayModal({
|
||
planId: planId,
|
||
planName: planName,
|
||
priceDisplay: priceDisplay,
|
||
orderNo: opts.orderNo,
|
||
channel: opts.channel || (payCore.defaultChannel ? payCore.defaultChannel() : 'alipay'),
|
||
onPaid: function () {
|
||
showPayBanner();
|
||
loadMembership();
|
||
},
|
||
});
|
||
}
|
||
|
||
function loadMembership() {
|
||
apiGet('/plans/me').then(function (my) {
|
||
if (!my.ok || !my.data) {
|
||
renderMyPlan(null);
|
||
return;
|
||
}
|
||
renderMyPlan(my.data.membership || my.data);
|
||
loadOrders();
|
||
loadPlans();
|
||
}).catch(function () {
|
||
renderMyPlan(null);
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
if (!localStorage.getItem('soon_access')) {
|
||
location.href = 'login.web.html?redirect=' + encodeURIComponent('member.web.html');
|
||
return;
|
||
}
|
||
if (window.SoonPortalTopbar) {
|
||
SoonPortalTopbar.mount('#portal-topbar', { active: 'member' });
|
||
} else {
|
||
initLanguageSelect();
|
||
}
|
||
soonPortalAuth.init({ logoutReload: false });
|
||
apiGet('/auth/me').then(function (me) {
|
||
if (!me.ok) {
|
||
location.href = 'login.web.html?redirect=' + encodeURIComponent('member.web.html');
|
||
return;
|
||
}
|
||
if (me.data && me.data.role === 'admin') {
|
||
var adminEl = document.getElementById('auth_admin');
|
||
if (adminEl) adminEl.style.display = 'inline-flex';
|
||
}
|
||
loadMembership();
|
||
if (new URLSearchParams(location.search).get('paid') || sessionStorage.getItem('soon_pay_return')) {
|
||
sessionStorage.removeItem('soon_pay_return');
|
||
showPayBanner();
|
||
loadMembership();
|
||
}
|
||
}).catch(function () {
|
||
renderMyPlan(null);
|
||
renderPlansError('网络错误,请稍后重试', function () { location.reload(); });
|
||
var ordersEl = document.getElementById('orderHistory');
|
||
if (ordersEl) {
|
||
ordersEl.innerHTML = '<div class="soon-member-orders-empty">加载失败,<button type="button" class="soon-btn soon-btn--sm" id="ordersRetry">重试</button></div>';
|
||
var ordersRetry = document.getElementById('ordersRetry');
|
||
if (ordersRetry) ordersRetry.onclick = function () { location.reload(); };
|
||
}
|
||
});
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|