feat: 实现社区激励金(公户)系统,经济闭环

- 新增 CommunityFund / FundLog 模型(公户余额单行表 + 6种流水类型)
- 新增 FundRepo 仓储(GetBalance / IncrBalance / CreateLog / ListLogs / WithTx)
- 改造 EnergyService 五个方法:Energize 税收入公户、DeductOnRename/DeductOnDeletePost 入公户、RefundOnRenameReject 从公户支出、AdminAdjust 拆分为 admin_transfer/system_operation
- 新增 ErrInsufficientFund 错误哨兵
- 管理后台新增公户余额展示 + 公户流水页面 + 域能调整资金来源选择
- 新增 energy.css / energy.js 前端交互
- 侧边栏新增公户流水入口
This commit is contained in:
2026-06-01 15:10:47 +08:00
parent 683c9e02d8
commit 26e557de8a
17 changed files with 1033 additions and 67 deletions

View File

@ -0,0 +1,259 @@
/* 域能管理页面样式 */
.fund-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
padding: 1.5rem;
color: #fff;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.fund-card .fund-info h3 {
margin: 0 0 0.25rem 0;
font-size: 0.9rem;
opacity: 0.9;
font-weight: 500;
}
.fund-card .fund-info .fund-balance {
font-size: 2rem;
font-weight: 700;
margin: 0;
}
.fund-card .fund-info .fund-balance.negative {
color: #fdd;
}
.fund-card .fund-icon {
font-size: 2.5rem;
opacity: 0.3;
}
/* 调整表单 */
.adjust-form {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.adjust-form h3 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
color: #1a202c;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.35rem;
font-size: 0.85rem;
font-weight: 500;
color: #4a5568;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 0.9rem;
color: #2d3748;
background: #fff;
transition: border-color 0.15s;
box-sizing: border-box;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-hint {
font-size: 0.78rem;
color: #a0aec0;
margin-top: 0.25rem;
}
.btn-primary {
background: #667eea;
color: #fff;
border: none;
padding: 0.6rem 1.5rem;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover {
background: #5a67d8;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 日志表格 */
.log-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.log-table th {
background: #f7fafc;
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.82rem;
font-weight: 600;
color: #4a5568;
border-bottom: 1px solid #e2e8f0;
}
.log-table td {
padding: 0.7rem 1rem;
font-size: 0.85rem;
color: #2d3748;
border-bottom: 1px solid #edf2f7;
}
.log-table tr:last-child td {
border-bottom: none;
}
.log-table .amount-positive {
color: #38a169;
font-weight: 600;
}
.log-table .amount-negative {
color: #e53e3e;
font-weight: 600;
}
.type-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.78rem;
font-weight: 500;
background: #ebf8ff;
color: #2b6cb0;
}
/* 筛选栏 */
.filter-bar {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
align-items: center;
}
.filter-bar select {
padding: 0.4rem 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 0.85rem;
color: #4a5568;
background: #fff;
}
.filter-bar select:focus {
outline: none;
border-color: #667eea;
}
/* 分页 */
.pagination {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
justify-content: center;
}
.pagination button {
padding: 0.4rem 0.85rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
background: #fff;
font-size: 0.85rem;
color: #4a5568;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #f7fafc;
border-color: #a0aec0;
}
.pagination button.active {
background: #667eea;
color: #fff;
border-color: #667eea;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 操作结果消息 */
.form-message {
margin-top: 0.75rem;
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
display: none;
}
.form-message.success {
background: #f0fff4;
color: #38a169;
border: 1px solid #c6f6d5;
display: block;
}
.form-message.error {
background: #fff5f5;
color: #e53e3e;
border: 1px solid #fed7d7;
display: block;
}
/* 日志时间 */
.log-time {
font-size: 0.8rem;
color: #a0aec0;
white-space: nowrap;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #a0aec0;
font-size: 0.9rem;
}

View File

@ -0,0 +1,207 @@
// 域能管理页面 JS
(function () {
const path = window.location.pathname;
if (path === '/admin/energy') {
initEnergyPage();
} else if (path === '/admin/energy/logs') {
initEnergyLogPage();
} else if (path === '/admin/energy/fund-logs') {
initFundLogPage();
}
// ----- 域能管理首页:公户余额 + 调整表单 -----
function initEnergyPage() {
var adjustForm = document.getElementById('adjust-form');
if (!adjustForm) return;
adjustForm.addEventListener('submit', function (e) {
e.preventDefault();
var form = e.target;
var submitBtn = form.querySelector('button[type="submit"]');
var msgEl = document.getElementById('adjust-message');
if (msgEl) msgEl.className = 'form-message';
var userIDsStr = form.querySelector('[name="user_ids"]').value.trim();
var amountStr = form.querySelector('[name="amount"]').value.trim();
var desc = form.querySelector('[name="description"]').value.trim();
var mode = form.querySelector('[name="mode"]').value;
if (!userIDsStr || !amountStr || !desc) {
showMsg(msgEl, '请填写完整信息', 'error');
return;
}
var userIDs = userIDsStr.split(',').map(function (s) { return parseInt(s.trim(), 10); }).filter(function (n) { return n > 0; });
var amount = parseInt(amountStr, 10);
if (userIDs.length === 0 || isNaN(amount) || amount === 0) {
showMsg(msgEl, '用户ID和金额格式不正确', 'error');
return;
}
if (submitBtn) submitBtn.disabled = true;
api.post('/api/admin/energy/adjust', {
user_ids: userIDs,
amount: amount,
description: desc,
mode: mode
}, function (res) {
if (submitBtn) submitBtn.disabled = false;
if (res.success) {
showMsg(msgEl, res.message || '调整成功', 'success');
// 刷新公户余额
setTimeout(function () {
api.get('/api/admin/energy/fund-balance', function (r) {
if (r.success && r.data) {
var balEl = document.getElementById('fund-balance');
if (balEl) {
balEl.textContent = r.data.balance_display.toFixed(1);
balEl.className = 'fund-balance' + (r.data.balance < 0 ? ' negative' : '');
}
}
});
}, 300);
} else {
showMsg(msgEl, res.message || '调整失败', 'error');
}
});
});
}
// ----- 域能日志页 -----
function initEnergyLogPage() {
var page = 1;
var typeFilter = document.getElementById('log-type-filter');
function loadLogs() {
var logType = typeFilter ? typeFilter.value : '';
api.get('/api/admin/energy/logs?page=' + page + '&page_size=20&type=' + encodeURIComponent(logType), function (res) {
if (!res.success) return;
renderLogTable('energy-log-tbody', res.data.items, function (item) {
return [
item.id,
formatTime(item.created_at),
item.user_id,
'<span class="' + (item.amount >= 0 ? 'amount-positive' : 'amount-negative') + '">' + item.energy_display.toFixed(1) + '</span>',
'<span class="type-badge">' + (item.type_name || item.type) + '</span>',
escapeHtml(item.description || '')
];
}, 6);
renderPagination('energy-log-pagination', page, res.data.total_pages, function (p) {
page = p;
loadLogs();
});
});
}
if (typeFilter) {
typeFilter.addEventListener('change', function () {
page = 1;
loadLogs();
});
}
loadLogs();
}
// ----- 公户流水页 -----
function initFundLogPage() {
var page = 1;
var typeFilter = document.getElementById('fund-log-type-filter');
function loadLogs() {
var logType = typeFilter ? typeFilter.value : '';
api.get('/api/admin/energy/fund-logs?page=' + page + '&page_size=20&type=' + encodeURIComponent(logType), function (res) {
if (!res.success) return;
renderLogTable('fund-log-tbody', res.data.items, function (item) {
return [
item.id,
formatTime(item.created_at),
'<span class="' + (item.amount >= 0 ? 'amount-positive' : 'amount-negative') + '">' + item.amount_display.toFixed(1) + '</span>',
'<span class="type-badge">' + (item.type_name || item.type) + '</span>',
escapeHtml(item.description || '')
];
}, 5);
renderPagination('fund-log-pagination', page, res.data.total_pages, function (p) {
page = p;
loadLogs();
});
});
}
if (typeFilter) {
typeFilter.addEventListener('change', function () {
page = 1;
loadLogs();
});
}
loadLogs();
}
// ----- 通用工具函数 -----
function renderLogTable(tbodyId, items, rowFn, colCount) {
var tbody = document.getElementById(tbodyId);
if (!tbody) return;
if (!items || items.length === 0) {
tbody.innerHTML = '<tr><td colspan="' + colCount + '" class="empty-state">暂无记录</td></tr>';
return;
}
var html = '';
for (var i = 0; i < items.length; i++) {
var cells = rowFn(items[i]);
html += '<tr>';
for (var j = 0; j < cells.length; j++) {
html += '<td>' + cells[j] + '</td>';
}
html += '</tr>';
}
tbody.innerHTML = html;
}
function renderPagination(paginationId, current, total, onChange) {
var container = document.getElementById(paginationId);
if (!container || total <= 1) { if (container) container.innerHTML = ''; return; }
var html = '';
html += '<button' + (current <= 1 ? ' disabled' : '') + ' data-page="' + (current - 1) + '"> 上一页</button>';
var start = Math.max(1, current - 2);
var end = Math.min(total, current + 2);
for (var p = start; p <= end; p++) {
html += '<button class="' + (p === current ? 'active' : '') + '" data-page="' + p + '">' + p + '</button>';
}
html += '<button' + (current >= total ? ' disabled' : '') + ' data-page="' + (current + 1) + '">下一页 </button>';
container.innerHTML = html;
var buttons = container.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
(function (btn) {
btn.addEventListener('click', function () {
var p = parseInt(this.getAttribute('data-page'), 10);
if (p > 0 && p <= total && p !== current) {
onChange(p);
}
});
})(buttons[i]);
}
}
function formatTime(isoStr) {
if (!isoStr) return '';
var d = new Date(isoStr);
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function escapeHtml(text) {
if (!text) return '';
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function showMsg(msgEl, text, type) {
if (!msgEl) return;
msgEl.textContent = text;
msgEl.className = 'form-message ' + type;
setTimeout(function () { msgEl.className = 'form-message'; }, 4000);
}
})();