重构 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,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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user