主要改进: - 新增配置管理 (server/config.js) - 新增日志工具 (server/utils/logger.js) - 添加全局错误处理中间件,统一错误响应格式 - 添加输入验证和 AppError 错误类 - 重构 db.js 迁移逻辑,代码更清晰 - 前端列表改为无限滚动加载,每页 20 条 - 封装 AppState 状态管理模块 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
355 lines
12 KiB
JavaScript
355 lines
12 KiB
JavaScript
// ============================================
|
|
// 应用状态管理
|
|
// ============================================
|
|
const API = '';
|
|
|
|
const AppState = {
|
|
// 认证状态
|
|
auth: {
|
|
token: localStorage.getItem('token'),
|
|
username: localStorage.getItem('username')
|
|
},
|
|
|
|
// 导航状态
|
|
nav: {
|
|
currentType: 'income',
|
|
currentCategory: '接单',
|
|
currentYear: new Date().getFullYear(),
|
|
currentMonth: new Date().getMonth() + 1,
|
|
currentNav: 'stats'
|
|
},
|
|
|
|
// 状态更新方法
|
|
setAuth(token, username) {
|
|
this.auth.token = token;
|
|
this.auth.username = username;
|
|
localStorage.setItem('token', token);
|
|
localStorage.setItem('username', username);
|
|
},
|
|
|
|
clearAuth() {
|
|
this.auth.token = null;
|
|
this.auth.username = null;
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('username');
|
|
},
|
|
|
|
setCategory(type, category) {
|
|
this.nav.currentType = type;
|
|
this.nav.currentCategory = category;
|
|
},
|
|
|
|
setYearMonth(year, month) {
|
|
this.nav.currentYear = year;
|
|
this.nav.currentMonth = month;
|
|
},
|
|
|
|
setNav(nav) {
|
|
this.nav.currentNav = nav;
|
|
}
|
|
};
|
|
|
|
// 兼容旧代码的全局变量
|
|
let token = AppState.auth.token;
|
|
let username = AppState.auth.username;
|
|
let currentType = AppState.nav.currentType;
|
|
let currentCategory = AppState.nav.currentCategory;
|
|
let currentYear = AppState.nav.currentYear;
|
|
let currentMonth = AppState.nav.currentMonth;
|
|
let currentNav = AppState.nav.currentNav;
|
|
|
|
function headers() {
|
|
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
|
}
|
|
|
|
function formatLocalTime(isoStr) {
|
|
if (!isoStr) return '';
|
|
const d = new Date(isoStr);
|
|
const h = String(d.getHours()).padStart(2, '0');
|
|
const m = String(d.getMinutes()).padStart(2, '0');
|
|
return h + ':' + m;
|
|
}
|
|
|
|
function formatLocalDate(input) {
|
|
if (!input) return '未知日期';
|
|
const d = input instanceof Date ? input : new Date(input);
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
return y + '-' + m + '-' + day;
|
|
}
|
|
|
|
async function loadTotalStats() {
|
|
try {
|
|
const res = await fetch(API + '/api/stats/total', { headers: headers() });
|
|
if (!res.ok) return;
|
|
const s = await res.json();
|
|
document.getElementById('totalTakeCount').textContent = s.take_count;
|
|
document.getElementById('totalDispatchCount').textContent = s.dispatch_count;
|
|
document.getElementById('totalNetIncome').textContent = '' + s.net_income.toFixed(2);
|
|
} catch (e) { console.error(e); }
|
|
}
|
|
|
|
function renderHorizontalChart(containerId, rows, clickHandler, fromPage) {
|
|
const container = document.getElementById(containerId);
|
|
if (rows.length === 0) {
|
|
container.innerHTML = '<div class="ranking-empty">暂无数据</div>';
|
|
return;
|
|
}
|
|
const max = Math.max(...rows.map(r => r.count), 1);
|
|
container.innerHTML = rows.map((r, i) => {
|
|
const pct = Math.max((r.count / max) * 100, 2);
|
|
const onclick = clickHandler ? ` onclick="${clickHandler}('${r.name.replace(/'/g, "\\'")}', '${fromPage}')"` : '';
|
|
return `<div class="hbar-row"${onclick}>
|
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
|
<div class="hbar-label" title="${r.name}">${r.name}</div>
|
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
|
<div class="hbar-value">${r.count}单</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function openTakeRanking() {
|
|
showPage('takeRankingPage');
|
|
try {
|
|
const res = await fetch(API + '/api/stats/take-ranking', { headers: headers() });
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const rows = data.rows || [];
|
|
|
|
// 显示统计卡片
|
|
const summaryContainer = document.getElementById('takeRankingSummary');
|
|
summaryContainer.innerHTML = `
|
|
<div class="item">
|
|
<div class="label">接单总数</div>
|
|
<div class="val">${data.total_take_count || 0}</div>
|
|
</div>
|
|
<div class="item">
|
|
<div class="label">接单总收入</div>
|
|
<div class="val green">${(data.total_take_income || 0).toFixed(2)}</div>
|
|
</div>
|
|
<div class="item">
|
|
<div class="label">红包收入</div>
|
|
<div class="val purple">${(data.total_redEnvelope_income || 0).toFixed(2)}</div>
|
|
</div>
|
|
`;
|
|
|
|
renderHorizontalChart('takeRankingChart', rows, 'openBossDetail', 'takeRankingPage');
|
|
} catch (e) { console.error(e); }
|
|
}
|
|
|
|
async function openDispatchRanking() {
|
|
showPage('dispatchRankingPage');
|
|
try {
|
|
const res = await fetch(API + '/api/stats/dispatch-ranking', { headers: headers() });
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const rows = data.rows || [];
|
|
|
|
// 计算总计
|
|
const totalDispatch = data.total_dispatch_count !== undefined ? data.total_dispatch_count : (rows.reduce((sum, r) => sum + (r.dispatch_count || 0), 0));
|
|
const totalPartnerIncome = rows.reduce((sum, r) => sum + (r.partner_income || 0), 0);
|
|
const totalRebateIncome = rows.reduce((sum, r) => sum + (r.rebate_income || 0), 0);
|
|
const totalMilkTeaIncome = data.total_milkTea_income || 0;
|
|
|
|
// 显示4个卡片
|
|
const summaryContainer = document.getElementById('dispatchRankingSummary');
|
|
summaryContainer.innerHTML = `
|
|
<div class="item">
|
|
<div class="label">派单总数</div>
|
|
<div class="val">${totalDispatch}</div>
|
|
</div>
|
|
<div class="item">
|
|
<div class="label">陪玩总收入</div>
|
|
<div class="val green">${totalPartnerIncome.toFixed(2)}</div>
|
|
</div>
|
|
<div class="item">
|
|
<div class="label">返点总收入</div>
|
|
<div class="val orange">${totalRebateIncome.toFixed(2)}</div>
|
|
</div>
|
|
<div class="item">
|
|
<div class="label">奶茶收入</div>
|
|
<div class="val purple">${totalMilkTeaIncome.toFixed(2)}</div>
|
|
</div>
|
|
`;
|
|
|
|
// 显示排行列表
|
|
const max = Math.max(...rows.map(r => r.dispatch_count || 0), 1);
|
|
const container = document.getElementById('dispatchRankingChart');
|
|
container.innerHTML = rows.map((r, i) => {
|
|
const pct = Math.max(((r.dispatch_count || 0) / max) * 100, 2);
|
|
return `<div class="hbar-row" onclick="openPartnerDetail('${r.name.replace(/'/g, "\\'")}', 'dispatchRankingPage')">
|
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
|
<div class="hbar-label" title="${r.name}">${r.name}</div>
|
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
|
<div class="hbar-value">${r.dispatch_count}单</div>
|
|
</div>`;
|
|
}).join('');
|
|
} catch (e) { console.error(e); }
|
|
}
|
|
|
|
function showToast(msg) {
|
|
const t = document.getElementById('toast');
|
|
t.textContent = msg;
|
|
t.classList.add('show');
|
|
setTimeout(() => t.classList.remove('show'), 2000);
|
|
}
|
|
|
|
function showPage(pageId) {
|
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
|
document.getElementById(pageId).classList.add('active');
|
|
window.scrollTo(0, 0);
|
|
if (pageId === 'homePage') {
|
|
updateMonthDisplay();
|
|
loadStats();
|
|
document.getElementById('profileName').textContent = username;
|
|
document.getElementById('avatarText').textContent = username.charAt(0).toUpperCase();
|
|
switchNav(currentNav);
|
|
}
|
|
if (pageId === 'recordPage') {
|
|
currentType = 'income';
|
|
currentCategory = '接单';
|
|
document.querySelectorAll('.type-btn').forEach((b, i) => b.classList.toggle('active', i === 0));
|
|
document.getElementById('incomeCats').style.display = 'grid';
|
|
document.getElementById('expenseCats').style.display = 'none';
|
|
selectCategory(document.querySelector('#incomeCats .category-item'), '接单');
|
|
}
|
|
if (pageId === 'rankingPage') {
|
|
const startDate = `${currentYear}-${String(currentMonth).padStart(2, '0')}-01`;
|
|
const now = new Date();
|
|
const endDay = (currentYear === now.getFullYear() && currentMonth === now.getMonth() + 1) ? formatLocalDate(now) : `${currentYear}-${String(currentMonth).padStart(2, '0')}-28`;
|
|
document.getElementById('rankStartDate').value = startDate;
|
|
document.getElementById('rankEndDate').value = endDay;
|
|
currentSort = 'total_income';
|
|
document.querySelectorAll('.sort-btn').forEach((b, i) => b.classList.toggle('active', i === 0));
|
|
loadRankingDetail();
|
|
}
|
|
if (pageId === 'orderListPage') {
|
|
document.getElementById('orderListTitle').textContent = `${currentYear}年${currentMonth}月接单列表`;
|
|
initList('orderList');
|
|
loadListData('orderList');
|
|
}
|
|
if (pageId === 'otherIncomeListPage') {
|
|
document.getElementById('otherIncomeListTitle').textContent = `${currentYear}年${currentMonth}月红包及其他收入`;
|
|
initList('otherIncomeList');
|
|
loadListData('otherIncomeList');
|
|
}
|
|
if (pageId === 'dispatchListPage') {
|
|
document.getElementById('dispatchListTitle').textContent = `${currentYear}年${currentMonth}月派单列表`;
|
|
initList('dispatchList');
|
|
loadListData('dispatchList');
|
|
}
|
|
if (pageId === 'redEnvelopeListPage') {
|
|
initList('redEnvelopeList');
|
|
loadListData('redEnvelopeList');
|
|
}
|
|
if (pageId === 'milkTeaListPage') {
|
|
initList('milkTeaList');
|
|
loadListData('milkTeaList');
|
|
}
|
|
}
|
|
|
|
function switchNav(nav) {
|
|
currentNav = nav;
|
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
document.getElementById('navStats').classList.toggle('active', nav === 'stats');
|
|
document.getElementById('navMine').classList.toggle('active', nav === 'mine');
|
|
if (nav === 'stats') {
|
|
document.getElementById('statsPanel').classList.add('active');
|
|
} else {
|
|
document.getElementById('minePanel').classList.add('active');
|
|
loadTotalStats();
|
|
}
|
|
}
|
|
|
|
function updateMonthDisplay() {
|
|
document.getElementById('monthDisplay').textContent = `${currentYear}年${currentMonth}月`;
|
|
}
|
|
|
|
// 头像连续点击5次显示隐藏按钮
|
|
let avatarTapCount = 0;
|
|
let avatarTapTimer = null;
|
|
function onAvatarTap() {
|
|
avatarTapCount++;
|
|
clearTimeout(avatarTapTimer);
|
|
avatarTapTimer = setTimeout(() => { avatarTapCount = 0; }, 1500);
|
|
if (avatarTapCount >= 5) {
|
|
avatarTapCount = 0;
|
|
const el = document.getElementById('secretBtns');
|
|
const visible = el.style.display !== 'none';
|
|
el.style.display = visible ? 'none' : 'block';
|
|
showToast(visible ? '已隐藏' : '已解锁');
|
|
}
|
|
}
|
|
|
|
// 下拉刷新
|
|
(function() {
|
|
let startY = 0, pulling = false, triggered = false;
|
|
const threshold = 60;
|
|
|
|
function getPanel() { return document.getElementById('statsPanel'); }
|
|
function getIndicator() { return document.getElementById('pullIndicator'); }
|
|
|
|
document.addEventListener('touchstart', function(e) {
|
|
const panel = getPanel();
|
|
if (!panel || !panel.classList.contains('active')) return;
|
|
if (currentNav !== 'stats') return;
|
|
if (window.scrollY > 0) return;
|
|
startY = e.touches[0].clientY;
|
|
pulling = true;
|
|
triggered = false;
|
|
}, { passive: true });
|
|
|
|
document.addEventListener('touchmove', function(e) {
|
|
if (!pulling) return;
|
|
const panel = getPanel();
|
|
if (!panel || !panel.classList.contains('active')) { pulling = false; return; }
|
|
if (window.scrollY > 0) { pulling = false; return; }
|
|
const dy = e.touches[0].clientY - startY;
|
|
if (dy < 0) return;
|
|
const indicator = getIndicator();
|
|
const arrow = document.getElementById('pullArrow');
|
|
const text = document.getElementById('pullText');
|
|
if (dy > 10) {
|
|
indicator.classList.add('visible');
|
|
if (dy >= threshold) {
|
|
arrow.classList.add('flipped');
|
|
text.textContent = '松手刷新';
|
|
triggered = true;
|
|
} else {
|
|
arrow.classList.remove('flipped');
|
|
text.textContent = '下拉刷新';
|
|
triggered = false;
|
|
}
|
|
}
|
|
}, { passive: true });
|
|
|
|
document.addEventListener('touchend', function() {
|
|
if (!pulling) return;
|
|
pulling = false;
|
|
const indicator = getIndicator();
|
|
const arrow = document.getElementById('pullArrow');
|
|
const text = document.getElementById('pullText');
|
|
if (triggered) {
|
|
text.textContent = '刷新中...';
|
|
arrow.classList.remove('flipped');
|
|
indicator.classList.add('refreshing');
|
|
loadStats().then(() => {
|
|
setTimeout(() => {
|
|
indicator.classList.remove('visible', 'refreshing');
|
|
showToast('已刷新');
|
|
}, 500);
|
|
});
|
|
} else {
|
|
indicator.classList.remove('visible');
|
|
}
|
|
}, { passive: true });
|
|
})();
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
if (token && username) {
|
|
showPage('homePage');
|
|
}
|
|
renderForm();
|
|
});
|