主要改进: - 新增配置管理 (server/config.js) - 新增日志工具 (server/utils/logger.js) - 添加全局错误处理中间件,统一错误响应格式 - 添加输入验证和 AppError 错误类 - 重构 db.js 迁移逻辑,代码更清晰 - 前端列表改为无限滚动加载,每页 20 条 - 封装 AppState 状态管理模块 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
209 lines
7.8 KiB
JavaScript
209 lines
7.8 KiB
JavaScript
// ============================================
|
||
// 通用列表管理 - 无限滚动加载
|
||
// ============================================
|
||
|
||
const config = {
|
||
orderList: { endpoint: '/api/records/orders', emptyMsg: '暂无接单记录', renderItem: renderOrderItem },
|
||
otherIncomeList: { endpoint: '/api/records/other-income', emptyMsg: '暂无红包及其他收入记录', renderItem: renderOtherIncomeItem },
|
||
dispatchList: { endpoint: '/api/records/dispatches', emptyMsg: '暂无派单记录', renderItem: renderDispatchItem },
|
||
redEnvelopeList: { endpoint: '/api/records/red-envelope', emptyMsg: '暂无红包收入记录', renderItem: renderRedEnvelopeItem },
|
||
milkTeaList: { endpoint: '/api/records/milk-tea', emptyMsg: '暂无奶茶收入记录', renderItem: renderMilkTeaItem }
|
||
};
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
const listState = {};
|
||
|
||
function initList(listId) {
|
||
listState[listId] = {
|
||
loading: false,
|
||
records: [],
|
||
page: 1,
|
||
hasMore: true,
|
||
totalCount: 0
|
||
};
|
||
|
||
const container = document.getElementById(listId);
|
||
if (container) container.innerHTML = '';
|
||
}
|
||
|
||
async function loadListData(listId) {
|
||
const cfg = config[listId];
|
||
const state = listState[listId];
|
||
if (!cfg || !state) return;
|
||
|
||
if (state.loading || !state.hasMore) return;
|
||
|
||
state.loading = true;
|
||
showLoading(listId, true);
|
||
|
||
try {
|
||
const res = await fetch(
|
||
API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth,
|
||
{ headers: headers() }
|
||
);
|
||
|
||
if (!res.ok) {
|
||
console.error('[loadListData] fetch failed:', res.status);
|
||
state.loading = false;
|
||
showLoading(listId, false);
|
||
return;
|
||
}
|
||
|
||
const data = await res.json();
|
||
const newRecords = data.records || [];
|
||
const total = data.total || 0;
|
||
|
||
state.records = state.records.concat(newRecords);
|
||
state.totalCount = total;
|
||
state.hasMore = state.records.length < total;
|
||
state.page++;
|
||
|
||
renderList(listId);
|
||
setupScrollObserver(listId);
|
||
} catch (e) {
|
||
console.error('[loadListData] error:', e);
|
||
} finally {
|
||
state.loading = false;
|
||
showLoading(listId, false);
|
||
}
|
||
}
|
||
|
||
function showLoading(listId, show) {
|
||
const loadingId = listId.replace('List', 'Loading');
|
||
const loadingEl = document.getElementById(loadingId);
|
||
if (loadingEl) {
|
||
loadingEl.style.display = show ? 'block' : 'none';
|
||
if (!show && listState[listId]) {
|
||
const state = listState[listId];
|
||
if (state.hasMore) {
|
||
loadingEl.textContent = '下拉加载更多...';
|
||
} else if (state.records.length > 0) {
|
||
loadingEl.textContent = '已加载全部';
|
||
} else {
|
||
loadingEl.textContent = '';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderList(listId) {
|
||
const cfg = config[listId];
|
||
const state = listState[listId];
|
||
if (!cfg || !state) return;
|
||
|
||
const container = document.getElementById(listId);
|
||
if (!container) return;
|
||
|
||
if (state.records.length === 0) {
|
||
container.innerHTML = '<div class="ranking-empty">' + cfg.emptyMsg + '</div>';
|
||
return;
|
||
}
|
||
|
||
const grouped = {};
|
||
state.records.forEach(r => {
|
||
const date = formatLocalDate(r.created_at);
|
||
if (!grouped[date]) grouped[date] = [];
|
||
grouped[date].push(r);
|
||
});
|
||
|
||
container.innerHTML = '';
|
||
|
||
for (const [date, records] of Object.entries(grouped)) {
|
||
const group = document.createElement('div');
|
||
group.className = 'order-date-group';
|
||
group.setAttribute('data-date', date);
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'order-date-label';
|
||
label.textContent = date;
|
||
group.appendChild(label);
|
||
|
||
records.forEach(r => {
|
||
const item = document.createElement('div');
|
||
item.innerHTML = cfg.renderItem(r);
|
||
group.appendChild(item.firstChild);
|
||
});
|
||
|
||
container.appendChild(group);
|
||
}
|
||
}
|
||
|
||
function setupScrollObserver(listId) {
|
||
const state = listState[listId];
|
||
if (!state || !state.hasMore) return;
|
||
|
||
const container = document.getElementById(listId);
|
||
if (!container) return;
|
||
|
||
// 移除旧的 sentinel
|
||
const oldSentinel = container.querySelector('.scroll-sentinel');
|
||
if (oldSentinel) oldSentinel.remove();
|
||
|
||
// 创建新的 sentinel
|
||
const sentinel = document.createElement('div');
|
||
sentinel.className = 'scroll-sentinel';
|
||
sentinel.style.height = '1px';
|
||
container.appendChild(sentinel);
|
||
|
||
const observer = new IntersectionObserver((entries) => {
|
||
if (entries[0].isIntersecting && !state.loading && state.hasMore) {
|
||
loadListData(listId);
|
||
}
|
||
}, { rootMargin: '100px' });
|
||
|
||
observer.observe(sentinel);
|
||
|
||
// 存储 observer 以便清理
|
||
state.observer = observer;
|
||
}
|
||
|
||
// ============================================
|
||
// 兼容接口
|
||
// ============================================
|
||
async function loadOrders() { return loadListData('orderList'); }
|
||
async function loadOtherIncome() { return loadListData('otherIncomeList'); }
|
||
async function loadDispatches() { return loadListData('dispatchList'); }
|
||
async function loadRedEnvelope() { return loadListData('redEnvelopeList'); }
|
||
async function loadMilkTea() { return loadListData('milkTeaList'); }
|
||
|
||
// ============================================
|
||
// 渲染函数
|
||
// ============================================
|
||
|
||
function renderOrderItem(r) {
|
||
const time = formatLocalTime(r.created_at);
|
||
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "'") + ')\'>' +
|
||
'<div class="order-item-top"><span class="order-item-boss">' + (r.boss || '-') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||
'<div class="order-item-bottom"><span>' + (r.quantity || 0) + '单 × ' + (r.unit_price || 0).toFixed(2) + '</span><span>' + time + (r.partner ? ' · ' + r.partner : '') + '</span></div></div>';
|
||
}
|
||
|
||
function renderOtherIncomeItem(r) {
|
||
const time = formatLocalTime(r.created_at);
|
||
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
||
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "'") + ')\'>' +
|
||
'<div class="order-item-top"><span class="order-item-boss">' + displayCategory + (r.boss ? ' · ' + r.boss : '') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||
'<div class="order-item-bottom"><span>' + (r.source || '') + '</span><span>' + time + '</span></div></div>';
|
||
}
|
||
|
||
function renderDispatchItem(r) {
|
||
const time = formatLocalTime(r.created_at);
|
||
const unitPrice = r.unit_price || (r.quantity ? r.amount / r.quantity : 0);
|
||
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "'") + ')\'>' +
|
||
'<div class="order-item-top"><span class="order-item-boss">' + (r.boss || '-') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||
'<div class="order-item-bottom"><span>' + (r.quantity || 0) + '单 × ' + (r.rebate || 0).toFixed(2) + '(单价' + unitPrice.toFixed(2) + ')</span><span>' + time + (r.partner ? ' · ' + r.partner : '') + '</span></div></div>';
|
||
}
|
||
|
||
function renderRedEnvelopeItem(r) {
|
||
const time = formatLocalTime(r.created_at);
|
||
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "'") + ')\'>' +
|
||
'<div class="order-item-top"><span class="order-item-boss">红包</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||
'<div class="order-item-bottom"><span>' + (r.source || '') + '</span><span>' + time + '</span></div></div>';
|
||
}
|
||
|
||
function renderMilkTeaItem(r) {
|
||
const time = formatLocalTime(r.created_at);
|
||
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "'") + ')\'>' +
|
||
'<div class="order-item-top"><span class="order-item-boss">奶茶</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||
'<div class="order-item-bottom"><span>' + (r.source || '') + '</span><span>' + time + '</span></div></div>';
|
||
} |