永久会员与模板库后台化:预览门控、轻量首页与 Admin CRUD
- 会员改为永久激活方案;云端文件不再拦截非会员;预览弹窗内导出/打印才校验 - 首页最近文件支持本地记录,登录后与云端合并;移除独立会员页与订阅页 - 模板库入库管理:soon_templates 表、Admin 上传 CRUD、/templates 轻量列表与按需下载 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
'/users': 'users',
|
||||
'/orders': 'orders',
|
||||
'/plans': 'plans',
|
||||
'/templates': 'templates',
|
||||
'/settings': 'settings',
|
||||
'/audits': 'audits',
|
||||
'/payment': 'payment',
|
||||
@@ -21,6 +22,7 @@
|
||||
users: '/users',
|
||||
orders: '/orders',
|
||||
plans: '/plans',
|
||||
templates: '/templates',
|
||||
settings: '/settings',
|
||||
audits: '/audits',
|
||||
payment: '/payment',
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
{ href: '#/users', label: '用户管理' },
|
||||
{ href: '#/orders', label: '订单管理' },
|
||||
{ href: '#/plans', label: '套餐配置' },
|
||||
{ href: '#/templates', label: '模板库' },
|
||||
{ href: '#/payment', label: '支付密钥' },
|
||||
{ href: '#/settings', label: '系统设置' },
|
||||
{ href: '#/audits', label: '审计日志' },
|
||||
@@ -96,7 +97,7 @@
|
||||
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('活跃会员', 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: '需在订单页处理' });
|
||||
|
||||
@@ -202,11 +202,11 @@
|
||||
|
||||
var subHtml;
|
||||
if (sub) {
|
||||
subHtml = Ui().detailRow('订阅状态', I().badgeStatus(sub.status)) +
|
||||
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>';
|
||||
subHtml = '<p class="soon-admin-detail-empty">无关联会员记录</p>';
|
||||
}
|
||||
|
||||
var drawerActions = rowActions(o).replace(/<button[^>]*order-detail-btn[^>]*>[\s\S]*?<\/button>/, '');
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
return plan && plan.code !== 'free' && plan.price_cents > 0;
|
||||
}
|
||||
|
||||
function isLifetimePlan(plan) {
|
||||
return plan && plan.code === 'member_lifetime';
|
||||
}
|
||||
|
||||
function editableFeatures(plan) {
|
||||
if (plan.features_editable && plan.features_editable.length) {
|
||||
return plan.features_editable.slice();
|
||||
@@ -96,9 +100,10 @@
|
||||
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 '付费方案保存后将自动追加订阅周期说明。';
|
||||
if (isLifetimePlan(plan)) return '永久激活方案,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 + '),用户端卡片中不展示该行';
|
||||
return '保存后将自动追加周期说明(' + label + '),用户端不展示该行';
|
||||
}
|
||||
|
||||
function openEditModal(plan) {
|
||||
@@ -112,6 +117,7 @@
|
||||
Form().field('简介', Form().input('peDesc', { value: plan.description || '' }))
|
||||
);
|
||||
|
||||
var lifetime = isLifetimePlan(plan);
|
||||
var pricePanel = Form().panel('价格与周期',
|
||||
'<div class="soon-admin-form-grid">' +
|
||||
Form().field('价格(元)',
|
||||
@@ -121,8 +127,8 @@
|
||||
disabled: free,
|
||||
})) +
|
||||
Form().field('有效天数',
|
||||
Form().input('peDays', { type: 'number', value: plan.duration_days }),
|
||||
free ? '免费版填 0' : '月付 30 / 季付 90 / 年付 365') +
|
||||
Form().input('peDays', { type: 'number', value: plan.duration_days, disabled: lifetime }),
|
||||
free ? '免费版填 0' : (lifetime ? '永久激活固定为 0' : '月付 30 / 季付 90 / 年付 365')) +
|
||||
Form().field('排序',
|
||||
Form().input('peSort', { type: 'number', value: plan.sort_order })) +
|
||||
'</div>' +
|
||||
@@ -140,12 +146,12 @@
|
||||
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 benefitTitle = free ? '免费版展示' : '会员权益';
|
||||
var benefitHint = free
|
||||
? '展示在会员中心免费版区域;补充说明写在简介中。'
|
||||
? '展示在用户端身份说明中。'
|
||||
: periodHint(plan);
|
||||
var benefitPanel = Form().panel(benefitTitle,
|
||||
'<div class="soon-admin-feature-list" id="peFeatures">' + featureRowsHtml(features) + '</div>' +
|
||||
@@ -158,7 +164,7 @@
|
||||
Form().actions('保存', 'adminModalSave') +
|
||||
'</div>';
|
||||
|
||||
Ui().openFormModal((free ? '免费版' : '订阅方案') + ' · ' + plan.name, form, function () {
|
||||
Ui().openFormModal((free ? '免费版' : '会员激活') + ' · ' + plan.name, form, function () {
|
||||
var name = Ui().el('peName').value.trim();
|
||||
if (!name) {
|
||||
soonToast('名称不能为空', 'warn');
|
||||
@@ -177,7 +183,7 @@
|
||||
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,
|
||||
duration_days: lifetime ? 0 : (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,
|
||||
@@ -215,7 +221,7 @@
|
||||
var panel = Ui().el('panel-plans');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('订阅方案', '配置价格、周期、配额与用户端展示文案') +
|
||||
Ui().pageHeader('会员激活', '配置激活价格、配额与用户端展示文案') +
|
||||
Ui().skeletonTable() + '</div>';
|
||||
|
||||
Api().get('plans').then(function (j) {
|
||||
@@ -230,7 +236,7 @@
|
||||
}).join('');
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('订阅方案', '配置价格、周期、配额与用户端展示文案') +
|
||||
Ui().pageHeader('会员激活', '配置激活价格、配额与用户端展示文案') +
|
||||
Ui().dataTable(
|
||||
['代码', '名称', '价格', '权益', '存储', '有效期', '推荐', '状态', '操作'],
|
||||
rows
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
(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 typeLabel(type) {
|
||||
return Number(type) === 2 ? '双面' : '单面';
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
n = parseInt(n, 10) || 0;
|
||||
if (!n) return '—';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
|
||||
return (n / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function readFileText(input) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var file = input && input.files && input.files[0];
|
||||
if (!file) {
|
||||
reject(new Error('no_file'));
|
||||
return;
|
||||
}
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () { resolve(String(reader.result || '')); };
|
||||
reader.onerror = function () { reject(new Error('read_failed')); };
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
});
|
||||
}
|
||||
|
||||
function defaultNameFromFile(input) {
|
||||
var file = input && input.files && input.files[0];
|
||||
if (!file) return '';
|
||||
return file.name.replace(/\.soon$/i, '').replace(/[_-]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function uploadFields(required) {
|
||||
return Form().panel('模板文件',
|
||||
Form().field('选择 .soon 文件',
|
||||
'<input type="file" id="tplFile" class="soon-input soon-input--sm" accept=".soon,application/json">' +
|
||||
(required ? '' : '<p class="soon-admin-form-hint soon-admin-form-hint--tight">留空则不替换已有文件</p>'),
|
||||
required ? '须为有效的 .soon JSON 文件' : '')
|
||||
);
|
||||
}
|
||||
|
||||
function metaFields(plan) {
|
||||
plan = plan || {};
|
||||
return Form().panel('展示设置',
|
||||
Form().field('名称', Form().input('tplName', { value: plan.name || '' })) +
|
||||
'<div class="soon-admin-form-grid">' +
|
||||
Form().field('排序', Form().input('tplSort', { type: 'number', value: plan.sort_order != null ? plan.sort_order : 0 })) +
|
||||
Form().field('类型', '<input class="soon-input soon-input--sm" value="' + I().esc(typeLabel(plan.type)) + '" disabled>') +
|
||||
'</div>' +
|
||||
'<label class="soon-admin-check"><input type="checkbox" id="tplActive"' +
|
||||
(plan.is_active !== false ? ' checked' : '') + '> 上架展示</label>'
|
||||
);
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
var form = '<div class="soon-admin-form">' + uploadFields(true) + metaFields() +
|
||||
Form().actions('上传', 'adminModalSave') + '</div>';
|
||||
|
||||
Ui().openFormModal('上传模板', form, function () {
|
||||
var nameEl = Ui().el('tplName');
|
||||
var fileEl = Ui().el('tplFile');
|
||||
var name = nameEl.value.trim() || defaultNameFromFile(fileEl);
|
||||
if (!name) {
|
||||
soonToast('请填写名称或选择文件', 'warn');
|
||||
return false;
|
||||
}
|
||||
return readFileText(fileEl).then(function (content) {
|
||||
return Api().postJson('templates', {
|
||||
name: name,
|
||||
content: content,
|
||||
sort_order: parseInt(Ui().el('tplSort').value, 10) || 0,
|
||||
is_active: Ui().el('tplActive').checked ? 1 : 0,
|
||||
}).then(function (r) {
|
||||
if (r) {
|
||||
layer.closeAll();
|
||||
soonToast('已上传', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
}).catch(function (e) {
|
||||
if (e && e.message === 'no_file') soonToast('请选择 .soon 文件', 'warn');
|
||||
else soonToast('文件读取失败', 'error');
|
||||
});
|
||||
}, { area: ['480px', 'auto'] });
|
||||
}
|
||||
|
||||
function openEditModal(plan) {
|
||||
var form = '<div class="soon-admin-form">' + metaFields(plan) + uploadFields(false) +
|
||||
Form().actions('保存', 'adminModalSave') + '</div>';
|
||||
|
||||
Ui().openFormModal('编辑模板 · ' + plan.name, form, function () {
|
||||
var name = Ui().el('tplName').value.trim();
|
||||
if (!name) {
|
||||
soonToast('名称不能为空', 'warn');
|
||||
return false;
|
||||
}
|
||||
var body = {
|
||||
name: name,
|
||||
sort_order: parseInt(Ui().el('tplSort').value, 10) || 0,
|
||||
is_active: Ui().el('tplActive').checked ? 1 : 0,
|
||||
};
|
||||
var fileEl = Ui().el('tplFile');
|
||||
var hasFile = fileEl && fileEl.files && fileEl.files[0];
|
||||
var save = function (content) {
|
||||
if (content) body.content = content;
|
||||
return Api().putJson('templates/' + plan.id, body).then(function (r) {
|
||||
if (r) {
|
||||
layer.closeAll();
|
||||
soonToast('已保存', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
};
|
||||
if (hasFile) {
|
||||
return readFileText(fileEl).then(save).catch(function () {
|
||||
soonToast('文件读取失败', 'error');
|
||||
});
|
||||
}
|
||||
return save(null);
|
||||
}, { area: ['480px', 'auto'] });
|
||||
}
|
||||
|
||||
function deleteTemplate(plan) {
|
||||
Ui().confirm('确定删除模板「' + plan.name + '」?', function () {
|
||||
Api().del('templates/' + plan.id).then(function (r) {
|
||||
if (r) {
|
||||
soonToast('已删除', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
var panel = Ui().el('panel-templates');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('模板库', '管理首页展示的 .soon 模板(缩略图与名称轻量加载)',
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="tplCreate">上传模板</button>') +
|
||||
Ui().skeletonTable() + '</div>';
|
||||
|
||||
Api().get('templates').then(function (j) {
|
||||
if (!j) return;
|
||||
var items = j.data.items || [];
|
||||
var rows = items.map(function (p) {
|
||||
return '<tr data-id="' + p.id + '"><td>' + p.id + '</td><td>' + I().esc(p.name) +
|
||||
'</td><td>' + typeLabel(p.type) + '</td><td>' + formatBytes(p.file_size) + '</td><td>' +
|
||||
p.sort_order + '</td><td>' + (p.has_thumb ? '有' : '—') + '</td><td>' +
|
||||
activeBadge(p.is_active) + '</td><td>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost tpl-edit">编辑</button> ' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost tpl-del">删除</button></td></tr>';
|
||||
}).join('');
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('模板库', '管理首页展示的 .soon 模板(缩略图与名称轻量加载)',
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="tplCreate">上传模板</button>') +
|
||||
Ui().dataTable(
|
||||
['ID', '名称', '类型', '大小', '排序', '缩略图', '状态', '操作'],
|
||||
rows
|
||||
) + '</div>';
|
||||
|
||||
var planMap = {};
|
||||
items.forEach(function (p) { planMap[p.id] = p; });
|
||||
|
||||
panel.querySelector('#tplCreate').onclick = openCreateModal;
|
||||
panel.querySelectorAll('.tpl-edit').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
var id = parseInt(btn.closest('tr').dataset.id, 10);
|
||||
if (planMap[id]) openEditModal(planMap[id]);
|
||||
};
|
||||
});
|
||||
panel.querySelectorAll('.tpl-del').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
var id = parseInt(btn.closest('tr').dataset.id, 10);
|
||||
if (planMap[id]) deleteTemplate(planMap[id]);
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.AdminViews = window.AdminViews || {};
|
||||
window.AdminViews.templates = { render: render };
|
||||
})();
|
||||
@@ -205,7 +205,7 @@
|
||||
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>';
|
||||
subHtml = '<p class="soon-admin-detail-empty">普通用户 / 未激活</p>';
|
||||
}
|
||||
|
||||
var usageHtml = Ui().detailRow('文件数', I().esc(usage.files_count || 0)) +
|
||||
@@ -263,7 +263,7 @@
|
||||
var inline = [
|
||||
{ label: '详情', action: 'detail', dataset: ds },
|
||||
{ label: '编辑', action: 'edit', dataset: editDs, disabled: editDisabled },
|
||||
{ label: '订阅', action: 'sub', dataset: ds },
|
||||
{ label: '会员', action: 'sub', dataset: ds },
|
||||
];
|
||||
var more = [
|
||||
{ label: '改密', action: 'pwd', dataset: ds },
|
||||
|
||||
Reference in New Issue
Block a user