重构 monorepo 并完善网页端订阅与首页体验
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user