重构 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
@@ -0,0 +1,144 @@
(function () {
'use strict';
var Api = function () { return window.AdminApi; };
var Ui = function () { return window.AdminUi; };
var I = function () { return window.AdminI18n; };
var State = function () { return window.AdminState; };
var Filter = function () { return window.AdminFilter; };
var Router = function () { return window.AdminRouter; };
var ACTION_OPTS = [
{ v: '', l: '全部操作' },
{ v: 'users.setStatus', l: '变更用户状态' },
{ v: 'users.create', l: '创建用户' },
{ v: 'users.update', l: '编辑用户' },
{ v: 'users.delete', l: '删除用户' },
{ v: 'users.setPassword', l: '重置密码' },
{ v: 'users.setSubscription', l: '设置会员' },
{ v: 'users.detail', l: '查看用户详情' },
{ v: 'orders.detail', l: '查看订单详情' },
{ v: 'orders.cancel', l: '取消订单' },
{ v: 'orders.refundApprove', l: '通过退款' },
{ v: 'orders.refundReject', l: '拒绝退款' },
{ v: 'plans.upsert', l: '编辑套餐' },
{ v: 'settings.set', l: '修改系统设置' },
{ v: 'settings.set_batch', l: '批量修改设置' },
{ v: 'payment.key.upload', l: '上传支付密钥' },
];
function buildQuery() {
var st = State().audits;
var q = 'audits?page=' + st.page + '&size=' + st.size;
if (st.q) q += '&q=' + encodeURIComponent(st.q);
if (st.from) q += '&from=' + encodeURIComponent(st.from);
if (st.to) q += '&to=' + encodeURIComponent(st.to);
if (st.action) q += '&action=' + encodeURIComponent(st.action);
return q;
}
function actionOptions(selected) {
return ACTION_OPTS.map(function (o) {
return '<option value="' + o.v + '"' + (selected === o.v ? ' selected' : '') + '>' + o.l + '</option>';
}).join('');
}
function formatContext(ctx) {
if (!ctx) return '';
var body;
try {
body = '<pre class="soon-admin-code">' + I().esc(JSON.stringify(JSON.parse(ctx), null, 2)) + '</pre>';
} catch (e) {
body = '<div class="soon-audit-item__context">' + I().esc(ctx) + '</div>';
}
return '<details class="soon-audit-item__details"><summary>查看上下文</summary>' + body + '</details>';
}
function renderFilterBar(st) {
return Filter().bar(
Filter().field('关键词', Filter().searchInput('auditQ', st.q, '操作、目标或管理员')) +
Filter().field('操作类型', Filter().select('auditAction', actionOptions(st.action))) +
Ui().filterDateField('auditFrom', '从', st.from) +
Ui().filterDateField('auditTo', '至', st.to),
'<button type="button" class="soon-btn soon-btn--sm" id="auditSearchBtn">查询</button>' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="auditResetBtn">重置</button>'
);
}
function bindFilters(st) {
var searchBtn = Ui().el('auditSearchBtn');
var resetBtn = Ui().el('auditResetBtn');
var qInput = Ui().el('auditQ');
if (searchBtn) {
searchBtn.onclick = function () {
st.q = qInput ? qInput.value.trim() : '';
st.action = Ui().el('auditAction').value;
st.from = Ui().el('auditFrom').value;
st.to = Ui().el('auditTo').value;
if (st.from && st.to && st.from > st.to) {
soonToast('开始日期不能晚于结束日期', 'warn');
return;
}
st.page = 1;
Router().syncQuery();
render();
};
}
if (qInput) {
qInput.onkeydown = function (e) {
if (e.key === 'Enter' && searchBtn) searchBtn.click();
};
}
if (resetBtn) {
resetBtn.onclick = function () {
State().audits = { page: 1, size: 50, q: '', from: '', to: '', action: '' };
Router().syncQuery();
render();
};
}
}
function renderTimeline(items) {
if (!items.length) {
return Ui().emptyBlock('暂无审计记录', '调整筛选条件或等待管理员操作产生日志', '');
}
return items.map(function (a) {
return '<div class="soon-audit-item"><div class="soon-audit-item__title">' +
I().esc(I().tAction(a.action)) + ' · ' + I().esc(a.target) + '</div>' +
'<div class="soon-audit-item__meta">' + I().esc(a.admin_email || '') + ' · ' +
I().formatDateTime(a.created_at) + '</div>' + formatContext(a.context) + '</div>';
}).join('');
}
function render() {
var panel = Ui().el('panel-audits');
if (!panel) return;
var st = State().audits;
var header = Ui().pageHeader('审计日志', '管理员操作记录,支持按操作类型与时间筛选');
panel.innerHTML = '<div class="soon-admin-page">' + header + renderFilterBar(st) + Ui().skeletonTable() + '</div>';
Api().get(buildQuery()).then(function (j) {
if (!j) {
panel.innerHTML = '<div class="soon-admin-page">' + header + renderFilterBar(st) +
Ui().listError('审计日志加载失败', 'auditsListRetry') + '</div>';
bindFilters(st);
Ui().el('auditsListRetry').onclick = render;
return;
}
var items = j.data.items || [];
panel.innerHTML = '<div class="soon-admin-page">' + header + renderFilterBar(st) +
'<div class="soon-admin-card"><div class="soon-audit-timeline">' + renderTimeline(items) + '</div></div>' +
Ui().pagerHtml(j.data.page, j.data.size, j.data.total) + '</div>';
bindFilters(st);
Ui().bindPager(panel, j.data.page, j.data.size, j.data.total, function (p, newSize) {
st.page = p;
if (newSize) st.size = newSize;
Router().syncQuery();
render();
});
});
}
window.AdminViews = window.AdminViews || {};
window.AdminViews.audits = { render: render };
})();
@@ -0,0 +1,122 @@
(function () {
'use strict';
var Api = function () { return window.AdminApi; };
var Ui = function () { return window.AdminUi; };
var I = function () { return window.AdminI18n; };
function statCard(label, value, opts) {
opts = opts || {};
var inner = '<div class="soon-admin-stat__label">' + I().esc(label) +
'</div><div class="soon-admin-stat__value">' + I().esc(value) + '</div>';
if (opts.hint) inner += '<div class="soon-admin-stat__hint">' + I().esc(opts.hint) + '</div>';
if (opts.href) {
return '<a class="soon-admin-stat soon-admin-stat--link" href="' + I().esc(opts.href) + '">' + inner + '</a>';
}
return '<div class="soon-admin-stat">' + inner + '</div>';
}
function alertBar(d) {
var parts = [];
if (d.orders_pending > 0) parts.push('待支付订单 <strong>' + d.orders_pending + '</strong> 笔');
if (d.refunds_pending > 0) parts.push('退款待审核 <strong>' + d.refunds_pending + '</strong> 笔');
if (!parts.length) return '';
var href = '#/orders';
if (d.refunds_pending > 0) href = '#/orders?refund_status=pending';
else if (d.orders_pending > 0) href = '#/orders?status=pending';
return '<div class="soon-admin-alert-bar"><span>' + parts.join(' · ') + '</span>' +
'<a href="' + href + '" class="soon-btn soon-btn--sm">去处理</a></div>';
}
function quickLinks() {
var links = [
{ href: '#/users', label: '用户管理' },
{ href: '#/orders', label: '订单管理' },
{ href: '#/plans', label: '套餐配置' },
{ href: '#/payment', label: '支付密钥' },
{ href: '#/settings', label: '系统设置' },
{ href: '#/audits', label: '审计日志' },
];
var level = (window.AdminState && AdminState.adminLevel) || 'full';
var visible = window.AdminPermissions
? AdminPermissions.filterQuickLinks(links, level)
: links;
var items = visible.map(function (l) {
return '<a class="soon-admin-quick-link" href="' + l.href + '">' + I().esc(l.label) + '</a>';
}).join('');
return '<div class="soon-admin-card"><h3 class="soon-admin-card__title">快捷入口</h3>' +
'<div class="soon-admin-quick-grid">' + items + '</div></div>';
}
function recentOrdersTable(orders) {
var rows = (orders || []).map(function (o) {
return '<tr><td>' + I().esc(o.order_no || o.id) + '</td><td>' + I().esc(o.email || '') + '</td><td>' +
I().formatCents(o.amount_cents) + '</td><td>' + I().badgeStatus(o.status) + '</td><td>' +
I().formatDateTime(o.created_at) + '</td></tr>';
}).join('');
return Ui().dataTable(['订单号', '用户', '金额', '状态', '时间'], rows, 5);
}
function render() {
var panel = Ui().el('panel-dashboard');
if (!panel) return;
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('运营概览', '关键指标、待办提醒与最近订单',
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="dashRefresh">刷新</button>') +
Ui().skeletonStats(6) +
'<div class="soon-admin-dash-grid">' +
'<div class="soon-admin-card"><h3 class="soon-admin-card__title">最近订单</h3>' + Ui().skeletonTable() + '</div>' +
'<div class="soon-skeleton soon-skeleton--h160"></div></div></div>';
function fetchStats(showLoading) {
var btn = Ui().el('dashRefresh');
if (showLoading && btn) Ui().setBtnLoading(btn, true, '刷新中…');
Api().get('stats').then(function (j) {
if (showLoading && btn) Ui().setBtnLoading(btn, false);
if (!j) {
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('运营概览', '关键指标、待办提醒与最近订单',
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="dashRefresh">刷新</button>') +
Ui().listError('概览数据加载失败', 'dashRetry') + '</div>';
Ui().el('dashRetry').onclick = render;
Ui().el('dashRefresh').onclick = function () { fetchStats(true); };
return;
}
paintDashboard(panel, j.data);
});
}
Ui().el('dashRefresh').onclick = function () { fetchStats(true); };
fetchStats(false);
}
function paintDashboard(panel, d) {
var stats =
statCard('用户总数', d.users, { href: '#/users', hint: '点击进入用户管理' }) +
statCard('订单总数', d.orders, { href: '#/orders', hint: '含全部状态' }) +
statCard('今日订单', d.orders_today, { href: '#/orders', hint: '按创建日统计' }) +
statCard('待支付', d.orders_pending || 0, { href: '#/orders?status=pending', hint: '需关注超时未付' }) +
statCard('活跃订阅', d.active_subscriptions, { href: '#/users', hint: '有效会员数' }) +
statCard('今日收入', I().formatCents(d.revenue_today_cents), { href: '#/orders', hint: '已支付订单' }) +
statCard('云端文件', d.files_total, { hint: '未删除的设计文件' }) +
statCard('退款待审', d.refunds_pending || 0, { href: '#/orders?refund_status=pending', hint: '需在订单页处理' });
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('运营概览', '关键指标、待办提醒与最近订单',
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="dashRefresh">刷新</button>' +
'<a href="#/orders" class="soon-btn soon-btn--sm">全部订单</a>') +
alertBar(d) +
'<div class="soon-admin-stat-grid soon-admin-stat-grid--dash">' + stats + '</div>' +
'<div class="soon-admin-dash-grid">' +
'<div class="soon-admin-card"><div class="soon-admin-card__head-row">' +
'<h3 class="soon-admin-card__title">最近订单</h3>' +
'<a href="#/orders" class="soon-admin-card__link">查看全部</a></div>' +
recentOrdersTable(d.recent_orders) + '</div>' +
quickLinks() + '</div></div>';
Ui().el('dashRefresh').onclick = render;
}
window.AdminViews = window.AdminViews || {};
window.AdminViews.dashboard = { render: render };
})();
@@ -0,0 +1,334 @@
(function () {
'use strict';
var Api = function () { return window.AdminApi; };
var Ui = function () { return window.AdminUi; };
var I = function () { return window.AdminI18n; };
var State = function () { return window.AdminState; };
var Filter = function () { return window.AdminFilter; };
var Menu = function () { return window.AdminMenu; };
var Router = function () { return window.AdminRouter; };
function buildQuery() {
var st = State().orders;
var q = 'orders?page=' + st.page + '&size=' + st.size;
if (st.q) q += '&q=' + encodeURIComponent(st.q);
if (st.status) q += '&status=' + encodeURIComponent(st.status);
if (st.refund_status) q += '&refund_status=' + encodeURIComponent(st.refund_status);
if (st.channel) q += '&channel=' + encodeURIComponent(st.channel);
if (st.from) q += '&from=' + encodeURIComponent(st.from);
if (st.to) q += '&to=' + encodeURIComponent(st.to);
return q;
}
function statusOptions(selected) {
var opts = [{ v: '', l: '全部状态' }, { v: 'pending', l: '待支付' }, { v: 'paid', l: '已支付' },
{ v: 'cancelled', l: '已取消' }, { v: 'refunded', l: '已退款' }];
return opts.map(function (o) {
return '<option value="' + o.v + '"' + (selected === o.v ? ' selected' : '') + '>' + o.l + '</option>';
}).join('');
}
function refundStatusOptions(selected) {
var opts = [{ v: '', l: '全部退款' }, { v: 'pending', l: '待审核' }, { v: 'none', l: '无申请' },
{ v: 'approved', l: '已通过' }, { v: 'rejected', l: '已拒绝' }];
return opts.map(function (o) {
return '<option value="' + o.v + '"' + (selected === o.v ? ' selected' : '') + '>' + o.l + '</option>';
}).join('');
}
function channelOptions(selected) {
var opts = [{ v: '', l: '全部渠道' }, { v: 'alipay', l: '支付宝' }, { v: 'wechat', l: '微信支付' }];
return opts.map(function (o) {
return '<option value="' + o.v + '"' + (selected === o.v ? ' selected' : '') + '>' + o.l + '</option>';
}).join('');
}
function renderFilterBar(st) {
return Filter().bar(
Filter().field('关键词', Filter().searchInput('orderQ', st.q, '订单号或邮箱')) +
Filter().field('状态', Filter().select('orderStatus', statusOptions(st.status))) +
Filter().field('退款', Filter().select('orderRefundStatus', refundStatusOptions(st.refund_status))) +
Filter().field('渠道', Filter().select('orderChannel', channelOptions(st.channel))) +
Ui().filterDateField('orderFrom', '从', st.from) +
Ui().filterDateField('orderTo', '至', st.to),
'<button type="button" class="soon-btn soon-btn--sm" id="orderSearchBtn">查询</button>' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="orderResetBtn">重置</button>'
);
}
function orderActionMenu(o) {
var items = [{ label: '详情', action: 'detail', dataset: { id: String(o.id) } }];
if (o.status === 'pending') {
items.push({ label: '取消', action: 'cancel', dataset: { id: String(o.id) } });
}
if (o.status === 'paid' && o.refund_status === 'pending') {
items.push({ label: '通过退款', action: 'refund_approve', dataset: { id: String(o.id) } });
items.push({ label: '拒绝退款', action: 'refund_reject', dataset: { id: String(o.id) } });
}
if (o.status === 'paid' && (o.refund_status === 'none' || !o.refund_status)) {
items.push({ label: '退款', action: 'refund_direct', dataset: { id: String(o.id) } });
}
return Menu().actionMenu('order-' + o.id, items);
}
function bindOrderMenus(root) {
Menu().bind(root, function (action, btn) {
var id = parseInt(btn.dataset.id, 10);
if (action === 'detail') showOrderDetail(id);
if (action === 'cancel') {
Ui().confirm('确认取消该待支付订单?', function () {
Api().postJson('orders/' + id + '/cancel', {}).then(function (r) {
if (r) { soonToast('订单已取消', 'success'); Ui().closeDrawer(); render(); }
});
});
}
if (action === 'refund_approve' || action === 'refund_direct') {
var title = action === 'refund_direct' ? '管理员退款' : '通过退款申请';
openNoteModal(title, '可选:填写审核备注', function (note) {
return Api().postJson('orders/' + id + '/refund/approve', { note: note }).then(function (r) {
if (r) { soonToast('退款已处理', 'success'); Ui().closeDrawer(); render(); }
});
});
}
if (action === 'refund_reject') {
openNoteModal('拒绝退款', '请填写拒绝原因', function (note) {
if (!note) {
soonToast('请填写拒绝原因', 'warn');
return false;
}
return Api().postJson('orders/' + id + '/refund/reject', { note: note }).then(function (r) {
if (r) { soonToast('已拒绝退款', 'success'); Ui().closeDrawer(); render(); }
});
});
}
});
}
function applyFilters() {
var st = State().orders;
st.q = Ui().el('orderQ').value.trim();
st.status = Ui().el('orderStatus').value;
st.refund_status = Ui().el('orderRefundStatus').value;
st.channel = Ui().el('orderChannel').value;
st.from = Ui().el('orderFrom').value;
st.to = Ui().el('orderTo').value;
if (st.from && st.to && st.from > st.to) {
soonToast('开始日期不能晚于结束日期', 'warn');
return false;
}
st.page = 1;
return true;
}
function bindFilters() {
Ui().el('orderSearchBtn').onclick = function () {
if (applyFilters()) {
Router().syncQuery();
render();
}
};
Ui().el('orderResetBtn').onclick = function () {
State().orders = {
page: 1, size: 20, q: '', status: '', refund_status: '', channel: '', from: '', to: '',
};
Router().syncQuery();
render();
};
}
function rowActions(o) {
var btns = '<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost order-detail-btn" data-id="' +
o.id + '">详情</button>';
if (o.status === 'pending') {
btns += '<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost order-cancel-btn" data-id="' +
o.id + '">取消</button>';
}
if (o.status === 'paid' && o.refund_status === 'pending') {
btns += '<button type="button" class="soon-btn soon-btn--xs order-refund-approve-btn" data-id="' + o.id +
'">通过退款</button>';
btns += '<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost order-refund-reject-btn" data-id="' +
o.id + '">拒绝</button>';
}
if (o.status === 'paid' && (o.refund_status === 'none' || !o.refund_status)) {
btns += '<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost order-refund-direct-btn" data-id="' +
o.id + '">退款</button>';
}
return '<div class="soon-admin-btn-group">' + btns + '</div>';
}
function openNoteModal(title, placeholder, onSave) {
var form = '<div class="soon-admin-form">' +
'<div class="soon-form-field"><label class="soon-form-label">备注</label>' +
'<textarea id="orderNote" class="soon-input soon-input--sm" rows="3" placeholder="' + I().esc(placeholder) +
'"></textarea></div>' +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">确认</button></div>';
Ui().openFormModal(title, form, function () {
return onSave(Ui().el('orderNote').value.trim());
});
}
function showOrderDetail(id, opts) {
opts = opts || {};
if (!opts.fromRoute) Router().openDetail(id);
Api().get('orders/' + id).then(function (j) {
if (!j) return;
var o = j.data.order || {};
var sub = j.data.subscription;
var basic = Ui().detailRow('订单号', I().esc(o.order_no)) +
Ui().detailRow('用户', I().esc(o.email || '')) +
Ui().detailRow('套餐', I().esc(o.plan_name || '-')) +
Ui().detailRow('渠道', I().esc(I().tChannel(o.channel))) +
Ui().detailRow('金额', I().formatCents(o.amount_cents)) +
Ui().detailRow('订单状态', I().badgeStatus(o.status)) +
Ui().detailRow('退款状态', I().badgeRefundStatus(o.refund_status)) +
Ui().detailRow('交易号', I().esc(o.txn_id || '-')) +
Ui().detailRow('创建时间', I().esc(I().formatDateTime(o.created_at))) +
Ui().detailRow('支付时间', I().esc(I().formatDateTime(o.paid_at))) +
Ui().detailRow('取消时间', I().esc(I().formatDateTime(o.cancelled_at))) +
Ui().detailRow('退款时间', I().esc(I().formatDateTime(o.refunded_at)));
var refundHtml = '';
if (o.refund_reason) {
refundHtml += Ui().detailRow('退款原因', I().esc(o.refund_reason));
}
if (o.refund_note) {
refundHtml += Ui().detailRow('审核备注', I().esc(o.refund_note));
}
if (!refundHtml) {
refundHtml = '<p class="soon-admin-detail-empty">无退款记录</p>';
}
var subHtml;
if (sub) {
subHtml = Ui().detailRow('订阅状态', I().badgeStatus(sub.status)) +
Ui().detailRow('开始', I().esc(I().formatDateTime(sub.started_at))) +
Ui().detailRow('到期', I().esc(I().formatDateTime(sub.expires_at)));
} else {
subHtml = '<p class="soon-admin-detail-empty">无关联订阅</p>';
}
var drawerActions = rowActions(o).replace(/<button[^>]*order-detail-btn[^>]*>[\s\S]*?<\/button>/, '');
Ui().openDrawer('订单 ' + I().esc(o.order_no),
'<div class="soon-admin-drawer-actions">' + drawerActions + '</div>' +
Ui().detailSection('订单信息', basic) +
Ui().detailSection('退款信息', refundHtml) +
Ui().detailSection('关联会员', subHtml));
var drawer = document.getElementById('adminDrawer');
if (drawer) bindRowActions(drawer);
});
}
function bindRowActions(root) {
if (!root) return;
root.querySelectorAll('.order-detail-btn').forEach(function (btn) {
btn.onclick = function () { showOrderDetail(parseInt(btn.dataset.id, 10)); };
});
root.querySelectorAll('.order-cancel-btn').forEach(function (btn) {
btn.onclick = function () {
Ui().confirm('确认取消该待支付订单?', function () {
Api().postJson('orders/' + btn.dataset.id + '/cancel', {}).then(function (r) {
if (r) {
soonToast('订单已取消', 'success');
Ui().closeDrawer();
render();
}
});
});
};
});
root.querySelectorAll('.order-refund-approve-btn, .order-refund-direct-btn').forEach(function (btn) {
btn.onclick = function () {
var isDirect = btn.classList.contains('order-refund-direct-btn');
var title = isDirect ? '管理员退款' : '通过退款申请';
openNoteModal(title, '可选:填写审核备注', function (note) {
return Api().postJson('orders/' + btn.dataset.id + '/refund/approve', { note: note }).then(function (r) {
if (r) {
layer.closeAll();
var gw = r.data && r.data.gateway;
var msg = '退款已完成';
if (gw && gw.indexOf('skipped') === 0) msg += '(未配置支付密钥,仅标记退款并撤销会员)';
else if (gw && gw.indexOf('fail') >= 0) msg += '(支付渠道退款失败,已标记退款并撤销会员)';
soonToast(msg, 'success');
Ui().closeDrawer();
render();
}
});
});
};
});
root.querySelectorAll('.order-refund-reject-btn').forEach(function (btn) {
btn.onclick = function () {
openNoteModal('拒绝退款', '请填写拒绝原因', function (note) {
if (!note) {
soonToast('请填写拒绝原因', 'warn');
return false;
}
return Api().postJson('orders/' + btn.dataset.id + '/refund/reject', { note: note }).then(function (r) {
if (r) {
layer.closeAll();
soonToast('已拒绝退款申请', 'success');
Ui().closeDrawer();
render();
}
});
});
};
});
}
function render() {
var panel = Ui().el('panel-orders');
if (!panel) return;
if (!Router().hasPendingDetail()) Ui().closeDrawer();
var st = State().orders;
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('订单管理', '支付订单查询、取消与退款审核') +
renderFilterBar(st) + Ui().skeletonTable() + '</div>';
Api().get(buildQuery()).then(function (j) {
if (!j) {
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('订单管理', '支付订单查询、取消与退款审核') +
renderFilterBar(st) + Ui().listError('订单列表加载失败', 'ordersListRetry') + '</div>';
bindFilters();
Ui().el('ordersListRetry').onclick = render;
var errDetailId = Router().consumeDetailId();
if (errDetailId) showOrderDetail(errDetailId, { fromRoute: true });
return;
}
var rows = (j.data.items || []).map(function (o) {
return '<tr><td>' + I().esc(o.order_no || o.id) + '</td><td>' + I().esc(o.email || '') + '</td><td>' +
I().esc(o.plan_name || '-') + '</td><td>' + I().esc(I().tChannel(o.channel)) + '</td><td>' +
I().formatCents(o.amount_cents) + '</td><td>' + I().badgeStatus(o.status) + '</td><td>' +
I().badgeRefundStatus(o.refund_status) + '</td><td>' + I().formatDateTime(o.created_at) + '</td><td>' +
I().formatDateTime(o.paid_at) + '</td><td>' + orderActionMenu(o) + '</td></tr>';
}).join('');
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('订单管理', '支付订单查询、取消与退款审核') +
renderFilterBar(st) +
Ui().dataTable(
['订单号', '用户', '套餐', '渠道', '金额', '状态', '退款', '创建时间', '支付时间', '操作'],
rows
) +
Ui().pagerHtml(j.data.page, j.data.size, j.data.total) + '</div>';
bindFilters();
Ui().bindPager(panel, j.data.page, j.data.size, j.data.total, function (p, newSize) {
st.page = p;
if (newSize) st.size = newSize;
Router().syncQuery();
render();
});
bindOrderMenus(panel);
var detailId = Router().consumeDetailId();
if (detailId) showOrderDetail(detailId, { fromRoute: true });
});
}
window.AdminViews = window.AdminViews || {};
window.AdminViews.orders = { render: render };
})();
@@ -0,0 +1,290 @@
(function () {
'use strict';
var Api = function () { return window.AdminApi; };
var Ui = function () { return window.AdminUi; };
var I = function () { return window.AdminI18n; };
var Form = function () { return window.AdminForm; };
var Tabs = function () { return window.AdminTabs; };
var CHANNEL_CONFIG = {
alipay: {
label: '支付宝',
kinds: [
{ kind: 'private_key', file: 'alipay_private_key.pem', label: '应用私钥 private_key' },
{ kind: 'public_key', file: 'alipay_public_key.pem', label: '支付宝公钥 public_key' },
],
},
wechat: {
label: '微信支付',
kinds: [
{ kind: 'mch_private_key', file: 'wechat_mch_private_key.pem', label: '商户私钥 mch_private_key' },
{ kind: 'api_v3_key', file: 'wechat_api_v3_key.pem', label: 'APIv3 密钥 api_v3_key' },
{ kind: 'platform_cert', file: 'wechat_platform_cert.pem', label: '平台证书 platform_cert' },
],
},
};
function fileSet(items) {
var set = {};
(items || []).forEach(function (p) { set[p.name] = p; });
return set;
}
function channelFormHtml(channel, files) {
var cfg = CHANNEL_CONFIG[channel];
var rows = cfg.kinds.map(function (k) {
var ok = !!files[k.file];
var statusCls = ok ? 'soon-admin-key-form-row__status--ok' : 'soon-admin-key-form-row__status--miss';
var statusText = ok ? '已就绪' : '缺失';
var id = channel + '_' + k.kind;
return '<div class="soon-admin-key-form-row">' +
'<div><div class="soon-admin-key-form-row__label">' + I().esc(k.label) + '</div>' +
'<div class="soon-admin-key-form-row__status ' + statusCls + '">' + statusText + '</div></div>' +
'<textarea id="' + id + '" class="soon-input soon-input--sm soon-input--textarea" rows="4" ' +
'placeholder="粘贴 PEM 内容或拖入 .pem 文件"></textarea>' +
'<button type="button" class="soon-btn soon-btn--sm pay-upload-btn" data-channel="' + channel +
'" data-kind="' + k.kind + '" data-target="' + id + '">上传</button></div>';
}).join('');
return '<div class="soon-admin-form">' + Form().panel(cfg.label + ' 密钥', rows) +
'<details class="soon-admin-hint"><summary>回调说明</summary>' +
'<p>notify<code>/api/v1/pay/' + channel + '/notify</code>(须配置 site.base_url 为 HTTPS 外网地址)</p></details>' +
'<div class="soon-admin-upload" id="payDrop_' + channel + '">拖拽 .pem 到此处自动填入对应字段</div></div>';
}
function filesTabHtml(items) {
var rows = (items || []).map(function (p) {
return '<tr><td>' + I().esc(p.name) + '</td><td>' + p.bytes + ' B</td><td>' +
I().formatDateTime(p.mtime) + '</td></tr>';
}).join('');
return Ui().dataTable(['文件名', '大小', '修改时间'], rows);
}
function uploadPem(channel, kind, content, onDone) {
return Api().fetch('payment/upload', {
method: 'POST',
body: JSON.stringify({ channel: channel, kind: kind, content: content }),
}).then(function (j) {
if (j) {
soonToast('密钥已上传', 'success');
if (onDone) onDone();
}
return j;
});
}
function bindChannel(channel, panel) {
panel.querySelectorAll('.pay-upload-btn').forEach(function (btn) {
btn.onclick = function () {
var target = Ui().el(btn.dataset.target);
var content = target ? target.value.trim() : '';
if (!content) {
soonToast('请先粘贴或拖入 PEM 内容', 'warn');
return;
}
Ui().runFormSubmit(btn, function () {
return uploadPem(btn.dataset.channel, btn.dataset.kind, content, render);
});
};
});
var drop = Ui().el('payDrop_' + channel);
if (!drop) return;
drop.ondragover = function (e) { e.preventDefault(); drop.classList.add('is-dragover'); };
drop.ondragleave = function () { drop.classList.remove('is-dragover'); };
drop.ondrop = function (e) {
e.preventDefault();
drop.classList.remove('is-dragover');
var file = e.dataTransfer.files[0];
if (!file) return;
file.text().then(function (text) {
var name = file.name.toLowerCase();
var cfg = CHANNEL_CONFIG[channel];
var matched = cfg.kinds.find(function (k) { return name.indexOf(k.kind.replace(/_/g, '')) >= 0 || name === k.file; });
var targetId = matched ? channel + '_' + matched.kind : channel + '_' + cfg.kinds[0].kind;
var ta = Ui().el(targetId);
if (ta) ta.value = text;
soonToast('已填入 ' + (matched ? matched.label : '首个字段') + ',请确认后点击上传', 'success');
});
};
}
function render() {
var panel = Ui().el('panel-payment');
if (!panel) return;
panel.innerHTML = '<div class="soon-admin-page">' + Ui().pageHeader('支付配置', '按渠道管理密钥文件,上传后立即热加载') +
Ui().skeletonTable() + '</div>';
Api().get('payment/status').then(function (j) {
if (!j) return;
var items = j.data.items || [];
var files = fileSet(items);
var tabsHtml = Tabs().html('paymentTabs', [
{ id: 'alipay', label: '支付宝' },
{ id: 'wechat', label: '微信支付' },
{ id: 'files', label: '文件清单' },
], {
alipay: channelFormHtml('alipay', files),
wechat: channelFormHtml('wechat', files),
files: filesTabHtml(items),
}, 'alipay');
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('支付配置', '按渠道管理密钥文件,上传后立即热加载') +
tabsHtml + '</div>';
var tabsRoot = Ui().el('paymentTabs');
Tabs().bind(tabsRoot);
bindChannel('alipay', panel);
bindChannel('wechat', panel);
});
}
window.AdminViews = window.AdminViews || {};
window.AdminViews.payment = { render: render };
})();
@@ -0,0 +1,253 @@
(function () {
'use strict';
var Api = function () { return window.AdminApi; };
var Ui = function () { return window.AdminUi; };
var I = function () { return window.AdminI18n; };
var Form = function () { return window.AdminForm; };
function activeBadge(v) {
var on = v === true || parseInt(v, 10) === 1;
return I().badgeStatus(on ? 'active' : 'disabled');
}
function recBadge(v) {
if (!v) return '—';
return '<span class="soon-badge soon-badge--paid">推荐</span>';
}
function isFreePlan(plan) {
return plan && plan.code === 'free';
}
function isPaidMemberPlan(plan) {
return plan && plan.code !== 'free' && plan.price_cents > 0;
}
function editableFeatures(plan) {
if (plan.features_editable && plan.features_editable.length) {
return plan.features_editable.slice();
}
var lines = plan.features || [];
return lines.filter(function (l) {
return l.indexOf('订阅有效期') < 0
&& l.indexOf('订阅周期') < 0
&& l.indexOf('到期') < 0;
});
}
function featureRowsHtml(lines) {
var items = lines && lines.length ? lines : [''];
return items.map(function (text) {
return '<div class="soon-admin-feature-row">' +
'<input type="text" class="soon-input soon-input--sm soon-admin-feature-input" value="' +
I().esc(text) + '" placeholder="例如:预览成品效果">' +
'<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-admin-feature-del" title="删除">×</button></div>';
}).join('');
}
function bindFeatureEditor() {
var list = document.getElementById('peFeatures');
var addBtn = document.getElementById('peFeatureAdd');
if (!list) return;
function bindDel(btn) {
if (!btn || btn.dataset.bound) return;
btn.dataset.bound = '1';
btn.onclick = function () {
var rows = list.querySelectorAll('.soon-admin-feature-row');
if (rows.length <= 1) {
var inp = list.querySelector('.soon-admin-feature-input');
if (inp) inp.value = '';
return;
}
var row = btn.closest('.soon-admin-feature-row');
if (row) row.remove();
};
}
list.querySelectorAll('.soon-admin-feature-del').forEach(bindDel);
if (addBtn && !addBtn.dataset.bound) {
addBtn.dataset.bound = '1';
addBtn.onclick = function () {
var row = document.createElement('div');
row.className = 'soon-admin-feature-row';
row.innerHTML =
'<input type="text" class="soon-input soon-input--sm soon-admin-feature-input" placeholder="例如:预览成品效果">' +
'<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-admin-feature-del" title="删除">×</button>';
list.appendChild(row);
bindDel(row.querySelector('.soon-admin-feature-del'));
row.querySelector('.soon-admin-feature-input').focus();
};
}
}
function collectFeatures() {
var lines = [];
document.querySelectorAll('#peFeatures .soon-admin-feature-input').forEach(function (inp) {
var t = inp.value.trim();
if (t) lines.push(t);
});
return lines;
}
function periodHint(plan) {
if (isFreePlan(plan)) return '';
var days = parseInt(Ui().el('peDays') && Ui().el('peDays').value, 10);
if (!days || days <= 0) days = plan.duration_days || 0;
if (days <= 0) return '付费方案保存后将自动追加订阅周期说明。';
var label = days >= 365 ? '1 年' : (days === 90 ? '1 季' : (days >= 30 && days % 30 === 0 ? (days / 30) + ' 个月' : days + ' 天'));
return '保存后将自动追加订阅周期说明(' + label + '),用户端卡片中不展示该行';
}
function openEditModal(plan) {
var free = isFreePlan(plan);
var paid = isPaidMemberPlan(plan);
var features = editableFeatures(plan);
var basicPanel = Form().panel('基本信息',
Form().field('代码', '<input class="soon-input soon-input--sm" value="' + I().esc(plan.code) + '" disabled>') +
Form().field('名称', Form().input('peName', { value: plan.name })) +
Form().field('简介', Form().input('peDesc', { value: plan.description || '' }))
);
var pricePanel = Form().panel('价格与周期',
'<div class="soon-admin-form-grid">' +
Form().field('价格(元)',
Form().input('pePriceYuan', {
type: 'number',
value: (plan.price_cents / 100).toFixed(2),
disabled: free,
})) +
Form().field('有效天数',
Form().input('peDays', { type: 'number', value: plan.duration_days }),
free ? '免费版填 0' : '月付 30 / 季付 90 / 年付 365') +
Form().field('排序',
Form().input('peSort', { type: 'number', value: plan.sort_order })) +
'</div>' +
'<div class="soon-admin-form-checks">' +
'<label class="soon-admin-check"><input type="checkbox" id="peRec"' +
(plan.is_recommended ? ' checked' : '') + (free ? ' disabled' : '') + '> 设为推荐</label>' +
'<label class="soon-admin-check"><input type="checkbox" id="peActive"' +
(plan.is_active ? ' checked' : '') + '> 上架展示</label>' +
(paid ? '<label class="soon-admin-check"><input type="checkbox" id="peSync" checked> 应用到全部付费方案</label>' : '') +
'</div>'
);
var quotaPanel = Form().panel('云端配额',
'<div class="soon-admin-form-grid">' +
Form().field('存储(MB', Form().input('peQuota', { type: 'number', value: plan.quota_mb })) +
Form().field('文件数上限', Form().input('peMaxFiles', { type: 'number', value: plan.max_files })) +
'</div>',
'云端存储与文件数量上限;预览/保存/导出仍受订阅状态约束。'
);
var benefitTitle = free ? '免费版展示' : '订阅包含';
var benefitHint = free
? '展示在会员中心免费版区域;补充说明写在简介中。'
: periodHint(plan);
var benefitPanel = Form().panel(benefitTitle,
'<div class="soon-admin-feature-list" id="peFeatures">' + featureRowsHtml(features) + '</div>' +
'<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost" id="peFeatureAdd">+ 添加权益</button>' +
(benefitHint ? '<p class="soon-admin-form-hint soon-admin-form-hint--tight">' + I().esc(benefitHint) + '</p>' : '')
);
var form = '<div class="soon-admin-form soon-admin-form--plan">' +
basicPanel + pricePanel + quotaPanel + benefitPanel +
Form().actions('保存', 'adminModalSave') +
'</div>';
Ui().openFormModal((free ? '免费版' : '订阅方案') + ' · ' + plan.name, form, function () {
var name = Ui().el('peName').value.trim();
if (!name) {
soonToast('名称不能为空', 'warn');
return false;
}
var featureLines = collectFeatures();
if (!featureLines.length) {
soonToast('请至少填写一条权益', 'warn');
return false;
}
var yuan = free ? 0 : (parseFloat(Ui().el('pePriceYuan').value) || 0);
var body = {
code: plan.code,
name: name,
description: Ui().el('peDesc').value.trim(),
price_cents: Math.round(yuan * 100),
quota_mb: parseInt(Ui().el('peQuota').value, 10) || 0,
max_files: parseInt(Ui().el('peMaxFiles').value, 10) || 0,
duration_days: parseInt(Ui().el('peDays').value, 10) || 0,
sort_order: parseInt(Ui().el('peSort').value, 10) || 0,
is_recommended: free ? 0 : (Ui().el('peRec').checked ? 1 : 0),
is_active: Ui().el('peActive').checked ? 1 : 0,
features: featureLines,
sync_member_benefits: paid && Ui().el('peSync') && Ui().el('peSync').checked ? 1 : 0,
};
return Api().postJson('plans', body).then(function (r) {
if (r) {
layer.closeAll();
soonToast('已保存', 'success');
render();
}
});
}, {
area: ['640px', '72vh'],
success: function () {
bindFeatureEditor();
var daysEl = Ui().el('peDays');
var hintEl = document.querySelector('.soon-admin-form-hint--tight');
if (daysEl && hintEl && paid) {
daysEl.oninput = function () {
hintEl.textContent = periodHint(plan);
};
}
},
});
}
function featureSummary(plan) {
var n = editableFeatures(plan).length;
return n > 0 ? n + ' 条' : '—';
}
function render() {
var panel = Ui().el('panel-plans');
if (!panel) return;
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('订阅方案', '配置价格、周期、配额与用户端展示文案') +
Ui().skeletonTable() + '</div>';
Api().get('plans').then(function (j) {
if (!j) return;
var items = j.data.items || [];
var rows = items.map(function (p) {
return '<tr data-code="' + I().esc(p.code) + '"><td>' + I().esc(p.code) + '</td><td>' + I().esc(p.name) +
'</td><td>' + I().formatCents(p.price_cents) + '</td><td>' + featureSummary(p) + '</td><td>' +
p.quota_mb + ' MB</td><td>' + (p.duration_days > 0 ? p.duration_days + ' 天' : '永久') + '</td><td>' +
recBadge(p.is_recommended) + '</td><td>' + activeBadge(p.is_active) +
'</td><td><button type="button" class="soon-btn soon-btn--sm soon-btn--ghost plan-edit">编辑</button></td></tr>';
}).join('');
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('订阅方案', '配置价格、周期、配额与用户端展示文案') +
Ui().dataTable(
['代码', '名称', '价格', '权益', '存储', '有效期', '推荐', '状态', '操作'],
rows
) + '</div>';
var planMap = {};
items.forEach(function (p) { planMap[p.code] = p; });
panel.querySelectorAll('.plan-edit').forEach(function (btn) {
btn.onclick = function () {
var code = btn.closest('tr').dataset.code;
if (planMap[code]) openEditModal(planMap[code]);
};
});
});
}
window.AdminViews = window.AdminViews || {};
window.AdminViews.plans = { render: render };
})();
@@ -0,0 +1,191 @@
(function () {
'use strict';
var Api = function () { return window.AdminApi; };
var Ui = function () { return window.AdminUi; };
var I = function () { return window.AdminI18n; };
var Form = function () { return window.AdminForm; };
var Tabs = function () { return window.AdminTabs; };
var Filter = function () { return window.AdminFilter; };
var State = function () { return window.AdminState; };
var Router = function () { return window.AdminRouter; };
var SETTING_LABELS = {
'site.base_url': 'API 站点外网地址',
'site.front_base_url': '前端站点地址',
};
var valuesByKey = {};
var allItems = [];
function siteFields() {
var cfg = window.SOON_DEPLOY_CONFIG || {};
var apiBase = String(cfg.api_v1_base || '').replace(/\/api\/v1\/?$/, '');
var frontBase = cfg.front_base_url || cfg.web_base || '';
return [
{
key: 'site.base_url',
label: 'API 站点外网地址',
hint: '须 HTTPS 外网可达;支付宝/微信 notify 回调依赖此地址',
placeholder: apiBase,
},
{
key: 'site.front_base_url',
label: '前端站点地址',
hint: '支付完成 return_url 跳转至会员页',
placeholder: frontBase,
},
];
}
function settingLabel(key) {
if (SETTING_LABELS[key]) {
return I().esc(key) + '<br><span class="soon-admin-setting-hint">' + I().esc(SETTING_LABELS[key]) + '</span>';
}
return I().esc(key);
}
function maskValue(key, val) {
if (/secret|password|key/i.test(key) && val) return '******';
return I().esc(val);
}
function siteFormHtml() {
var fields = siteFields().map(function (f) {
var val = valuesByKey[f.key] != null ? valuesByKey[f.key] : '';
return Form().field(f.label,
Form().input('cfg_' + f.key.replace(/\./g, '_'), { value: val, placeholder: f.placeholder }),
f.hint);
}).join('');
return '<div class="soon-admin-form">' + Form().panel('站点与回调地址', fields) +
Form().actions('保存站点配置', 'settingsSiteSave') + '</div>';
}
function allSettingsHtml(filterQ) {
var q = (filterQ || '').toLowerCase();
var items = allItems.filter(function (s) {
if (!q) return true;
return String(s.key).toLowerCase().indexOf(q) >= 0 ||
String(s.value || '').toLowerCase().indexOf(q) >= 0;
});
var rows = items.map(function (s) {
return '<tr><td>' + settingLabel(s.key) + '</td><td>' + maskValue(s.key, s.value) + '</td><td>' +
I().formatDateTime(s.updated_at) + '</td><td>' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost setting-edit" data-key="' +
I().esc(s.key) + '">编辑</button></td></tr>';
}).join('');
var filter = Filter().bar(
Filter().field('搜索', Filter().searchInput('settingsSearchQ', filterQ, '按 key 或 value 搜索')),
'<button type="button" class="soon-btn soon-btn--sm" id="settingsSearchBtn">搜索</button>' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="settingsSearchReset">重置</button>'
);
return filter + Ui().dataTable(['配置项', '当前值', '更新时间', '操作'], rows);
}
function bindSiteForm() {
Ui().bindFormSubmit('settingsSiteSave', function () {
var items = siteFields().map(function (f) {
var el = Ui().el('cfg_' + f.key.replace(/\./g, '_'));
return { key: f.key, value: el ? el.value.trim() : '' };
});
return Api().postJson('settings/batch', { items: items }).then(function (r) {
if (r) {
soonToast('站点配置已保存', 'success');
render();
}
});
});
}
function bindAllSettings() {
var st = State().settings;
var searchBtn = Ui().el('settingsSearchBtn');
var resetBtn = Ui().el('settingsSearchReset');
if (searchBtn) {
searchBtn.onclick = function () {
st.q = Ui().el('settingsSearchQ').value.trim();
st.tab = 'all';
Router().syncQuery();
render();
};
}
if (resetBtn) {
resetBtn.onclick = function () {
st.q = '';
st.tab = 'all';
Router().syncQuery();
render();
};
}
var searchInput = Ui().el('settingsSearchQ');
if (searchInput) {
searchInput.onkeydown = function (e) {
if (e.key === 'Enter' && searchBtn) searchBtn.click();
};
}
document.querySelectorAll('#panel-settings .setting-edit').forEach(function (btn) {
btn.onclick = function () {
var key = btn.dataset.key;
var val = valuesByKey[key] != null ? valuesByKey[key] : '';
var form = '<div class="soon-admin-form">' +
Form().field('配置键', '<input class="soon-input soon-input--sm" value="' + I().esc(key) + '" disabled>') +
Form().field('配置值', Form().input('settingVal', { value: val, type: 'textarea', rows: 5 })) +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">保存</button></div>';
Ui().openFormModal('修改 ' + key, form, function () {
Ui().confirm('确认保存该配置?', function () {
var saveBtn = Ui().el('adminModalSave');
Ui().setBtnLoading(saveBtn, true);
Api().postJson('settings', { key: key, value: Ui().el('settingVal').value }).then(function (r) {
if (r) {
layer.closeAll();
soonToast('配置已保存', 'success');
render();
}
}).finally(function () {
Ui().setBtnLoading(saveBtn, false);
});
});
});
};
});
}
function render() {
var panel = Ui().el('panel-settings');
if (!panel) return;
var st = State().settings;
var header = Ui().pageHeader('系统设置', '站点地址与运行时参数;修改后即时生效');
panel.innerHTML = '<div class="soon-admin-page">' + header + Ui().skeletonTable() + '</div>';
Api().get('settings').then(function (j) {
if (!j) {
panel.innerHTML = '<div class="soon-admin-page">' + header +
Ui().listError('设置加载失败', 'settingsRetry') + '</div>';
Ui().el('settingsRetry').onclick = render;
return;
}
valuesByKey = {};
allItems = j.data.items || [];
allItems.forEach(function (s) { valuesByKey[s.key] = s.value; });
var tabsHtml = Tabs().html('settingsTabs', [
{ id: 'site', label: '站点配置' },
{ id: 'all', label: '全部配置' },
], {
site: siteFormHtml(),
all: allSettingsHtml(st.q),
}, st.tab);
panel.innerHTML = '<div class="soon-admin-page">' + header + tabsHtml + '</div>';
Tabs().bind(Ui().el('settingsTabs'), function (tabId) {
st.tab = tabId;
Router().syncQuery();
});
bindSiteForm();
bindAllSettings();
});
}
window.AdminViews = window.AdminViews || {};
window.AdminViews.settings = { render: render };
})();
@@ -0,0 +1,417 @@
(function () {
'use strict';
var Api = function () { return window.AdminApi; };
var Ui = function () { return window.AdminUi; };
var I = function () { return window.AdminI18n; };
var State = function () { return window.AdminState; };
var Filter = function () { return window.AdminFilter; };
var Menu = function () { return window.AdminMenu; };
var Router = function () { return window.AdminRouter; };
function buildQuery() {
var st = State().users;
var q = 'users?page=' + st.page + '&size=' + st.size;
if (st.q) q += '&q=' + encodeURIComponent(st.q);
return q;
}
function formField(label, inner) {
return '<div class="soon-form-field"><label class="soon-form-label">' + label + '</label>' + inner + '</div>';
}
function roleOptions(selected) {
var roles = ['user'];
if (State().adminLevel === 'full') roles.push('admin');
return roles.map(function (r) {
return '<option value="' + r + '"' + (selected === r ? ' selected' : '') + '>' + I().tRole(r) + '</option>';
}).join('');
}
function adminLevelOptions(selected) {
return [
{ v: 'full', l: '超级管理员' },
{ v: 'ops', l: '运营管理员' },
].map(function (o) {
return '<option value="' + o.v + '"' + (selected === o.v ? ' selected' : '') + '>' + o.l + '</option>';
}).join('');
}
function bindAdminLevelField() {
var roleEl = Ui().el('ueRole') || Ui().el('ucRole');
var wrap = Ui().el('adminLevelField');
if (!roleEl || !wrap) return;
function sync() {
wrap.style.display = roleEl.value === 'admin' ? '' : 'none';
}
roleEl.onchange = sync;
sync();
}
function showCreateModal() {
var form = '<div class="soon-admin-form">' +
formField('邮箱', '<input id="ucEmail" type="email" class="soon-input soon-input--sm" autocomplete="off">') +
formField('密码', '<input id="ucPwd" type="password" class="soon-input soon-input--sm" autocomplete="new-password" placeholder="至少 8 位">') +
formField('角色', '<select id="ucRole" class="soon-input soon-input--sm">' + roleOptions('user') + '</select>') +
(State().adminLevel === 'full'
? formField('管理级别', '<div id="adminLevelField" style="display:none"><select id="ucAdminLevel" class="soon-input soon-input--sm">' +
adminLevelOptions('ops') + '</select></div>', '运营管理员不可访问设置、审计与支付密钥')
: '') +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">创建</button></div>';
Ui().openFormModal('新增用户', form, function () {
var email = Ui().el('ucEmail').value.trim();
var pwd = Ui().el('ucPwd').value;
if (!email || pwd.length < 8) {
soonToast('请填写有效邮箱与至少 8 位密码', 'warn');
return false;
}
var body = {
email: email,
password: pwd,
role: Ui().el('ucRole').value,
};
if (body.role === 'admin') {
var lv = Ui().el('ucAdminLevel');
body.admin_level = lv ? lv.value : 'ops';
}
return Api().postJson('users', body).then(function (r) {
if (r) {
layer.closeAll();
soonToast('用户已创建', 'success');
render();
}
});
}, {
success: function () { bindAdminLevelField(); },
});
}
function showEditModal(user) {
var level = user.admin_level || 'full';
var form = '<div class="soon-admin-form">' +
formField('邮箱', '<input id="ueEmail" type="email" class="soon-input soon-input--sm" value="' + I().esc(user.email) + '">') +
formField('角色', '<select id="ueRole" class="soon-input soon-input--sm">' + roleOptions(user.role) + '</select>') +
(State().adminLevel === 'full'
? formField('管理级别', '<div id="adminLevelField"><select id="ueAdminLevel" class="soon-input soon-input--sm">' +
adminLevelOptions(level) + '</select></div>', '仅对管理员账号生效')
: '') +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">保存</button></div>';
Ui().openFormModal('编辑用户 #' + user.id, form, function () {
var body = {
email: Ui().el('ueEmail').value.trim(),
role: Ui().el('ueRole').value,
};
if (body.role === 'admin') {
var lv = Ui().el('ueAdminLevel');
if (lv) body.admin_level = lv.value;
}
return Api().putJson('users/' + user.id, body).then(function (r) {
if (r) {
layer.closeAll();
soonToast('用户信息已更新', 'success');
render();
}
});
}, {
success: function () { bindAdminLevelField(); },
});
}
function showPasswordModal(userId) {
var form = '<div class="soon-admin-form">' +
formField('新密码', '<input id="upPwd" type="password" class="soon-input soon-input--sm" autocomplete="new-password" placeholder="至少 8 位">') +
formField('确认密码', '<input id="upPwd2" type="password" class="soon-input soon-input--sm" autocomplete="new-password">') +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">重置密码</button></div>';
Ui().openFormModal('重置密码', form, function () {
var p1 = Ui().el('upPwd').value;
var p2 = Ui().el('upPwd2').value;
if (p1.length < 8) {
soonToast('密码至少 8 位', 'warn');
return false;
}
if (p1 !== p2) {
soonToast('两次密码不一致', 'warn');
return false;
}
return Api().postJson('users/' + userId + '/password', { password: p1 }).then(function (r) {
if (r) {
layer.closeAll();
soonToast('密码已重置', 'success');
}
});
});
}
function showSubscriptionModal(userId) {
Api().get('plans').then(function (j) {
if (!j) return;
var opts = (j.data.items || []).map(function (p) {
return '<option value="' + p.id + '" data-days="' + p.duration_days + '">' +
I().esc(p.name) + ' (' + p.duration_days + ' 天)</option>';
}).join('');
var form = '<div class="soon-admin-form">' +
formField('套餐', '<select id="usPlan" class="soon-input soon-input--sm">' + opts + '</select>') +
formField('有效天数', '<input id="usDays" type="number" min="0" class="soon-input soon-input--sm" placeholder="留空则用套餐默认天数">') +
'<p class="soon-admin-form-hint">选择免费版将取消当前有效会员。</p>' +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">保存</button></div>';
Ui().openFormModal('设置会员', form, function () {
var planSel = Ui().el('usPlan');
var daysVal = Ui().el('usDays').value.trim();
var body = { plan_id: parseInt(planSel.value, 10) };
if (daysVal) body.days = parseInt(daysVal, 10);
return Api().postJson('users/' + userId + '/subscription', body).then(function (r) {
if (r) {
layer.closeAll();
soonToast('会员已更新', 'success');
render();
}
});
});
var planEl = Ui().el('usPlan');
if (planEl) {
planEl.onchange = function () {
var opt = planEl.options[planEl.selectedIndex];
Ui().el('usDays').placeholder = '默认 ' + (opt.getAttribute('data-days') || '0') + ' 天';
};
planEl.onchange();
}
});
}
function showUserDetail(id, opts) {
opts = opts || {};
if (!opts.fromRoute) Router().openDetail(id);
Api().get('users/' + id).then(function (j) {
if (!j) return;
var d = j.data;
var u = d.user || {};
var sub = d.subscription;
var usage = d.usage || {};
var adminId = State().currentAdminId;
var levelLabel = u.role === 'admin'
? (u.admin_level === 'ops' ? '运营管理员' : '超级管理员')
: '—';
var account = Ui().detailRow('邮箱', I().esc(u.email)) +
Ui().detailRow('角色', I().esc(I().tRole(u.role))) +
(u.role === 'admin' ? Ui().detailRow('管理级别', I().esc(levelLabel)) : '') +
Ui().detailRow('状态', I().badgeStatus(u.status)) +
Ui().detailRow('注册时间', I().esc(I().formatDateTime(u.created_at)));
var subHtml;
if (sub && sub.plan_name) {
subHtml = Ui().detailRow('套餐', I().esc(sub.plan_name)) +
Ui().detailRow('状态', I().badgeStatus(sub.status)) +
Ui().detailRow('开始时间', I().esc(I().formatDateTime(sub.started_at))) +
Ui().detailRow('到期时间', I().esc(I().formatDateTime(sub.expires_at)));
} else {
subHtml = '<p class="soon-admin-detail-empty">免费版 / 无有效订阅</p>';
}
var usageHtml = Ui().detailRow('文件数', I().esc(usage.files_count || 0)) +
Ui().detailRow('占用空间', I().esc(I().formatBytes(usage.storage_bytes)));
var orderRows = (d.recent_orders || []).map(function (o) {
return '<tr><td>' + I().esc(o.order_no || o.id) + '</td><td>' + I().formatCents(o.amount_cents) +
'</td><td>' + I().badgeStatus(o.status) + '</td><td>' + I().formatDateTime(o.created_at) + '</td></tr>';
}).join('');
var ordersHtml = Ui().dataTable(['订单号', '金额', '状态', '时间'], orderRows, 4);
var actions = '<div class="soon-admin-drawer-actions">' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="drawerEdit">编辑</button>' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="drawerPwd">改密</button>' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="drawerSub">设会员</button>' +
(u.id !== adminId ? '<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost soon-btn--danger" id="drawerDel">删除</button>' : '') +
'</div>';
Ui().openDrawer('用户 #' + u.id, actions +
Ui().detailSection('账号信息', account) +
Ui().detailSection('当前会员', subHtml) +
Ui().detailSection('云端用量', usageHtml) +
Ui().detailSection('最近订单', ordersHtml));
Ui().el('drawerEdit').onclick = function () { Ui().closeDrawer(); showEditModal(u); };
Ui().el('drawerPwd').onclick = function () { showPasswordModal(u.id); };
Ui().el('drawerSub').onclick = function () { showSubscriptionModal(u.id); };
var delBtn = Ui().el('drawerDel');
if (delBtn) {
delBtn.onclick = function () {
Ui().confirm('确认删除该用户?其云端文件将标记删除且不可恢复。', function () {
Api().del('users/' + u.id).then(function (r) {
if (r) {
Ui().closeDrawer();
soonToast('用户已删除', 'success');
render();
}
});
});
};
}
});
}
function userRowActions(u, adminId) {
var isSelf = adminId && u.id === adminId;
var editDisabled = State().adminLevel === 'ops' && u.role === 'admin';
var ds = { id: String(u.id) };
var editDs = {
id: String(u.id),
email: u.email,
role: u.role,
admin_level: u.admin_level || 'full',
};
var inline = [
{ label: '详情', action: 'detail', dataset: ds },
{ label: '编辑', action: 'edit', dataset: editDs, disabled: editDisabled },
{ label: '订阅', action: 'sub', dataset: ds },
];
var more = [
{ label: '改密', action: 'pwd', dataset: ds },
];
if (u.status === 'disabled') {
more.push({ label: '启用', action: 'status', dataset: { id: String(u.id), st: 'active' } });
} else if (!isSelf) {
more.push({ label: '停用', action: 'status', dataset: { id: String(u.id), st: 'disabled' } });
} else {
more.push({ label: '停用', action: 'status', disabled: true });
}
if (!isSelf) {
more.push({
label: '删除',
action: 'delete',
className: 'soon-admin-action-menu__item--danger',
dataset: ds,
});
}
return Menu().rowActions('user-' + u.id, inline, more);
}
function bindUserMenus(panel, adminId) {
Menu().bind(panel, function (action, btn) {
var id = parseInt(btn.dataset.id, 10);
if (action === 'detail') showUserDetail(id);
if (action === 'edit') {
showEditModal({
id: id,
email: btn.dataset.email,
role: btn.dataset.role,
admin_level: btn.dataset.adminLevel || 'full',
});
}
if (action === 'pwd') showPasswordModal(id);
if (action === 'sub') showSubscriptionModal(id);
if (action === 'status' && btn.dataset.st) {
Ui().confirm('确认变更用户状态?', function () {
Api().postJson('users/' + id + '/status', { status: btn.dataset.st }).then(function (r) {
if (r) {
soonToast('状态已更新', 'success');
render();
}
});
});
}
if (action === 'delete') {
Ui().confirm('确认删除该用户?其云端文件将标记删除。', function () {
Api().del('users/' + id).then(function (r) {
if (r) {
soonToast('用户已删除', 'success');
render();
}
});
});
}
});
}
function renderFilterBar(st) {
return Filter().bar(
Filter().field('邮箱', Filter().searchInput('userSearchQ', st.q, '搜索邮箱')),
'<button type="button" class="soon-btn soon-btn--sm" id="userSearchBtn">搜索</button>' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="userSearchReset">重置</button>'
);
}
function bindFilters(st) {
Ui().el('userSearchBtn').onclick = function () {
st.q = Ui().el('userSearchQ').value.trim();
st.page = 1;
Router().syncQuery();
render();
};
var reset = Ui().el('userSearchReset');
if (reset) {
reset.onclick = function () {
st.q = '';
st.page = 1;
Router().syncQuery();
render();
};
}
Ui().el('userSearchQ').onkeydown = function (e) {
if (e.key === 'Enter') {
st.q = Ui().el('userSearchQ').value.trim();
st.page = 1;
Router().syncQuery();
render();
}
};
}
function render() {
var panel = Ui().el('panel-users');
if (!panel) return;
if (!Router().hasPendingDetail()) Ui().closeDrawer();
var st = State().users;
var pageShell = '<div class="soon-admin-page">' +
Ui().pageHeader('用户管理', '增删改用户、重置密码与设置会员',
'<button type="button" class="soon-btn soon-btn--sm" id="userCreateBtn">新增用户</button>') +
renderFilterBar(st) + Ui().skeletonTable() + '</div>';
panel.innerHTML = pageShell;
Api().get(buildQuery()).then(function (j) {
if (!j) {
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('用户管理', '增删改用户、重置密码与设置会员',
'<button type="button" class="soon-btn soon-btn--sm" id="userCreateBtn">新增用户</button>') +
renderFilterBar(st) + Ui().listError('用户列表加载失败', 'usersListRetry') + '</div>';
Ui().el('userCreateBtn').onclick = showCreateModal;
Ui().el('usersListRetry').onclick = render;
bindFilters(st);
var errDetailId = Router().consumeDetailId();
if (errDetailId) showUserDetail(errDetailId, { fromRoute: true });
return;
}
var items = j.data.items || [];
var adminId = State().currentAdminId;
var rows = items.map(function (u) {
var planLabel = u.plan_name ? I().esc(u.plan_name) : '免费版';
return '<tr><td>' + u.id + '</td><td>' + I().esc(u.email) + '</td><td>' + I().esc(I().tRole(u.role)) +
'</td><td>' + planLabel + '</td><td>' + I().badgeStatus(u.status) + '</td><td>' +
I().formatDateTime(u.created_at) + '</td><td class="soon-admin-table__actions">' +
userRowActions(u, adminId) + '</td></tr>';
}).join('');
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('用户管理', '增删改用户、重置密码与设置会员',
'<button type="button" class="soon-btn soon-btn--sm" id="userCreateBtn">新增用户</button>') +
renderFilterBar(st) +
Ui().dataTable(['ID', '邮箱', '角色', '会员', '状态', '注册时间', '操作'], rows) +
Ui().pagerHtml(j.data.page, j.data.size, j.data.total) + '</div>';
Ui().el('userCreateBtn').onclick = showCreateModal;
bindFilters(st);
Ui().bindPager(panel, j.data.page, j.data.size, j.data.total, function (p, newSize) {
st.page = p;
if (newSize) st.size = newSize;
Router().syncQuery();
render();
});
bindUserMenus(panel, adminId);
var detailId = Router().consumeDetailId();
if (detailId) showUserDetail(detailId, { fromRoute: true });
});
}
window.AdminViews = window.AdminViews || {};
window.AdminViews.users = { render: render };
})();