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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 18:17:39 +08:00

472 lines
19 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 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 || 'wechat',
});
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 || 'wechat',
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();
}
})();