Compare commits

...
9 Commits
Author SHA1 Message Date
lizhilun b728ae08e4 修复安全与备份问题 2026-07-12 17:50:04 +08:00
lizhilun 77cd66a854 commit remaining frontend updates 2026-07-01 16:15:23 +08:00
lizhilun e69930799c fix security and import validation 2026-07-01 16:13:47 +08:00
lizhilunandClaude Opus 4.6 6467048f38 fix: 修复年度净收入计算错误 - 避免数据重复累加
问题:grandTotal 累加了 UNION ALL 的所有行数据,
包括月明细和年汇总,导致总额约为实际值的2倍。

修复:只累加 month='00' 的年度汇总数据。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 17:25:48 +08:00
lizhilunandClaude Opus 4.6 ac2d21a5a0 fix: 修复添加记录后首页数据不刷新的问题
将缓存清除逻辑从 stats.js 路由移至 app.js 全局中间件,
确保 records.js 路由的写操作也能触发缓存清除。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:59:32 +08:00
lizhilunandClaude Opus 4.6 d551841035 refactor: 系统优化 - 错误处理、输入验证、无限滚动
主要改进:
- 新增配置管理 (server/config.js)
- 新增日志工具 (server/utils/logger.js)
- 添加全局错误处理中间件,统一错误响应格式
- 添加输入验证和 AppError 错误类
- 重构 db.js 迁移逻辑,代码更清晰
- 前端列表改为无限滚动加载,每页 20 条
- 封装 AppState 状态管理模块

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 10:39:58 +08:00
lizhilunandClaude Opus 4.6 19e5a48f07 refactor: 清理代码 - 移除死代码和调试语句
- 移除未使用的 currentListId 变量
- 移除空的 setupInfiniteScroll 函数
- 移除重复的 partner 变量声明
- 移除调试用的 console.log 语句

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 22:18:49 +08:00
lizhilunandClaude Opus 4.6 9126348d1b fix: 修复分页和缓存相关bug
- 前端: 移除无限滚动,改为一次性加载全部数据
- 前端: monthly-records接口添加limit=1000参数
- 前端: 修复edit-modal.js中loadListData调用参数
- 后端: 修复stats.js中getCached函数Promise处理
- 后端: 修复records.js各接口正确传递limit参数
- 后端: 修复partner-records SQL参数数量问题
- 统一版本号强制浏览器刷新缓存

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 22:12:11 +08:00
lizhilunandClaude Opus 4.6 df3a9daa65 perf: 系统性能优化 - 数据库索引、缓存、GZIP压缩和查询合并
- 添加3个复合索引提升查询性能 (user_date, user_category, destination)
- 添加GZIP压缩和静态资源缓存 (maxAge: 1h)
- 添加API响应no-cache头
- 添加stats端点2分钟内存缓存 (per-user key, LRU淘汰)
- 合并多次数据库查询为单次查询
- 添加滚动防抖改善移动端体验
- 修复res.once内存泄漏问题

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 10:25:37 +08:00
25 changed files with 1764 additions and 684 deletions
+22 -1
View File
@@ -14,7 +14,28 @@ mkdir -p "${BACKUP_DIR}"
# 备份数据库 # 备份数据库
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始备份数据库..." echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始备份数据库..."
cp "${SOURCE_DIR}/data.db" "${BACKUP_DIR}/data.db" export SOURCE_DIR BACKUP_DIR
node <<'NODE'
const path = require('path');
const sourceDir = process.env.SOURCE_DIR;
const backupDir = process.env.BACKUP_DIR;
const Database = require(path.join(sourceDir, 'node_modules', 'better-sqlite3'));
async function main() {
const db = new Database(path.join(sourceDir, 'data.db'), { readonly: true });
try {
await db.backup(path.join(backupDir, 'data.db'));
} finally {
db.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
NODE
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 数据库备份完成" echo "[$(date '+%Y-%m-%d %H:%M:%S')] 数据库备份完成"
else else
+25 -1
View File
@@ -1,10 +1,34 @@
const fs = require('fs');
const path = require('path');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return {};
return fs.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.reduce((env, line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return env;
const idx = trimmed.indexOf('=');
if (idx === -1) return env;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
env[key] = value.replace(/^['"]|['"]$/g, '');
return env;
}, {});
}
const runtimeEnv = loadEnvFile(path.join(__dirname, '.env'));
module.exports = { module.exports = {
apps: [{ apps: [{
name: 'gamer', name: 'gamer',
script: 'server/app.js', script: 'server/app.js',
env: { env: {
...runtimeEnv,
NODE_ENV: 'production', NODE_ENV: 'production',
PORT: 3000 PORT: 3000,
HOST: runtimeEnv.HOST || '0.0.0.0',
TRUST_PROXY: runtimeEnv.TRUST_PROXY || 'loopback'
}, },
instances: 1, instances: 1,
autorestart: true, autorestart: true,
+87 -22
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"compression": "^1.8.1",
"cors": "^2.8.6", "cors": "^2.8.6",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3" "jsonwebtoken": "^9.0.3"
@@ -190,6 +191,60 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -372,9 +427,9 @@
} }
}, },
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@@ -578,9 +633,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -905,6 +960,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -924,9 +988,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "8.3.0", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -983,12 +1047,13 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.1", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"
@@ -1156,14 +1221,14 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3", "object-inspect": "^1.13.4",
"side-channel-list": "^1.0.0", "side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1", "side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2" "side-channel-weakmap": "^1.0.2"
}, },
@@ -1175,13 +1240,13 @@
} }
}, },
"node_modules/side-channel-list": { "node_modules/side-channel-list": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3" "object-inspect": "^1.13.4"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
+1
View File
@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"compression": "^1.8.1",
"cors": "^2.8.6", "cors": "^2.8.6",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3" "jsonwebtoken": "^9.0.3"
+18 -1
View File
@@ -12,7 +12,7 @@ body {
.page { display: none; } .page { display: none; }
.page.active { display: flex; flex-direction: column; } .page.active { display: flex; flex-direction: column; }
#loginPage { #loginPage, #registerPage {
min-height: 100vh; min-height: 100vh;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -68,6 +68,12 @@ body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; color: #fff;
} }
.btn-secondary {
background: transparent;
color: #667eea;
border: 1px solid #667eea;
margin-top: 12px;
}
.login-error { .login-error {
color: #e74c3c; color: #e74c3c;
text-align: center; text-align: center;
@@ -76,6 +82,17 @@ body {
min-height: 20px; min-height: 20px;
} }
.login-register-link {
text-align: center;
margin-top: 16px;
}
.login-register-link a {
color: #667eea;
text-decoration: none;
font-size: 14px;
}
/* 首页 */ /* 首页 */
#homePage { #homePage {
min-height: 100vh; min-height: 100vh;
+26 -3
View File
@@ -21,10 +21,33 @@
<input type="password" id="loginPass" placeholder="请输入密码" autocomplete="current-password"> <input type="password" id="loginPass" placeholder="请输入密码" autocomplete="current-password">
</div> </div>
<button class="btn btn-primary" onclick="doLogin()">登 录</button> <button class="btn btn-primary" onclick="doLogin()">登 录</button>
<button class="btn btn-secondary" onclick="showRegisterPage()">注册账号</button>
<div class="login-error" id="loginError"></div> <div class="login-error" id="loginError"></div>
</div> </div>
</div> </div>
<!-- 注册页 -->
<div id="registerPage" class="page">
<div class="login-box">
<h1>注册账号</h1>
<div class="form-group">
<label>用户名</label>
<input type="text" id="registerUser" placeholder="请输入用户名" autocomplete="username">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="registerPass" placeholder="请输入密码" autocomplete="new-password">
</div>
<div class="form-group">
<label>确认密码</label>
<input type="password" id="registerPassConfirm" placeholder="请再次输入密码" autocomplete="new-password">
</div>
<button class="btn btn-primary" onclick="doRegister()">注 册</button>
<button class="btn btn-secondary" onclick="showLoginPage()">返回登录</button>
<div class="login-error" id="registerError"></div>
</div>
</div>
<!-- 首页 --> <!-- 首页 -->
<div id="homePage" class="page"> <div id="homePage" class="page">
<div class="home-header"> <div class="home-header">
@@ -457,12 +480,12 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="js/app.js?v=20260224"></script> <script src="js/app.js?v=20260224"></script>
<script src="js/auth.js"></script> <script src="js/auth.js?v=20260428b"></script>
<script src="js/autocomplete.js"></script> <script src="js/autocomplete.js"></script>
<script src="js/month-picker.js"></script> <script src="js/month-picker.js"></script>
<script src="js/stats.js?v=2026022401"></script> <script src="js/stats.js?v=20260326"></script>
<script src="js/ranking.js"></script> <script src="js/ranking.js"></script>
<script src="js/order-list.js"></script> <script src="js/order-list.js?v=20260326"></script>
<script src="js/edit-modal.js"></script> <script src="js/edit-modal.js"></script>
<script src="js/record-form.js"></script> <script src="js/record-form.js"></script>
<script src="js/import-export.js"></script> <script src="js/import-export.js"></script>
+110 -29
View File
@@ -1,16 +1,100 @@
// ============================================
// 应用状态管理
// ============================================
const API = ''; const API = '';
let token = localStorage.getItem('token');
let username = localStorage.getItem('username'); const AppState = {
let currentType = 'income'; // 认证状态
let currentCategory = '接单'; auth: {
let currentYear = new Date().getFullYear(); token: localStorage.getItem('token'),
let currentMonth = new Date().getMonth() + 1; username: localStorage.getItem('username')
let currentNav = 'stats'; },
// 导航状态
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() { function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
} }
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[ch]));
}
function escapeAttr(value) {
return escapeHtml(value);
}
function jsString(value) {
return escapeAttr(JSON.stringify(String(value ?? '')));
}
function recordArg(record) {
return escapeAttr(JSON.stringify(record));
}
// 统一的 API 请求函数,自动处理 401 跳转到登录页
async function apiFetch(url, options = {}) {
const res = await fetch(url, { ...options, headers: { ...headers(), ...(options.headers || {}) } });
if (res.status === 401) {
AppState.clearAuth();
showPage('loginPage');
throw new Error('Unauthorized');
}
return res;
}
function formatLocalTime(isoStr) { function formatLocalTime(isoStr) {
if (!isoStr) return ''; if (!isoStr) return '';
const d = new Date(isoStr); const d = new Date(isoStr);
@@ -30,7 +114,7 @@ function formatLocalDate(input) {
async function loadTotalStats() { async function loadTotalStats() {
try { try {
const res = await fetch(API + '/api/stats/total', { headers: headers() }); const res = await apiFetch(API + '/api/stats/total');
if (!res.ok) return; if (!res.ok) return;
const s = await res.json(); const s = await res.json();
document.getElementById('totalTakeCount').textContent = s.take_count; document.getElementById('totalTakeCount').textContent = s.take_count;
@@ -48,10 +132,11 @@ function renderHorizontalChart(containerId, rows, clickHandler, fromPage) {
const max = Math.max(...rows.map(r => r.count), 1); const max = Math.max(...rows.map(r => r.count), 1);
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.count / max) * 100, 2); const pct = Math.max((r.count / max) * 100, 2);
const onclick = clickHandler ? ` onclick="${clickHandler}('${r.name.replace(/'/g, "\\'")}', '${fromPage}')"` : ''; const onclick = clickHandler ? ` onclick="${clickHandler}(${jsString(r.name)}, ${jsString(fromPage)})"` : '';
const name = escapeHtml(r.name);
return `<div class="hbar-row"${onclick}> return `<div class="hbar-row"${onclick}>
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.name}">${r.name}</div> <div class="hbar-label" title="${escapeAttr(r.name)}">${name}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.count}单</div> <div class="hbar-value">${r.count}单</div>
</div>`; </div>`;
@@ -61,7 +146,7 @@ function renderHorizontalChart(containerId, rows, clickHandler, fromPage) {
async function openTakeRanking() { async function openTakeRanking() {
showPage('takeRankingPage'); showPage('takeRankingPage');
try { try {
const res = await fetch(API + '/api/stats/take-ranking', { headers: headers() }); const res = await apiFetch(API + '/api/stats/take-ranking');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const rows = data.rows || []; const rows = data.rows || [];
@@ -90,7 +175,7 @@ async function openTakeRanking() {
async function openDispatchRanking() { async function openDispatchRanking() {
showPage('dispatchRankingPage'); showPage('dispatchRankingPage');
try { try {
const res = await fetch(API + '/api/stats/dispatch-ranking', { headers: headers() }); const res = await apiFetch(API + '/api/stats/dispatch-ranking');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const rows = data.rows || []; const rows = data.rows || [];
@@ -127,9 +212,10 @@ async function openDispatchRanking() {
const container = document.getElementById('dispatchRankingChart'); const container = document.getElementById('dispatchRankingChart');
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max(((r.dispatch_count || 0) / max) * 100, 2); const pct = Math.max(((r.dispatch_count || 0) / max) * 100, 2);
return `<div class="hbar-row" onclick="openPartnerDetail('${r.name.replace(/'/g, "\\'")}', 'dispatchRankingPage')"> const name = escapeHtml(r.name);
return `<div class="hbar-row" onclick="openPartnerDetail(${jsString(r.name)}, 'dispatchRankingPage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.name}">${r.name}</div> <div class="hbar-label" title="${escapeAttr(r.name)}">${name}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.dispatch_count}单</div> <div class="hbar-value">${r.dispatch_count}单</div>
</div>`; </div>`;
@@ -175,31 +261,26 @@ function showPage(pageId) {
} }
if (pageId === 'orderListPage') { if (pageId === 'orderListPage') {
document.getElementById('orderListTitle').textContent = `${currentYear}${currentMonth}月接单列表`; document.getElementById('orderListTitle').textContent = `${currentYear}${currentMonth}月接单列表`;
orderPage = 1; orderHasMore = true; initList('orderList');
document.getElementById('orderList').innerHTML = ''; loadListData('orderList');
loadOrders();
} }
if (pageId === 'otherIncomeListPage') { if (pageId === 'otherIncomeListPage') {
document.getElementById('otherIncomeListTitle').textContent = `${currentYear}${currentMonth}月红包及其他收入`; document.getElementById('otherIncomeListTitle').textContent = `${currentYear}${currentMonth}月红包及其他收入`;
otherIncomePage = 1; otherIncomeHasMore = true; initList('otherIncomeList');
document.getElementById('otherIncomeList').innerHTML = ''; loadListData('otherIncomeList');
loadOtherIncome();
} }
if (pageId === 'dispatchListPage') { if (pageId === 'dispatchListPage') {
document.getElementById('dispatchListTitle').textContent = `${currentYear}${currentMonth}月派单列表`; document.getElementById('dispatchListTitle').textContent = `${currentYear}${currentMonth}月派单列表`;
dispatchPage = 1; dispatchHasMore = true; initList('dispatchList');
document.getElementById('dispatchList').innerHTML = ''; loadListData('dispatchList');
loadDispatches();
} }
if (pageId === 'redEnvelopeListPage') { if (pageId === 'redEnvelopeListPage') {
redEnvelopePage = 1; redEnvelopeHasMore = true; initList('redEnvelopeList');
document.getElementById('redEnvelopeList').innerHTML = ''; loadListData('redEnvelopeList');
loadRedEnvelope();
} }
if (pageId === 'milkTeaListPage') { if (pageId === 'milkTeaListPage') {
milkTeaPage = 1; milkTeaHasMore = true; initList('milkTeaList');
document.getElementById('milkTeaList').innerHTML = ''; loadListData('milkTeaList');
loadMilkTea();
} }
} }
+34
View File
@@ -20,6 +20,40 @@ async function doLogin() {
} }
} }
async function doRegister() {
const user = document.getElementById('registerUser').value.trim();
const pass = document.getElementById('registerPass').value.trim();
const passConfirm = document.getElementById('registerPassConfirm').value.trim();
if (!user || !pass) { document.getElementById('registerError').textContent = '请输入用户名和密码'; return; }
if (pass !== passConfirm) { document.getElementById('registerError').textContent = '两次密码输入不一致'; return; }
try {
const res = await fetch(API + '/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass })
});
const data = await res.json();
if (!res.ok) { document.getElementById('registerError').textContent = data.error; return; }
showToast('注册成功,请登录');
showLoginPage();
} catch (e) {
document.getElementById('registerError').textContent = '网络错误';
}
}
function showRegisterPage() {
document.getElementById('registerError').textContent = '';
document.getElementById('registerUser').value = '';
document.getElementById('registerPass').value = '';
document.getElementById('registerPassConfirm').value = '';
showPage('registerPage');
}
function showLoginPage() {
document.getElementById('loginError').textContent = '';
showPage('loginPage');
}
function doLogout() { function doLogout() {
token = null; token = null;
username = null; username = null;
+6 -4
View File
@@ -4,7 +4,7 @@ const _acCache = {};
async function acFetchNames(field) { async function acFetchNames(field) {
if (_acCache[field] && Date.now() - _acCache[field].ts < 60000) return _acCache[field].data; if (_acCache[field] && Date.now() - _acCache[field].ts < 60000) return _acCache[field].data;
try { try {
const res = await fetch(API + '/api/contacts?field=' + field, { headers: headers() }); const res = await apiFetch(API + '/api/contacts?field=' + field);
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); const data = await res.json();
_acCache[field] = { data, ts: Date.now() }; _acCache[field] = { data, ts: Date.now() };
@@ -45,14 +45,16 @@ function acSetup(inputId, field) {
if (filtered.length === 0) { list.classList.remove('show'); return; } if (filtered.length === 0) { list.classList.remove('show'); return; }
activeIdx = -1; activeIdx = -1;
list.innerHTML = filtered.slice(0, 10).map((n, i) => { list.innerHTML = filtered.slice(0, 10).map((n, i) => {
let display = n; let display = escapeHtml(n);
if (q) { if (q) {
const idx = n.toLowerCase().indexOf(q); const idx = n.toLowerCase().indexOf(q);
if (idx >= 0) { if (idx >= 0) {
display = n.slice(0, idx) + '<span class="ac-highlight">' + n.slice(idx, idx + q.length) + '</span>' + n.slice(idx + q.length); display = escapeHtml(n.slice(0, idx)) +
'<span class="ac-highlight">' + escapeHtml(n.slice(idx, idx + q.length)) + '</span>' +
escapeHtml(n.slice(idx + q.length));
} }
} }
return `<div class="ac-item" data-idx="${i}" data-value="${n.replace(/"/g, '&quot;')}">${display}</div>`; return `<div class="ac-item" data-idx="${i}" data-value="${escapeAttr(n)}">${display}</div>`;
}).join(''); }).join('');
list.classList.add('show'); list.classList.add('show');
} }
+24 -28
View File
@@ -12,46 +12,46 @@ function openEditModal(record) {
if (cat === '接单' || cat === '派单') { if (cat === '接单' || cat === '派单') {
document.getElementById('editModalTitle').textContent = cat === '派单' ? '编辑派单记录' : '编辑接单记录'; document.getElementById('editModalTitle').textContent = cat === '派单' ? '编辑派单记录' : '编辑接单记录';
let fields = ` let fields = `
<div class="form-group"><label>单数</label><input type="number" id="editQuantity" value="${record.quantity || ''}"></div> <div class="form-group"><label>单数</label><input type="number" id="editQuantity" value="${escapeAttr(record.quantity ?? '')}"></div>
<div class="form-group"><label>单价(元)</label><input type="number" step="0.01" id="editPrice" value="${record.unit_price || ''}"></div> <div class="form-group"><label>单价(元)</label><input type="number" step="0.01" id="editPrice" value="${escapeAttr(record.unit_price ?? '')}"></div>
`; `;
if (cat === '派单') { if (cat === '派单') {
const rebateVal = record.rebate || (record.quantity ? (record.amount / record.quantity) : ''); const rebateVal = record.rebate || (record.quantity ? (record.amount / record.quantity) : '');
fields += `<div class="form-group"><label>返点(元)</label><input type="number" step="0.01" id="editRebate" value="${rebateVal}"></div>`; fields += `<div class="form-group"><label>返点(元)</label><input type="number" step="0.01" id="editRebate" value="${escapeAttr(rebateVal)}"></div>`;
} }
fields += ` fields += `
<div class="form-group"><label>老板</label><input type="text" id="editBoss" value="${record.boss || ''}"></div> <div class="form-group"><label>老板</label><input type="text" id="editBoss" value="${escapeAttr(record.boss || '')}"></div>
<div class="form-group"><label>${cat === '派单' ? '负责打单的陪玩' : '一起的陪玩'}</label><input type="text" id="editPartner" value="${record.partner || ''}"></div> <div class="form-group"><label>${cat === '派单' ? '负责打单的陪玩' : '一起的陪玩'}</label><input type="text" id="editPartner" value="${escapeAttr(record.partner || '')}"></div>
<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${localTime}"></div> <div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${escapeAttr(localTime)}"></div>
`; `;
body.innerHTML = fields; body.innerHTML = fields;
} else if (cat === '红包收入' || cat === '奶茶') { } else if (cat === '红包收入' || cat === '奶茶') {
document.getElementById('editModalTitle').textContent = '编辑' + cat + '记录'; document.getElementById('editModalTitle').textContent = '编辑' + cat + '记录';
body.innerHTML = ` body.innerHTML = `
<div class="form-group"><label>金额(元)</label><input type="number" step="0.01" id="editAmount" value="${record.amount || ''}"></div> <div class="form-group"><label>金额(元)</label><input type="number" step="0.01" id="editAmount" value="${escapeAttr(record.amount ?? '')}"></div>
<div class="form-group"><label>来源</label><input type="text" id="editSource" value="${record.source || ''}"></div> <div class="form-group"><label>来源</label><input type="text" id="editSource" value="${escapeAttr(record.source || '')}"></div>
<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${localTime}"></div> <div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${escapeAttr(localTime)}"></div>
`; `;
} else if (cat === '红包支出' || cat === '比心' || cat === '其他支出') { } else if (cat === '红包支出' || cat === '比心' || cat === '其他支出') {
document.getElementById('editModalTitle').textContent = '编辑' + cat + '记录'; document.getElementById('editModalTitle').textContent = '编辑' + cat + '记录';
let fields = ` let fields = `
<div class="form-group"><label>金额(元)</label><input type="number" step="0.01" id="editAmount" value="${record.amount || ''}"></div> <div class="form-group"><label>金额(元)</label><input type="number" step="0.01" id="editAmount" value="${escapeAttr(record.amount ?? '')}"></div>
`; `;
if (cat !== '比心') { if (cat !== '比心') {
fields += `<div class="form-group"><label>目的地</label><input type="text" id="editDest" value="${record.destination || ''}"></div>`; fields += `<div class="form-group"><label>目的地</label><input type="text" id="editDest" value="${escapeAttr(record.destination || '')}"></div>`;
} else { } else {
fields += `<div class="form-group"><label>用途</label><input type="text" id="editDest" value="${record.destination || ''}"></div>`; fields += `<div class="form-group"><label>用途</label><input type="text" id="editDest" value="${escapeAttr(record.destination || '')}"></div>`;
} }
if (cat === '其他支出') { if (cat === '其他支出') {
fields += `<div class="form-group"><label>备注</label><input type="text" id="editNote" value="${record.note || ''}"></div>`; fields += `<div class="form-group"><label>备注</label><input type="text" id="editNote" value="${escapeAttr(record.note || '')}"></div>`;
} }
fields += `<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${localTime}"></div>`; fields += `<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${escapeAttr(localTime)}"></div>`;
body.innerHTML = fields; body.innerHTML = fields;
} else { } else {
document.getElementById('editModalTitle').textContent = '编辑记录'; document.getElementById('editModalTitle').textContent = '编辑记录';
body.innerHTML = ` body.innerHTML = `
<div class="form-group"><label>金额(元)</label><input type="number" step="0.01" id="editAmount" value="${record.amount || ''}"></div> <div class="form-group"><label>金额(元)</label><input type="number" step="0.01" id="editAmount" value="${escapeAttr(record.amount ?? '')}"></div>
<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${localTime}"></div> <div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${escapeAttr(localTime)}"></div>
`; `;
} }
// 绑定自动补全 // 绑定自动补全
@@ -74,17 +74,14 @@ function closeEditModal() {
function refreshCurrentList() { function refreshCurrentList() {
const cat = document.getElementById('editCategory').value; const cat = document.getElementById('editCategory').value;
if (cat === '派单') { if (cat === '派单') {
dispatchPage = 1; dispatchHasMore = true; initList('dispatchList');
document.getElementById('dispatchList').innerHTML = ''; loadListData('dispatchList');
loadDispatches();
} else if (cat === '接单') { } else if (cat === '接单') {
orderPage = 1; orderHasMore = true; initList('orderList');
document.getElementById('orderList').innerHTML = ''; loadListData('orderList');
loadOrders();
} else { } else {
otherIncomePage = 1; otherIncomeHasMore = true; initList('otherIncomeList');
document.getElementById('otherIncomeList').innerHTML = ''; loadListData('otherIncomeList');
loadOtherIncome();
} }
// 如果在日详情页也刷新 // 如果在日详情页也刷新
if (document.getElementById('dailyDetailPage').classList.contains('active') && currentDailyDate) { if (document.getElementById('dailyDetailPage').classList.contains('active') && currentDailyDate) {
@@ -138,9 +135,8 @@ async function saveEditRecord() {
if (time) body.created_at = new Date(time).toISOString(); if (time) body.created_at = new Date(time).toISOString();
try { try {
const res = await fetch(API + '/api/records/' + id, { const res = await apiFetch(API + '/api/records/' + id, {
method: 'PUT', method: 'PUT',
headers: headers(),
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
if (!res.ok) { showToast('保存失败'); return; } if (!res.ok) { showToast('保存失败'); return; }
@@ -155,7 +151,7 @@ async function deleteEditRecord() {
const id = document.getElementById('editId').value; const id = document.getElementById('editId').value;
if (!confirm('确定删除这条记录?')) return; if (!confirm('确定删除这条记录?')) return;
try { try {
const res = await fetch(API + '/api/records/' + id, { method: 'DELETE', headers: headers() }); const res = await apiFetch(API + '/api/records/' + id, { method: 'DELETE' });
if (!res.ok) { showToast('删除失败'); return; } if (!res.ok) { showToast('删除失败'); return; }
showToast('已删除'); showToast('已删除');
closeEditModal(); closeEditModal();
+3 -3
View File
@@ -11,7 +11,7 @@ async function doExport(format) {
closeExportModal(); closeExportModal();
showToast('正在导出...'); showToast('正在导出...');
try { try {
const res = await fetch(API + '/api/export?format=' + format, { headers: headers() }); const res = await apiFetch(API + '/api/export?format=' + format);
if (!res.ok) { if (!res.ok) {
const err = await res.json(); const err = await res.json();
showToast(err.error || '导出失败'); showToast(err.error || '导出失败');
@@ -88,9 +88,9 @@ async function confirmImport() {
if (!pendingImportContent) return; if (!pendingImportContent) return;
showToast('正在导入...'); showToast('正在导入...');
try { try {
const res = await fetch(API + '/api/import', { const res = await apiFetch(API + '/api/import', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Authorization': 'Bearer ' + token }, headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body: pendingImportContent body: pendingImportContent
}); });
const data = await res.json(); const data = await res.json();
+177 -97
View File
@@ -1,128 +1,208 @@
// 全局状态变量(供 app.js 引用) // ============================================
var orderPage = 1, orderHasMore = true, orderLoading = false; // 通用列表管理 - 无限滚动加载
var otherIncomePage = 1, otherIncomeHasMore = true, otherIncomeLoading = false; // ============================================
var dispatchPage = 1, dispatchHasMore = true, dispatchLoading = false;
var redEnvelopePage = 1, redEnvelopeHasMore = true, redEnvelopeLoading = false;
var milkTeaPage = 1, milkTeaHasMore = true, milkTeaLoading = false;
// 通用分页状态管理 const config = {
const listConfig = { orderList: { endpoint: '/api/records/orders', emptyMsg: '暂无接单记录', renderItem: renderOrderItem },
orderList: { getPage: () => orderPage, setPage: (v) => orderPage = v, getHasMore: () => orderHasMore, setHasMore: (v) => orderHasMore = v, getLoading: () => orderLoading, setLoading: (v) => orderLoading = v, emptyMsg: '暂无接单记录', endpoint: '/api/records/orders', renderItem: renderOrderItem }, otherIncomeList: { endpoint: '/api/records/other-income', emptyMsg: '暂无红包及其他收入记录', renderItem: renderOtherIncomeItem },
otherIncomeList: { getPage: () => otherIncomePage, setPage: (v) => otherIncomePage = v, getHasMore: () => otherIncomeHasMore, setHasMore: (v) => otherIncomeHasMore = v, getLoading: () => otherIncomeLoading, setLoading: (v) => otherIncomeLoading = v, emptyMsg: '暂无红包及其他收入记录', endpoint: '/api/records/other-income', renderItem: renderOtherIncomeItem }, dispatchList: { endpoint: '/api/records/dispatches', emptyMsg: '暂无派单记录', renderItem: renderDispatchItem },
dispatchList: { getPage: () => dispatchPage, setPage: (v) => dispatchPage = v, getHasMore: () => dispatchHasMore, setHasMore: (v) => dispatchHasMore = v, getLoading: () => dispatchLoading, setLoading: (v) => dispatchLoading = v, emptyMsg: '暂无派单记录', endpoint: '/api/records/dispatches', renderItem: renderDispatchItem }, redEnvelopeList: { endpoint: '/api/records/red-envelope', emptyMsg: '暂无红包收入记录', renderItem: renderRedEnvelopeItem },
redEnvelopeList: { getPage: () => redEnvelopePage, setPage: (v) => redEnvelopePage = v, getHasMore: () => redEnvelopeHasMore, setHasMore: (v) => redEnvelopeHasMore = v, getLoading: () => redEnvelopeLoading, setLoading: (v) => redEnvelopeLoading = v, emptyMsg: '暂无红包收入记录', endpoint: '/api/records/red-envelope', renderItem: renderRedEnvelopeItem }, milkTeaList: { endpoint: '/api/records/milk-tea', emptyMsg: '暂无奶茶收入记录', renderItem: renderMilkTeaItem }
milkTeaList: { getPage: () => milkTeaPage, setPage: (v) => milkTeaPage = v, getHasMore: () => milkTeaHasMore, setHasMore: (v) => milkTeaHasMore = v, getLoading: () => milkTeaLoading, setLoading: (v) => milkTeaLoading = v, emptyMsg: '暂无奶茶收入记录', endpoint: '/api/records/milk-tea', renderItem: renderMilkTeaItem }
}; };
// 通用列表加载函数 const PAGE_SIZE = 20;
async function loadList(listId, append) {
const config = listConfig[listId];
if (!config) return;
if (config.getLoading() || (!append && !config.getHasMore())) return; const listState = {};
config.setLoading(true);
document.getElementById(listId.replace('List', 'Loading')).style.display = 'block';
try { function initList(listId) {
const res = await fetch(API + config.endpoint + '?page=' + config.getPage() + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); listState[listId] = {
if (!res.ok) return; loading: false,
const data = await res.json(); records: [],
const container = document.getElementById(listId); page: 1,
if (!append) container.innerHTML = ''; hasMore: true,
totalCount: 0
};
if (data.records.length === 0) { const container = document.getElementById(listId);
if (config.getPage() === 1) container.innerHTML = '<div class="ranking-empty">' + config.emptyMsg + '</div>'; if (container) container.innerHTML = '';
config.setHasMore(false);
} else {
const grouped = {};
data.records.forEach(r => {
const date = formatLocalDate(r.created_at);
if (!grouped[date]) grouped[date] = [];
grouped[date].push(r);
});
let html = '';
for (const [date, records] of Object.entries(grouped)) {
html += '<div class="order-date-group" data-date="' + date + '"><div class="order-date-label">' + date + '</div>';
records.forEach(r => {
html += config.renderItem(r);
});
html += '</div>';
}
container.insertAdjacentHTML('beforeend', html);
config.setHasMore(data.records.length >= 20);
config.setPage(config.getPage() + 1);
}
} catch (e) { console.error(e); }
config.setLoading(false);
document.getElementById(listId.replace('List', 'Loading')).style.display = 'none';
} }
// 通用渲染函数 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 apiFetch(
API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth
);
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) { function renderOrderItem(r) {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "&#39;") + ')\'>' + return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<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-top"><span class="order-item-boss">' + escapeHtml(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>'; '<div class="order-item-bottom"><span>' + (r.quantity || 0) + '单 × ' + (r.unit_price || 0).toFixed(2) + '</span><span>' + escapeHtml(time + (r.partner ? ' · ' + r.partner : '')) + '</span></div></div>';
} }
function renderOtherIncomeItem(r) { function renderOtherIncomeItem(r) {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-'); const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "&#39;") + ')\'>' + return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<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-top"><span class="order-item-boss">' + escapeHtml(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>'; '<div class="order-item-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
} }
function renderDispatchItem(r) { function renderDispatchItem(r) {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
const unitPrice = r.unit_price || (r.quantity ? r.amount / r.quantity : 0); const unitPrice = r.unit_price || (r.quantity ? r.amount / r.quantity : 0);
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "&#39;") + ')\'>' + return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<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-top"><span class="order-item-boss">' + escapeHtml(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>'; '<div class="order-item-bottom"><span>' + (r.quantity || 0) + '单 × ' + (r.rebate || 0).toFixed(2) + '(单价' + unitPrice.toFixed(2) + '</span><span>' + escapeHtml(time + (r.partner ? ' · ' + r.partner : '')) + '</span></div></div>';
} }
function renderRedEnvelopeItem(r) { function renderRedEnvelopeItem(r) {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "&#39;") + ')\'>' + return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<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-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>'; '<div class="order-item-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
} }
function renderMilkTeaItem(r) { function renderMilkTeaItem(r) {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "&#39;") + ')\'>' + return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<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-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>'; '<div class="order-item-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
} }
// 滚动加载
window.addEventListener('scroll', () => {
const pageListMap = [
{ page: 'orderListPage', list: 'orderList' },
{ page: 'dispatchListPage', list: 'dispatchList' },
{ page: 'otherIncomeListPage', list: 'otherIncomeList' },
{ page: 'redEnvelopeListPage', list: 'redEnvelopeList' },
{ page: 'milkTeaListPage', list: 'milkTeaList' }
];
for (const m of pageListMap) {
if (document.getElementById(m.page).classList.contains('active')) {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
const config = listConfig[m.list];
if (config && config.getHasMore() && !config.getLoading()) {
loadList(m.list, true);
}
}
break;
}
}
});
// 兼容旧接口
async function loadOrders(append) { return loadList('orderList', append); }
async function loadOtherIncome(append) { return loadList('otherIncomeList', append); }
async function loadDispatches(append) { return loadList('dispatchList', append); }
async function loadRedEnvelope(append) { return loadList('redEnvelopeList', append); }
async function loadMilkTea(append) { return loadList('milkTeaList', append); }
+10 -7
View File
@@ -17,7 +17,7 @@ async function loadRankingDetail() {
url += '&endDate=' + formatLocalDate(d); url += '&endDate=' + formatLocalDate(d);
} }
try { try {
const res = await fetch(url, { headers: headers() }); const res = await apiFetch(url);
if (!res.ok) return; if (!res.ok) return;
const rows = await res.json(); const rows = await res.json();
const container = document.getElementById('rankingDetailList'); const container = document.getElementById('rankingDetailList');
@@ -29,9 +29,10 @@ async function loadRankingDetail() {
const max = Math.max(...rows.map(r => r.dispatch_count), 1); const max = Math.max(...rows.map(r => r.dispatch_count), 1);
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.dispatch_count / max) * 100, 2); const pct = Math.max((r.dispatch_count / max) * 100, 2);
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'rankingPage')"> const boss = escapeHtml(r.boss);
return `<div class="hbar-row" onclick="openBossDetail(${jsString(r.boss)}, 'rankingPage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.boss}">${r.boss}</div> <div class="hbar-label" title="${escapeAttr(r.boss)}">${boss}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.dispatch_count}单</div> <div class="hbar-value">${r.dispatch_count}单</div>
</div>`; </div>`;
@@ -40,9 +41,10 @@ async function loadRankingDetail() {
const max = Math.max(...rows.map(r => r.take_count), 1); const max = Math.max(...rows.map(r => r.take_count), 1);
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.take_count / max) * 100, 2); const pct = Math.max((r.take_count / max) * 100, 2);
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'rankingPage')"> const boss = escapeHtml(r.boss);
return `<div class="hbar-row" onclick="openBossDetail(${jsString(r.boss)}, 'rankingPage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.boss}">${r.boss}</div> <div class="hbar-label" title="${escapeAttr(r.boss)}">${boss}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.take_count}单</div> <div class="hbar-value">${r.take_count}单</div>
</div>`; </div>`;
@@ -51,9 +53,10 @@ async function loadRankingDetail() {
const max = Math.max(...rows.map(r => r.total_income), 1); const max = Math.max(...rows.map(r => r.total_income), 1);
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.total_income / max) * 100, 2); const pct = Math.max((r.total_income / max) * 100, 2);
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'rankingPage')"> const boss = escapeHtml(r.boss);
return `<div class="hbar-row" onclick="openBossDetail(${jsString(r.boss)}, 'rankingPage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.boss}">${r.boss}</div> <div class="hbar-label" title="${escapeAttr(r.boss)}">${boss}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.total_income.toFixed(2)}</div> <div class="hbar-value">${r.total_income.toFixed(2)}</div>
</div>`; </div>`;
+3 -4
View File
@@ -123,11 +123,12 @@ async function submitRecord() {
const partner = document.getElementById('fPartner')?.value.trim() || ''; const partner = document.getElementById('fPartner')?.value.trim() || '';
if (currentCategory === '派单') { if (currentCategory === '派单') {
const rebate = parseFloat(document.getElementById('fRebate').value); const rebate = parseFloat(document.getElementById('fRebate').value);
if (!quantity || !unit_price || !rebate || !boss) { showToast('请填写完整信息'); return; } if (!quantity || !unit_price || !rebate || !boss || !partner) { showToast('请填写完整信息(包括负责打单的陪玩)'); return; }
body.quantity = quantity; body.quantity = quantity;
body.unit_price = unit_price; body.unit_price = unit_price;
body.rebate = rebate; body.rebate = rebate;
body.amount = quantity * rebate; body.amount = quantity * rebate;
body.partner = partner;
} else { } else {
if (!quantity || !unit_price || !boss) { showToast('请填写完整信息'); return; } if (!quantity || !unit_price || !boss) { showToast('请填写完整信息'); return; }
body.quantity = quantity; body.quantity = quantity;
@@ -135,7 +136,6 @@ async function submitRecord() {
body.amount = quantity * unit_price; body.amount = quantity * unit_price;
} }
body.boss = boss; body.boss = boss;
body.partner = partner;
} else if (currentCategory === '红包收入' || currentCategory === '奶茶') { } else if (currentCategory === '红包收入' || currentCategory === '奶茶') {
const amount = parseFloat(document.getElementById('fAmount').value); const amount = parseFloat(document.getElementById('fAmount').value);
const source = document.getElementById('fSource').value.trim(); const source = document.getElementById('fSource').value.trim();
@@ -159,9 +159,8 @@ async function submitRecord() {
} }
try { try {
const res = await fetch(API + '/api/records', { const res = await apiFetch(API + '/api/records', {
method: 'POST', method: 'POST',
headers: headers(),
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
if (!res.ok) { showToast('保存失败'); return; } if (!res.ok) { showToast('保存失败'); return; }
+73 -63
View File
@@ -2,10 +2,10 @@ async function loadStats() {
try { try {
// 并行加载所有数据 // 并行加载所有数据
const [monthlyRes, bossRankRes, partnerRankRes, weeklyRes] = await Promise.all([ const [monthlyRes, bossRankRes, partnerRankRes, weeklyRes] = await Promise.all([
fetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }), apiFetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth),
fetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }), apiFetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth),
fetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }), apiFetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth),
fetch(API + '/api/stats/daily-income', { headers: headers() }) apiFetch(API + '/api/stats/daily-income')
]); ]);
if (monthlyRes.ok) { if (monthlyRes.ok) {
@@ -40,9 +40,38 @@ async function loadStats() {
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
function recordLineText(r, fallbackField) {
if (r.quantity) {
if (r.category === '派单') {
const rebate = (r.rebate || r.amount / r.quantity).toFixed(2);
return r.quantity + '单 × ' + rebate + '(单价' + (r.unit_price || 0).toFixed(2) + '';
}
return r.quantity + '单 × ' + (r.unit_price || 0).toFixed(2);
}
return r[fallbackField] || '';
}
function renderRecordItem(r, opts = {}) {
const time = opts.time || formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
const suffix = opts.suffix || '';
const line = opts.line !== undefined ? opts.line : recordLineText(r, 'note');
const amountPrefix = opts.amountPrefix || '';
return `<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="${recordArg(r)}">
<div class="order-item-top">
<span class="order-item-boss">${escapeHtml(displayCategory + suffix)}</span>
<span class="order-item-amount">${amountPrefix}${(r.amount || 0).toFixed(2)}</span>
</div>
<div class="order-item-bottom">
<span>${escapeHtml(line)}</span>
<span>${escapeHtml(time + (opts.partnerTime && r.partner ? ' · ' + r.partner : ''))}</span>
</div>
</div>`;
}
async function loadWeeklyChart() { async function loadWeeklyChart() {
try { try {
const res = await fetch(API + '/api/stats/daily-income', { headers: headers() }); const res = await apiFetch(API + '/api/stats/daily-income');
if (!res.ok) return; if (!res.ok) return;
const days = await res.json(); const days = await res.json();
renderWeeklyChart(days); renderWeeklyChart(days);
@@ -61,7 +90,7 @@ function renderWeeklyChart(days) {
const height = Math.max((d.income / max) * barMaxHeight, 4); const height = Math.max((d.income / max) * barMaxHeight, 4);
const label = d.date.slice(5).replace('-', '/'); const label = d.date.slice(5).replace('-', '/');
const amount = d.income > 0 ? '' + d.income.toFixed(0) : '0'; const amount = d.income > 0 ? '' + d.income.toFixed(0) : '0';
return `<div class="weekly-bar-col" onclick="openDailyDetail('${d.date}')"> return `<div class="weekly-bar-col" onclick="openDailyDetail(${jsString(d.date)})">
<span class="weekly-bar-amount">${amount}</span> <span class="weekly-bar-amount">${amount}</span>
<div class="weekly-bar" style="height:${height}px"></div> <div class="weekly-bar" style="height:${height}px"></div>
<span class="weekly-bar-date">${label}</span> <span class="weekly-bar-date">${label}</span>
@@ -81,7 +110,7 @@ async function openDailyDetail(date) {
showPage('dailyDetailPage'); showPage('dailyDetailPage');
} }
try { try {
const res = await fetch(API + '/api/stats/daily-detail?date=' + date, { headers: headers() }); const res = await apiFetch(API + '/api/stats/daily-detail?date=' + date);
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
document.getElementById('dailySummary').innerHTML = ` document.getElementById('dailySummary').innerHTML = `
@@ -95,17 +124,12 @@ async function openDailyDetail(date) {
} }
container.innerHTML = data.records.map(r => { container.innerHTML = data.records.map(r => {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-'); return renderRecordItem(r, {
return `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'> time,
<div class="order-item-top"> suffix: r.boss ? ' · ' + r.boss : '',
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span> line: recordLineText(r, 'source'),
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span> partnerTime: true
</div> });
<div class="order-item-bottom">
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + '' : (r.unit_price || 0).toFixed(2)) : (r.source || '')}</span>
<span>${time}${r.partner ? ' · ' + r.partner : ''}</span>
</div>
</div>`;
}).join(''); }).join('');
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
@@ -144,7 +168,7 @@ async function loadYearlyNetIncome() {
} }
try { try {
const res = await fetch(API + '/api/stats/yearly-net-income', { headers: headers() }); const res = await apiFetch(API + '/api/stats/yearly-net-income');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
@@ -214,7 +238,7 @@ async function loadMonthlyDetail(type) {
} }
try { try {
const res = await fetch(API + '/api/stats/monthly-records?type=' + type + '&year=' + monthlyDetailYear + '&month=' + monthlyDetailMonth, { headers: headers() }); const res = await apiFetch(API + '/api/stats/monthly-records?type=' + type + '&year=' + monthlyDetailYear + '&month=' + monthlyDetailMonth + '&limit=1000');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const color = type === 'income' ? '#27ae60' : '#e74c3c'; const color = type === 'income' ? '#27ae60' : '#e74c3c';
@@ -238,17 +262,13 @@ async function loadMonthlyDetail(type) {
html += `<div class="order-date-group"><div class="order-date-label">${date}</div>`; html += `<div class="order-date-group"><div class="order-date-label">${date}</div>`;
records.forEach(r => { records.forEach(r => {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-'); html += renderRecordItem(r, {
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'> time,
<div class="order-item-top"> suffix: r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : '',
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : ''}</span> line: recordLineText(r, 'note'),
<span class="order-item-amount">${type === 'expense' ? '-' : ''}${(r.amount || 0).toFixed(2)}</span> amountPrefix: type === 'expense' ? '-' : '',
</div> partnerTime: true
<div class="order-item-bottom"> });
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + '' : (r.unit_price || 0).toFixed(2)) : (r.note || '')}</span>
<span>${time}${r.partner ? ' · ' + r.partner : ''}</span>
</div>
</div>`;
}); });
html += '</div>'; html += '</div>';
} }
@@ -258,7 +278,7 @@ async function loadMonthlyDetail(type) {
async function loadBossRankingPreview() { async function loadBossRankingPreview() {
try { try {
const res = await fetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); const res = await apiFetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth);
if (!res.ok) return; if (!res.ok) return;
const rows = await res.json(); const rows = await res.json();
renderBossRankingPreview(rows); renderBossRankingPreview(rows);
@@ -275,11 +295,12 @@ function renderHBarRanking(rows, { containerId, getValue, getLabel, onClick, for
const max = Math.max(...rows.map(r => getValue(r)), 1); const max = Math.max(...rows.map(r => getValue(r)), 1);
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max((getValue(r) / max) * 100, 2); const pct = Math.max((getValue(r) / max) * 100, 2);
const label = getLabel(r).replace(/'/g, "\\'"); const rawLabel = getLabel(r);
const label = escapeHtml(rawLabel);
const value = formatValue ? formatValue(getValue(r)) : (typeof getValue(r) === 'number' ? getValue(r).toFixed(2) : getValue(r)); const value = formatValue ? formatValue(getValue(r)) : (typeof getValue(r) === 'number' ? getValue(r).toFixed(2) : getValue(r));
return `<div class="hbar-row" onclick="${onClick}('${label}', 'homePage')"> return `<div class="hbar-row" onclick="${onClick}(${jsString(rawLabel)}, 'homePage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${label}">${label}</div> <div class="hbar-label" title="${escapeAttr(rawLabel)}">${label}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${value}</div> <div class="hbar-value">${value}</div>
</div>`; </div>`;
@@ -392,7 +413,7 @@ async function loadBossDetail(append) {
} }
try { try {
const res = await fetch(url, { headers: headers() }); const res = await apiFetch(url);
if (!res.ok) { bossDetailLoading = false; return; } if (!res.ok) { bossDetailLoading = false; return; }
const data = await res.json(); const data = await res.json();
@@ -447,17 +468,11 @@ async function loadBossDetail(append) {
html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`; html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`;
group.records.forEach(r => { group.records.forEach(r => {
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at); const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : r.category; html += renderRecordItem(r, {
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'> time,
<div class="order-item-top"> suffix: r.partner ? ' · ' + r.partner : '',
<span class="order-item-boss">${displayCategory}${r.partner ? ' · ' + r.partner : ''}</span> line: recordLineText(r, 'note')
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span> });
</div>
<div class="order-item-bottom">
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + '' : (r.unit_price || 0).toFixed(2)) : (r.note || '')}</span>
<span>${time}</span>
</div>
</div>`;
}); });
html += '</div>'; html += '</div>';
} }
@@ -502,7 +517,7 @@ function setupBossDetailScroll() {
async function loadPartnerRankingPreview() { async function loadPartnerRankingPreview() {
try { try {
const res = await fetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); const res = await apiFetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth);
if (!res.ok) return; if (!res.ok) return;
const rows = await res.json(); const rows = await res.json();
renderPartnerRankingPreview(rows); renderPartnerRankingPreview(rows);
@@ -542,7 +557,7 @@ async function loadPartnerRankingDetail() {
} }
if (params.length) url += '?' + params.join('&'); if (params.length) url += '?' + params.join('&');
try { try {
const res = await fetch(url, { headers: headers() }); const res = await apiFetch(url);
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const rows = data.rows || []; const rows = data.rows || [];
@@ -565,9 +580,10 @@ async function loadPartnerRankingDetail() {
const max = Math.max(...rows.map(r => r.dispatch_count), 1); const max = Math.max(...rows.map(r => r.dispatch_count), 1);
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.dispatch_count / max) * 100, 2); const pct = Math.max((r.dispatch_count / max) * 100, 2);
return `<div class="hbar-row" onclick="openPartnerDetail('${r.partner.replace(/'/g, "\\'")}', 'partnerRankingPage')"> const partner = escapeHtml(r.partner);
return `<div class="hbar-row" onclick="openPartnerDetail(${jsString(r.partner)}, 'partnerRankingPage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.partner}">${r.partner}</div> <div class="hbar-label" title="${escapeAttr(r.partner)}">${partner}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.dispatch_count}单</div> <div class="hbar-value">${r.dispatch_count}单</div>
</div>`; </div>`;
@@ -667,7 +683,7 @@ async function loadPartnerDetail(append) {
} }
try { try {
const res = await fetch(url, { headers: headers() }); const res = await apiFetch(url);
if (!res.ok) { partnerDetailLoading = false; return; } if (!res.ok) { partnerDetailLoading = false; return; }
const data = await res.json(); const data = await res.json();
@@ -722,17 +738,11 @@ async function loadPartnerDetail(append) {
html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`; html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`;
group.records.forEach(r => { group.records.forEach(r => {
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at); const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : r.category; html += renderRecordItem(r, {
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'> time,
<div class="order-item-top"> suffix: r.boss ? ' · ' + r.boss : '',
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span> line: recordLineText(r, 'source')
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span> });
</div>
<div class="order-item-bottom">
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + '' : (r.unit_price || 0).toFixed(2)) : (r.source || '')}</span>
<span>${time}</span>
</div>
</div>`;
}); });
html += '</div>'; html += '</div>';
} }
+67 -5
View File
@@ -1,7 +1,11 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
const compression = require('compression');
const config = require('./config');
const logger = require('./utils/logger');
const { clearUserCache } = require('./utils/cache');
const db = require('./db'); const db = require('./db');
const { router: authRouter, auth } = require('./routes/auth'); const { router: authRouter, auth } = require('./routes/auth');
const statsRouter = require('./routes/stats'); const statsRouter = require('./routes/stats');
@@ -10,11 +14,42 @@ const ioRouter = require('./routes/io');
const app = express(); const app = express();
app.use(cors()); app.set('trust proxy', config.trustProxy);
// 请求日志中间件
app.use(logger.requestLogger);
app.use(cors({
origin(origin, callback) {
if (!origin || config.corsOrigins.includes(origin)) {
return callback(null, true);
}
return callback(null, false);
}
}));
app.use(express.json()); app.use(express.json());
app.use(express.static(path.join(__dirname, '..', 'public'))); app.use(compression());
app.use(express.static(path.join(__dirname, '..', 'public'), {
maxAge: '1h',
etag: true
}));
// 联系人自动补全 // 联系人自动补全
app.use('/api', (req, res, next) => {
res.setHeader('Cache-Control', 'no-cache');
next();
});
// 全局缓存清除中间件:在写操作完成后清除用户缓存
app.use('/api', (req, res, next) => {
res.once('finish', () => {
if (['POST', 'DELETE', 'PUT', 'PATCH'].includes(req.method) && req.user?.id) {
clearUserCache(req.user.id);
}
});
next();
});
app.get('/api/contacts', auth, (req, res) => { app.get('/api/contacts', auth, (req, res) => {
const field = req.query.field; const field = req.query.field;
const allowed = ['boss', 'partner', 'source', 'destination']; const allowed = ['boss', 'partner', 'source', 'destination'];
@@ -34,7 +69,34 @@ app.use('/api/stats', auth, statsRouter);
app.use('/api/records', auth, recordsRouter); app.use('/api/records', auth, recordsRouter);
app.use('/api', auth, ioRouter); app.use('/api', auth, ioRouter);
const PORT = process.env.PORT || 3000; // 404 处理
app.listen(PORT, '0.0.0.0', () => { app.use((req, res) => {
console.log(`服务器运行在 http://0.0.0.0:${PORT}`); res.status(404).json({ error: '接口不存在' });
});
// 全局错误处理中间件
app.use((err, req, res, next) => {
// 记录错误日志
logger.error('Unhandled error:', err.message, err.stack);
// 处理特定错误类型
if (err.name === 'UnauthorizedError') {
return res.status(401).json({ error: '未授权访问' });
}
if (err.name === 'SyntaxError' && err.status === 400 && 'body' in err) {
return res.status(400).json({ error: 'JSON 格式错误' });
}
// 默认错误响应
const status = err.status || err.statusCode || 500;
const message = err.expose ? err.message : '服务器内部错误';
res.status(status).json({ error: message });
});
const PORT = config.port;
const HOST = config.host;
app.listen(PORT, HOST, () => {
logger.info(`服务器运行在 http://${HOST}:${PORT}`);
}); });
+60
View File
@@ -0,0 +1,60 @@
/**
* 应用配置
*/
const isProduction = process.env.NODE_ENV === 'production';
const jwtSecret = process.env.JWT_SECRET || '';
if (isProduction && !jwtSecret) {
throw new Error('JWT_SECRET must be set in production');
}
module.exports = {
// 服务器配置
port: process.env.PORT || 3000,
host: process.env.HOST || (isProduction ? '127.0.0.1' : '0.0.0.0'),
trustProxy: process.env.TRUST_PROXY || (isProduction ? 'loopback' : false),
// JWT 配置
jwtSecret: jwtSecret || 'dev-only-gamer-order-secret',
jwtExpiresIn: '7d',
// 注册默认关闭,生产环境可通过 ENABLE_REGISTRATION=true 显式开启
enableRegistration: process.env.ENABLE_REGISTRATION === 'true',
// CORS 配置:默认同源;如需跨域,用逗号分隔配置 CORS_ORIGINS
corsOrigins: (process.env.CORS_ORIGINS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean),
// 缓存配置
cache: {
ttl: 2 * 60 * 1000, // 缓存有效期 2 分钟
maxSize: 100 // 最大缓存条目数
},
// 分页配置
pagination: {
defaultLimit: 20, // 默认每页条数
maxLimit: 100 // 最大每页条数
},
import: {
maxRecords: 5000
},
// 日志配置
log: {
level: process.env.LOG_LEVEL || 'info', // debug, info, warn, error
colorize: true
},
// 允许的类别
categories: {
income: ['接单', '派单', '红包收入', '奶茶'],
expense: ['红包支出', '比心', '其他支出']
},
// 允许的联系人字段
contactFields: ['boss', 'partner', 'source', 'destination']
};
+110 -72
View File
@@ -1,92 +1,130 @@
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const path = require('path'); const path = require('path');
const logger = require('./utils/logger');
const db = new Database(path.join(__dirname, '..', 'data.db')); const db = new Database(path.join(__dirname, '..', 'data.db'));
// 启用 WAL 模式和外键约束
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
// 创建表 /**
db.exec(` * 创建表结构
CREATE TABLE IF NOT EXISTS users ( */
id INTEGER PRIMARY KEY AUTOINCREMENT, function createTables() {
username TEXT UNIQUE NOT NULL, db.exec(`
password TEXT NOT NULL, CREATE TABLE IF NOT EXISTS users (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP id INTEGER PRIMARY KEY AUTOINCREMENT,
); username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS records ( CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK(type IN ('income', 'expense')), type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
category TEXT NOT NULL, category TEXT NOT NULL,
amount REAL NOT NULL, amount REAL NOT NULL,
quantity INTEGER, quantity INTEGER,
unit_price REAL, unit_price REAL,
boss TEXT, boss TEXT,
partner TEXT, partner TEXT,
source TEXT, source TEXT,
destination TEXT, destination TEXT,
note TEXT, note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
); );
CREATE INDEX IF NOT EXISTS idx_records_user_id ON records(user_id); CREATE INDEX IF NOT EXISTS idx_records_user_id ON records(user_id);
CREATE INDEX IF NOT EXISTS idx_records_category ON records(category); CREATE INDEX IF NOT EXISTS idx_records_category ON records(category);
CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at); CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at);
CREATE INDEX IF NOT EXISTS idx_records_type ON records(type); CREATE INDEX IF NOT EXISTS idx_records_type ON records(type);
CREATE INDEX IF NOT EXISTS idx_records_boss ON records(boss); CREATE INDEX IF NOT EXISTS idx_records_boss ON records(boss);
CREATE INDEX IF NOT EXISTS idx_records_partner ON records(partner); CREATE INDEX IF NOT EXISTS idx_records_partner ON records(partner);
CREATE INDEX IF NOT EXISTS idx_records_source ON records(source); CREATE INDEX IF NOT EXISTS idx_records_source ON records(source);
`); CREATE INDEX IF NOT EXISTS idx_records_user_date ON records(user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_records_user_category ON records(user_id, category, created_at);
CREATE INDEX IF NOT EXISTS idx_records_destination ON records(destination);
`);
logger.debug('表结构创建完成');
}
// 迁移:去掉 category 的 CHECK 约束 /**
try { * 迁移:去掉 category 的 CHECK 约束
const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get(); */
if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) { function migrateRemoveCategoryCheck() {
db.exec(` try {
CREATE TABLE records_new ( const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get();
id INTEGER PRIMARY KEY AUTOINCREMENT, if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) {
user_id INTEGER NOT NULL, logger.info('执行迁移:移除 category CHECK 约束');
type TEXT NOT NULL CHECK(type IN ('income', 'expense')), db.exec(`
category TEXT NOT NULL, CREATE TABLE records_new (
amount REAL NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
quantity INTEGER, user_id INTEGER NOT NULL,
unit_price REAL, type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
boss TEXT, category TEXT NOT NULL,
partner TEXT, amount REAL NOT NULL,
source TEXT, quantity INTEGER,
destination TEXT, unit_price REAL,
note TEXT, boss TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, partner TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) source TEXT,
); destination TEXT,
INSERT INTO records_new SELECT * FROM records; note TEXT,
DROP TABLE records; created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
ALTER TABLE records_new RENAME TO records; FOREIGN KEY (user_id) REFERENCES users(id)
`); );
INSERT INTO records_new SELECT * FROM records;
DROP TABLE records;
ALTER TABLE records_new RENAME TO records;
`);
logger.info('迁移完成:category CHECK 约束已移除');
}
} catch (e) {
logger.warn('迁移跳过 (category):', e.message);
} }
} catch (e) {
console.log('Migration skipped:', e.message);
} }
// 迁移:添加 rebate 列(派单返点) /**
try { * 迁移:添加 rebate 列
const cols = db.prepare("PRAGMA table_info(records)").all(); */
if (!cols.find(c => c.name === 'rebate')) { function migrateAddRebateColumn() {
db.exec("ALTER TABLE records ADD COLUMN rebate REAL"); try {
const cols = db.prepare("PRAGMA table_info(records)").all();
if (!cols.find(c => c.name === 'rebate')) {
logger.info('执行迁移:添加 rebate 列');
db.exec("ALTER TABLE records ADD COLUMN rebate REAL");
logger.info('迁移完成:rebate 列已添加');
}
} catch (e) {
logger.warn('迁移跳过 (rebate):', e.message);
} }
} catch (e) {
console.log('Rebate migration skipped:', e.message);
} }
// 初始化默认用户 lizhilun /**
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun'); * 初始化默认用户
if (!existingUser) { */
const hash = bcrypt.hashSync('lizhilun', 10); function initDefaultUser() {
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash); if (process.env.NODE_ENV === 'production') {
logger.info('生产环境跳过默认用户初始化');
return;
}
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun');
if (!existingUser) {
const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync('lizhilun', 10);
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash);
logger.info('开发默认用户 lizhilun 已创建');
}
} }
// 执行初始化
createTables();
migrateRemoveCategoryCheck();
migrateAddRebateColumn();
initDefaultUser();
module.exports = db; module.exports = db;
+92 -3
View File
@@ -2,16 +2,54 @@ const express = require('express');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const db = require('../db'); const db = require('../db');
const config = require('../config');
const logger = require('../utils/logger');
const router = express.Router(); const router = express.Router();
const SECRET = 'gamer-order-secret-2024';
const loginAttempts = new Map();
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const LOGIN_MAX_FAILURES = 5;
function normalizeLoginKey(req, username) {
return `${req.ip || req.socket.remoteAddress || 'unknown'}:${String(username || '').trim().toLowerCase()}`;
}
function getLoginAttempt(key) {
const now = Date.now();
const attempt = loginAttempts.get(key);
if (!attempt || attempt.resetAt <= now) {
const fresh = { failures: 0, resetAt: now + LOGIN_WINDOW_MS };
loginAttempts.set(key, fresh);
return fresh;
}
return attempt;
}
function clearExpiredLoginAttempts() {
const now = Date.now();
for (const [key, attempt] of loginAttempts) {
if (attempt.resetAt <= now) loginAttempts.delete(key);
}
}
function recordFailedLogin(key) {
const attempt = getLoginAttempt(key);
attempt.failures += 1;
if (loginAttempts.size > 1000) clearExpiredLoginAttempts();
return attempt;
}
function resetLoginAttempts(key) {
loginAttempts.delete(key);
}
// JWT 中间件 // JWT 中间件
function auth(req, res, next) { function auth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1]; const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: '未登录' }); if (!token) return res.status(401).json({ error: '未登录' });
try { try {
req.user = jwt.verify(token, SECRET); req.user = jwt.verify(token, config.jwtSecret);
next(); next();
} catch { } catch {
res.status(401).json({ error: '登录已过期' }); res.status(401).json({ error: '登录已过期' });
@@ -21,12 +59,63 @@ function auth(req, res, next) {
// 登录 // 登录
router.post('/login', (req, res) => { router.post('/login', (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' });
}
const loginKey = normalizeLoginKey(req, username);
const attempt = getLoginAttempt(loginKey);
if (attempt.failures >= LOGIN_MAX_FAILURES) {
const retryAfter = Math.ceil((attempt.resetAt - Date.now()) / 1000);
res.setHeader('Retry-After', String(Math.max(retryAfter, 1)));
return res.status(429).json({ error: '登录失败次数过多,请稍后再试' });
}
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user || !bcrypt.compareSync(password, user.password)) { if (!user || !bcrypt.compareSync(password, user.password)) {
recordFailedLogin(loginKey);
logger.warn(`登录失败: ${username}`);
return res.status(401).json({ error: '用户名或密码错误' }); return res.status(401).json({ error: '用户名或密码错误' });
} }
const token = jwt.sign({ id: user.id, username: user.username }, SECRET, { expiresIn: '7d' });
resetLoginAttempts(loginKey);
const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
logger.info(`用户登录: ${username}`);
res.json({ token, username: user.username }); res.json({ token, username: user.username });
}); });
// 注册
router.post('/register', (req, res) => {
if (!config.enableRegistration) {
return res.status(403).json({ error: '注册已关闭' });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' });
}
if (username.length < 2 || username.length > 20) {
return res.status(400).json({ error: '用户名长度需在2-20个字符之间' });
}
if (password.length < 6) {
return res.status(400).json({ error: '密码长度不能少于6个字符' });
}
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing) {
return res.status(409).json({ error: '用户名已存在' });
}
const hash = bcrypt.hashSync(password, 10);
const result = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run(username, hash);
logger.info(`新用户注册: ${username}`);
const token = jwt.sign({ id: result.lastInsertRowid, username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
res.json({ token, username });
});
module.exports = { router, auth }; module.exports = { router, auth };
+160 -75
View File
@@ -1,61 +1,93 @@
const express = require('express'); const express = require('express');
const db = require('../db'); const db = require('../db');
const { parseCSVLine } = require('../utils/helpers'); const config = require('../config');
const logger = require('../utils/logger');
const {
parseCSVLine,
AppError,
validateNumber,
validateInList,
validateIsoDate,
optionalString
} = require('../utils/helpers');
const router = express.Router(); const router = express.Router();
function emptyToNull(value) {
return value === undefined || value === null || value === '' ? null : value;
}
function nullableDbValue(value) {
return value === undefined || value === null || value === '' ? null : value;
}
const csvTextFields = new Set(['type', 'category', 'boss', 'partner', 'source', 'destination', 'note', 'created_at']);
function escapeCsvFormula(value) {
return /^[\t\r=+\-@]/.test(value) || /^[ \t]+[=+\-@]/.test(value)
? `'${value}`
: value;
}
function encodeCsvCell(value, col) {
if (value === null || value === undefined) return '';
const str = csvTextFields.has(col) ? escapeCsvFormula(String(value)) : String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
// 导出数据 // 导出数据
router.get('/export', (req, res) => { router.get('/export', (req, res, next) => {
const format = req.query.format; try {
if (!format || !['json', 'csv'].includes(format)) { const format = req.query.format;
return res.status(400).json({ error: '请指定格式: json 或 csv' }); if (!format || !['json', 'csv'].includes(format)) {
throw new AppError('请指定格式: json 或 csv', 400);
}
const records = db.prepare(
'SELECT type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at FROM records WHERE user_id = ? ORDER BY created_at DESC'
).all(req.user.id);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `gamer-export-${timestamp}`;
if (format === 'json') {
const exportData = {
exportVersion: 1,
exportedAt: new Date().toISOString(),
username: req.user.username,
recordCount: records.length,
records
};
logger.info(`用户 ${req.user.username} 导出 JSON 数据: ${records.length}`);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
return res.send(JSON.stringify(exportData, null, 2));
}
// CSV with UTF-8 BOM
const BOM = '\uFEFF';
const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at'];
const csvRows = records.map(r => {
return cols.map(col => encodeCsvCell(r[col], col)).join(',');
});
logger.info(`用户 ${req.user.username} 导出 CSV 数据: ${records.length}`);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n'));
} catch (err) {
next(err);
} }
const records = db.prepare(
'SELECT type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at FROM records WHERE user_id = ? ORDER BY created_at DESC'
).all(req.user.id);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `gamer-export-${timestamp}`;
if (format === 'json') {
const exportData = {
exportVersion: 1,
exportedAt: new Date().toISOString(),
username: req.user.username,
recordCount: records.length,
records
};
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
return res.send(JSON.stringify(exportData, null, 2));
}
// CSV with UTF-8 BOM
const BOM = '\uFEFF';
const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at'];
const csvRows = records.map(r => {
return cols.map(col => {
const val = r[col];
if (val === null || val === undefined) return '';
const str = String(val);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}).join(',');
});
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n'));
}); });
// 导入数据 // 导入数据
router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res) => { router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res, next) => {
try { try {
const raw = req.body; const raw = req.body;
if (!raw || typeof raw !== 'string') { if (!raw || typeof raw !== 'string') {
return res.status(400).json({ error: '未收到文件内容' }); throw new AppError('未收到文件内容', 400);
} }
let records = []; let records = [];
@@ -66,45 +98,79 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res)
if (trimmed.startsWith('{')) { if (trimmed.startsWith('{')) {
const data = JSON.parse(trimmed); const data = JSON.parse(trimmed);
if (!data.exportVersion || !Array.isArray(data.records)) { if (!data.exportVersion || !Array.isArray(data.records)) {
return res.status(400).json({ error: '无效的JSON导出文件格式' }); throw new AppError('无效的JSON导出文件格式', 400);
} }
records = data.records; records = data.records;
} else { } else {
const lines = trimmed.split('\n').map(l => l.replace(/\r$/, '')); const lines = trimmed.split('\n').map(l => l.replace(/\r$/, ''));
if (lines.length < 2) { if (lines.length < 2) {
return res.status(400).json({ error: 'CSV文件为空或格式不正确' }); throw new AppError('CSV文件为空或格式不正确', 400);
} }
if (lines[0] !== allFields.join(',')) { if (lines[0] !== allFields.join(',')) {
return res.status(400).json({ error: 'CSV列头不匹配,请使用本系统导出的文件' }); throw new AppError('CSV列头不匹配,请使用本系统导出的文件', 400);
} }
const csvHeaders = lines[0].split(','); const csvHeaders = lines[0].split(',');
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue; if (!lines[i].trim()) continue;
const values = parseCSVLine(lines[i]); const values = parseCSVLine(lines[i]);
const record = {}; const record = {};
csvHeaders.forEach((h, idx) => { record[h] = values[idx] || null; }); csvHeaders.forEach((h, idx) => { record[h] = emptyToNull(values[idx]); });
if (record.amount) record.amount = parseFloat(record.amount); if (record.amount !== null) record.amount = parseFloat(record.amount);
if (record.quantity) record.quantity = parseInt(record.quantity) || null; if (record.quantity !== null) record.quantity = parseInt(record.quantity, 10);
if (record.unit_price) record.unit_price = parseFloat(record.unit_price) || null; if (record.unit_price !== null) record.unit_price = parseFloat(record.unit_price);
if (record.rebate) record.rebate = parseFloat(record.rebate) || null; if (record.rebate !== null) record.rebate = parseFloat(record.rebate);
records.push(record); records.push(record);
} }
} }
for (const r of records) { if (records.length > config.import.maxRecords) {
for (const f of requiredFields) { throw new AppError(`单次最多导入 ${config.import.maxRecords} 条记录`, 400);
if (!r[f] && r[f] !== 0) {
return res.status(400).json({ error: `记录缺少必填字段: ${f}` });
}
}
if (!['income', 'expense'].includes(r.type)) {
return res.status(400).json({ error: `无效的类型: ${r.type}` });
}
} }
const existingCheck = db.prepare( const allCategories = [...config.categories.income, ...config.categories.expense];
'SELECT id FROM records WHERE user_id = ? AND created_at = ? AND category = ? AND amount = ?' const normalizedRecords = records.map((r, index) => {
); for (const f of requiredFields) {
if (!r[f] && r[f] !== 0) {
throw new AppError(`${index + 1} 条记录缺少必填字段: ${f}`, 400);
}
}
validateInList(r.type, 'type', ['income', 'expense']);
validateInList(r.category, 'category', allCategories);
const amount = r.category === '派单'
? validateNumber(r.amount, '金额')
: validateNumber(r.amount, '金额', 0);
return {
type: r.type,
category: r.category,
amount,
quantity: r.quantity === undefined || r.quantity === null || r.quantity === '' ? null : validateNumber(r.quantity, '数量', 0),
unit_price: r.unit_price === undefined || r.unit_price === null || r.unit_price === '' ? null : validateNumber(r.unit_price, '单价', 0),
rebate: r.rebate === undefined || r.rebate === null || r.rebate === '' ? null : validateNumber(r.rebate, '返点'),
boss: optionalString(r.boss, '老板', 100),
partner: optionalString(r.partner, '陪玩', 100),
source: optionalString(r.source, '来源', 100),
destination: optionalString(r.destination, '去向', 100),
note: optionalString(r.note, '备注', 500),
created_at: validateIsoDate(r.created_at, '创建时间')
};
});
const existingCheck = db.prepare(`
SELECT id FROM records
WHERE user_id = ?
AND type = ?
AND category = ?
AND amount = ?
AND COALESCE(quantity, '') = COALESCE(?, '')
AND COALESCE(unit_price, '') = COALESCE(?, '')
AND COALESCE(rebate, '') = COALESCE(?, '')
AND COALESCE(boss, '') = COALESCE(?, '')
AND COALESCE(partner, '') = COALESCE(?, '')
AND COALESCE(source, '') = COALESCE(?, '')
AND COALESCE(destination, '') = COALESCE(?, '')
AND COALESCE(note, '') = COALESCE(?, '')
AND created_at = ?
`);
const insertStmt = db.prepare(` const insertStmt = db.prepare(`
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at) INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -113,23 +179,42 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res)
let imported = 0, skipped = 0; let imported = 0, skipped = 0;
const insertMany = db.transaction((recs) => { const insertMany = db.transaction((recs) => {
for (const r of recs) { for (const r of recs) {
if (existingCheck.get(req.user.id, r.created_at, r.category, r.amount)) { const values = [
req.user.id,
r.type,
r.category,
r.amount,
r.quantity,
r.unit_price,
r.rebate,
r.boss,
r.partner,
r.source,
r.destination,
r.note,
r.created_at
];
if (existingCheck.get(...values)) {
skipped++; continue; skipped++; continue;
} }
insertStmt.run(req.user.id, r.type, r.category, r.amount, insertStmt.run(req.user.id, r.type, r.category, r.amount,
r.quantity || null, r.unit_price || null, r.rebate || null, nullableDbValue(r.quantity), nullableDbValue(r.unit_price), nullableDbValue(r.rebate),
r.boss || null, r.partner || null, nullableDbValue(r.boss), nullableDbValue(r.partner),
r.source || null, r.destination || null, nullableDbValue(r.source), nullableDbValue(r.destination),
r.note || null, r.created_at); nullableDbValue(r.note), r.created_at);
imported++; imported++;
} }
}); });
insertMany(records); insertMany(normalizedRecords);
res.json({ imported, skipped, total: records.length }); logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped}`);
} catch (e) { res.json({ imported, skipped, total: normalizedRecords.length });
console.error('Import error:', e); } catch (err) {
res.status(400).json({ error: '文件解析失败: ' + e.message }); if (err instanceof SyntaxError) {
next(new AppError('文件解析失败: ' + err.message, 400));
} else {
next(err);
}
} }
}); });
+143 -30
View File
@@ -1,24 +1,74 @@
const express = require('express'); const express = require('express');
const db = require('../db'); const db = require('../db');
const { getMonthRange } = require('../utils/helpers'); const config = require('../config');
const logger = require('../utils/logger');
const {
getMonthRange,
AppError,
validateRequired,
validateNumber,
validateInList,
validatePage,
validateLimit,
validateIsoDate,
optionalString
} = require('../utils/helpers');
const router = express.Router(); const router = express.Router();
// 新增记录 // 新增记录
router.post('/', (req, res) => { router.post('/', (req, res, next) => {
const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body; try {
const stmt = db.prepare(` const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body;
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) // 输入验证
`); validateRequired(req.body, ['type', 'category', 'amount']);
const result = stmt.run(req.user.id, type, category, amount, quantity || null, unit_price || null, rebate || null, boss || null, partner || null, source || null, destination || null, note || null, created_at || new Date().toISOString()); validateInList(type, 'type', ['income', 'expense']);
res.json({ id: result.lastInsertRowid });
const allCategories = [...config.categories.income, ...config.categories.expense];
validateInList(category, 'category', allCategories);
// 派单的返点可为负数,因此金额也允许为负数
const validatedAmount = category === '派单'
? validateNumber(amount, '金额')
: validateNumber(amount, '金额', 0);
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
const stmt = db.prepare(`
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
req.user.id,
type,
category,
validatedAmount,
validatedQuantity,
validatedUnitPrice,
validatedRebate,
optionalString(boss, '老板', 100),
optionalString(partner, '陪玩', 100),
optionalString(source, '来源', 100),
optionalString(destination, '去向', 100),
optionalString(note, '备注', 500),
validatedCreatedAt
);
logger.info(`用户 ${req.user.username} 新增记录: ${category} ${validatedAmount}`);
res.json({ id: result.lastInsertRowid });
} catch (err) {
next(err);
}
}); });
// 通用分页查询函数 // 通用分页查询函数
function queryPagedRecords(userId, year, month, options) { function queryPagedRecords(userId, year, month, options) {
const { page = 1, limit = 20 } = options; const page = validatePage(options.page, 1);
const offset = (Number(page) - 1) * Number(limit); const limit = validateLimit(options.limit, config.pagination.defaultLimit, config.pagination.maxLimit);
const offset = (page - 1) * limit;
const { startDate, endDate } = getMonthRange(year, month); const { startDate, endDate } = getMonthRange(year, month);
let whereClause = `user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`; let whereClause = `user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`;
@@ -43,15 +93,17 @@ function queryPagedRecords(userId, year, month, options) {
const records = db.prepare( const records = db.prepare(
`SELECT * FROM records WHERE ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?` `SELECT * FROM records WHERE ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`
).all(...params, Number(limit), offset); ).all(...params, limit, offset);
return { records, total, page: Number(page), limit: Number(limit) }; return { records, total, page, limit };
} }
// 接单列表 // 接单列表
router.get('/orders', (req, res) => { router.get('/orders', (req, res) => {
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, { const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
category: '接单' category: '接单',
page: req.query.page,
limit: req.query.limit
}); });
res.json(result); res.json(result);
}); });
@@ -60,7 +112,9 @@ router.get('/orders', (req, res) => {
router.get('/other-income', (req, res) => { router.get('/other-income', (req, res) => {
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, { const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
type: 'income', type: 'income',
excludeCategories: ['接单', '派单'] excludeCategories: ['接单', '派单'],
page: req.query.page,
limit: req.query.limit
}); });
res.json(result); res.json(result);
}); });
@@ -69,7 +123,9 @@ router.get('/other-income', (req, res) => {
router.get('/red-envelope', (req, res) => { router.get('/red-envelope', (req, res) => {
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, { const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
type: 'income', type: 'income',
category: '红包收入' category: '红包收入',
page: req.query.page,
limit: req.query.limit
}); });
res.json(result); res.json(result);
}); });
@@ -78,7 +134,9 @@ router.get('/red-envelope', (req, res) => {
router.get('/milk-tea', (req, res) => { router.get('/milk-tea', (req, res) => {
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, { const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
type: 'income', type: 'income',
category: '奶茶' category: '奶茶',
page: req.query.page,
limit: req.query.limit
}); });
res.json(result); res.json(result);
}); });
@@ -86,7 +144,9 @@ router.get('/milk-tea', (req, res) => {
// 派单列表 // 派单列表
router.get('/dispatches', (req, res) => { router.get('/dispatches', (req, res) => {
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, { const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
category: '派单' category: '派单',
page: req.query.page,
limit: req.query.limit
}); });
res.json(result); res.json(result);
}); });
@@ -94,7 +154,9 @@ router.get('/dispatches', (req, res) => {
// 获取记录列表 // 获取记录列表
router.get('/', (req, res) => { router.get('/', (req, res) => {
const { type, page = 1, limit = 20 } = req.query; const { type, page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit; const validatedPage = validatePage(page, 1);
const validatedLimit = validateLimit(limit, config.pagination.defaultLimit, config.pagination.maxLimit);
const offset = (validatedPage - 1) * validatedLimit;
let sql = 'SELECT * FROM records WHERE user_id = ?'; let sql = 'SELECT * FROM records WHERE user_id = ?';
const params = [req.user.id]; const params = [req.user.id];
if (type) { if (type) {
@@ -102,24 +164,75 @@ router.get('/', (req, res) => {
params.push(type); params.push(type);
} }
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(Number(limit), Number(offset)); params.push(validatedLimit, offset);
res.json(db.prepare(sql).all(...params)); res.json(db.prepare(sql).all(...params));
}); });
// 更新记录 // 更新记录
router.put('/:id', (req, res) => { router.put('/:id', (req, res, next) => {
const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body; try {
db.prepare(` const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body;
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ? const id = Number(req.params.id);
WHERE id = ? AND user_id = ?
`).run(quantity || null, unit_price || null, rebate || null, amount, boss || null, partner || null, source || null, destination || null, note || null, created_at || null, req.params.id, req.user.id); if (!id || id <= 0) {
res.json({ success: true }); throw new AppError('无效的记录ID', 400);
}
// 获取原有记录以判断类别(派单允许负数金额)
const existing = db.prepare('SELECT category FROM records WHERE id = ? AND user_id = ?').get(id, req.user.id);
if (!existing) {
throw new AppError('记录不存在', 404);
}
const validatedAmount = existing.category === '派单'
? validateNumber(amount, '金额')
: validateNumber(amount, '金额', 0);
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
db.prepare(`
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ?
WHERE id = ? AND user_id = ?
`).run(
validatedQuantity,
validatedUnitPrice,
validatedRebate,
validatedAmount,
optionalString(boss, '老板', 100),
optionalString(partner, '陪玩', 100),
optionalString(source, '来源', 100),
optionalString(destination, '去向', 100),
optionalString(note, '备注', 500),
validatedCreatedAt,
id,
req.user.id
);
logger.debug(`用户 ${req.user.username} 更新记录: ${id}`);
res.json({ success: true });
} catch (err) {
next(err);
}
}); });
// 删除记录 // 删除记录
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res, next) => {
db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id); try {
res.json({ success: true }); const id = Number(req.params.id);
if (!id || id <= 0) {
throw new AppError('无效的记录ID', 400);
}
db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?').run(id, req.user.id);
logger.info(`用户 ${req.user.username} 删除记录: ${id}`);
res.json({ success: true });
} catch (err) {
next(err);
}
}); });
module.exports = router; module.exports = router;
+271 -233
View File
@@ -1,9 +1,14 @@
const express = require('express'); const express = require('express');
const db = require('../db'); const db = require('../db');
const { getMonthRange } = require('../utils/helpers'); const config = require('../config');
const logger = require('../utils/logger');
const { getMonthRange, AppError, validateRequired, validateNumber, validateInList } = require('../utils/helpers');
const { getCached } = require('../utils/cache');
const router = express.Router(); const router = express.Router();
// 缓存模块已移至 utils/cache.js
// 格式化本地日期为 YYYY-MM-DD // 格式化本地日期为 YYYY-MM-DD
function formatLocalDate(date) { function formatLocalDate(date) {
const y = date.getFullYear(); const y = date.getFullYear();
@@ -13,63 +18,65 @@ function formatLocalDate(date) {
} }
// 本月统计 // 本月统计
router.get('/monthly', (req, res) => { router.get('/monthly', (req, res, next) => {
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); try {
const stats = db.prepare(` const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
SELECT const cacheKey = `monthly:${req.user.id}:${startDate}:${endDate}`;
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_order_count, getCached(cacheKey, () => {
COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as take_order_income, const stats = db.prepare(`
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_order_count, SELECT
COALESCE(SUM(CASE WHEN category = '单' THEN amount ELSE 0 END), 0) as dispatch_order_income, COALESCE(SUM(CASE WHEN category = '单' THEN quantity ELSE 0 END), 0) as take_order_count,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income, COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as take_order_income,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN 1 ELSE 0 END), 0) as redEnvelope_count, COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_order_count,
COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as milkTea_income, COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as dispatch_order_income,
COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count, COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income, COALESCE(SUM(CASE WHEN category = '红包收入' THEN 1 ELSE 0 END), 0) as redEnvelope_count,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as milkTea_income,
FROM records COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count,
WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
`).get(req.user.id, startDate, endDate); COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
res.json(stats); FROM records
WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, startDate, endDate);
return stats;
}).then(stats => res.json(stats)).catch(next);
} catch (err) {
next(err);
}
}); });
// 月收入/支出记录列表 // 月收入/支出记录列表
router.get('/monthly-records', (req, res) => { router.get('/monthly-records', (req, res, next) => {
const { type, page = 1, limit = 20 } = req.query; try {
if (!type || !['income', 'expense'].includes(type)) return res.status(400).json({ error: '缺少类型参数' }); const { type, page = 1, limit = 20 } = req.query;
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); if (!type || !['income', 'expense'].includes(type)) {
const offset = (Number(page) - 1) * Number(limit); throw new AppError('缺少类型参数或类型无效', 400);
}
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
const validatedPage = Math.max(1, Number(page) || 1);
const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
const offset = (validatedPage - 1) * validatedLimit;
// 合并 summary 和 total 为一次查询 const stats = db.prepare(`
const stats = db.prepare(` SELECT COALESCE(SUM(amount), 0) as total, COUNT(*) as count
SELECT COALESCE(SUM(amount), 0) as total, COUNT(*) as count FROM records WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
FROM records WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? `).get(req.user.id, type, startDate, endDate);
`).get(req.user.id, type, startDate, endDate);
const records = db.prepare(` const records = db.prepare(`
SELECT * FROM records SELECT * FROM records
WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
ORDER BY created_at DESC LIMIT ? OFFSET ? ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, type, startDate, endDate, Number(limit), offset); `).all(req.user.id, type, startDate, endDate, validatedLimit, offset);
res.json({ total: stats.total, records, page: Number(page), limit: Number(limit), totalCount: stats.count }); res.json({ total: stats.total, records, page: validatedPage, limit: validatedLimit, totalCount: stats.count });
} catch (err) {
next(err);
}
}); });
// 年度净收入按年统计 // 年度净收入按年统计
router.get('/yearly-net-income', (req, res) => { router.get('/yearly-net-income', (req, res) => {
// 获取所有年份数据 const combined = db.prepare(`
const yearlyStats = db.prepare(`
SELECT
strftime('%Y', created_at) as year,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
FROM records
WHERE user_id = ?
GROUP BY year ORDER BY year DESC
`).all(req.user.id);
// 获取所有年份的每月数据
const monthlyByYear = db.prepare(`
SELECT SELECT
strftime('%Y', created_at) as year, strftime('%Y', created_at) as year,
strftime('%m', created_at) as month, strftime('%m', created_at) as month,
@@ -77,41 +84,60 @@ router.get('/yearly-net-income', (req, res) => {
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
FROM records FROM records
WHERE user_id = ? WHERE user_id = ?
GROUP BY year, month ORDER BY year DESC, month GROUP BY year, month
`).all(req.user.id); UNION ALL
// 按年份分组每月数据
const monthlyData = {};
monthlyByYear.forEach(m => {
if (!monthlyData[m.year]) monthlyData[m.year] = [];
monthlyData[m.year].push({
month: m.month,
total_income: m.total_income,
total_expense: m.total_expense
});
});
const grandTotal = db.prepare(`
SELECT SELECT
strftime('%Y', created_at) as year,
'00' as month,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income, COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
FROM records WHERE user_id = ? FROM records
`).get(req.user.id); WHERE user_id = ?
GROUP BY year
`).all(req.user.id, req.user.id);
const yearlyStats = [];
const monthlyData = {};
let grandTotal = { total_income: 0, total_expense: 0 };
combined.forEach(row => {
// grandTotal 只累加年度汇总数据(month='00'),避免重复计算月明细
if (row.month === '00') {
grandTotal.total_income += row.total_income;
grandTotal.total_expense += row.total_expense;
yearlyStats.push({
year: row.year,
total_income: row.total_income,
total_expense: row.total_expense
});
} else {
if (!monthlyData[row.year]) monthlyData[row.year] = [];
monthlyData[row.year].push({
month: row.month,
total_income: row.total_income,
total_expense: row.total_expense
});
}
});
yearlyStats.sort((a, b) => b.year - a.year);
res.json({ yearlyStats, monthlyData, grandTotal }); res.json({ yearlyStats, monthlyData, grandTotal });
}); });
// 陪玩派单榜预览 // 陪玩派单榜预览
router.get('/partner-ranking/preview', (req, res) => { router.get('/partner-ranking/preview', (req, res) => {
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
const rows = db.prepare(` const cacheKey = `partner-ranking-preview:${req.user.id}:${startDate}:${endDate}`;
SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income getCached(cacheKey, () => {
FROM records const rows = db.prepare(`
WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != '' SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? FROM records
GROUP BY partner ORDER BY dispatch_count DESC LIMIT 10 WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != ''
`).all(req.user.id, startDate, endDate); AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
res.json(rows); GROUP BY partner ORDER BY dispatch_count DESC LIMIT 10
`).all(req.user.id, startDate, endDate);
return rows;
}).then(rows => res.json(rows));
}); });
// 陪玩派单榜详情 // 陪玩派单榜详情
@@ -141,24 +167,23 @@ router.get('/partner-ranking', (req, res) => {
rows.forEach(row => { rows.forEach(row => {
row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0); row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0);
}); });
const summary = db.prepare(` const summaryStats = db.prepare(`
SELECT SELECT
COALESCE(SUM(quantity), 0) as total_dispatch_count, COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as total_dispatch_count,
COALESCE(SUM(quantity * COALESCE(unit_price, 0)), 0) as total_dispatch_income, COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as total_dispatch_income,
COALESCE(SUM(amount), 0) as total_rebate_income, COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as total_rebate_income,
COALESCE(SUM(quantity * COALESCE(unit_price, 0)), 0) - COALESCE(SUM(amount), 0) as total_partner_income COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as total_milkTea_income
FROM records FROM records
WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != '' WHERE user_id = ? AND type = 'income' AND category IN ('派单', '奶茶')
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, start, end); `).get(req.user.id, start, end);
// 获取奶茶收入 const summary = {
const milkTeaStats = db.prepare(` total_dispatch_count: summaryStats.total_dispatch_count,
SELECT COALESCE(SUM(amount), 0) as total_milkTea_income total_dispatch_income: summaryStats.total_dispatch_income,
FROM records total_rebate_income: summaryStats.total_rebate_income,
WHERE user_id = ? AND type = 'income' AND category = '奶茶' total_partner_income: summaryStats.total_dispatch_income - summaryStats.total_rebate_income,
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? total_milkTea_income: summaryStats.total_milkTea_income
`).get(req.user.id, start, end); };
summary.total_milkTea_income = milkTeaStats.total_milkTea_income;
res.json({ rows, summary }); res.json({ rows, summary });
}); });
@@ -169,22 +194,18 @@ router.get('/take-ranking', (req, res) => {
FROM records WHERE user_id = ? AND category = '接单' AND boss IS NOT NULL AND boss != '' FROM records WHERE user_id = ? AND category = '接单' AND boss IS NOT NULL AND boss != ''
GROUP BY boss ORDER BY count DESC GROUP BY boss ORDER BY count DESC
`).all(req.user.id); `).all(req.user.id);
// 获取接单总收入和红包收入
const stats = db.prepare(` const stats = db.prepare(`
SELECT SELECT
COALESCE(SUM(quantity), 0) as total_take_count, COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as total_take_count,
COALESCE(SUM(amount), 0) as total_take_income COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as total_take_income,
FROM records WHERE user_id = ? AND category = '接单' COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as total_redEnvelope_income
`).get(req.user.id); FROM records WHERE user_id = ? AND type = 'income' AND category IN ('接单', '红包收入')
const redEnvelopeStats = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total_redEnvelope_income
FROM records WHERE user_id = ? AND type = 'income' AND category = '红包收入'
`).get(req.user.id); `).get(req.user.id);
const result = { const result = {
rows: rows, rows: rows,
total_take_count: stats.total_take_count, total_take_count: stats.total_take_count,
total_take_income: stats.total_take_income, total_take_income: stats.total_take_income,
total_redEnvelope_income: redEnvelopeStats.total_redEnvelope_income total_redEnvelope_income: stats.total_redEnvelope_income
}; };
res.json(result); res.json(result);
}); });
@@ -204,19 +225,16 @@ router.get('/dispatch-ranking', (req, res) => {
rows.forEach(row => { rows.forEach(row => {
row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0); row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0);
}); });
// 获取派单总数和奶茶收入 const stats = db.prepare(`
const dispatchTotal = db.prepare(` SELECT
SELECT COALESCE(SUM(quantity), 0) as total_dispatch_count COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as total_dispatch_count,
FROM records WHERE user_id = ? AND category = '派单' COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as total_milkTea_income
`).get(req.user.id); FROM records WHERE user_id = ? AND type = 'income' AND category IN ('派单', '奶茶')
const milkTeaStats = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total_milkTea_income
FROM records WHERE user_id = ? AND type = 'income' AND category = '奶茶'
`).get(req.user.id); `).get(req.user.id);
const result = { const result = {
rows: rows, rows: rows,
total_dispatch_count: dispatchTotal.total_dispatch_count, total_dispatch_count: stats.total_dispatch_count,
total_milkTea_income: milkTeaStats.total_milkTea_income total_milkTea_income: stats.total_milkTea_income
}; };
res.json(result); res.json(result);
}); });
@@ -236,101 +254,112 @@ router.get('/total', (req, res) => {
}); });
// 老板交易记录 // 老板交易记录
router.get('/boss-records', (req, res) => { router.get('/boss-records', (req, res, next) => {
const boss = req.query.boss; try {
const { page = 1, limit = 20, year, month } = req.query; const boss = req.query.boss;
if (!boss) return res.status(400).json({ error: '缺少老板参数' }); const { page = 1, limit = 20, year, month } = req.query;
const offset = (Number(page) - 1) * Number(limit); if (!boss) throw new AppError('缺少老板参数', 400);
// 日期过滤条件 const validatedPage = Math.max(1, Number(page) || 1);
let dateFilter = ''; const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
let dateParams = []; const offset = (validatedPage - 1) * validatedLimit;
if (year && month) {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`; // 日期过滤条件
const endDate = `${year}-${String(month).padStart(2, '0')}-31`; let dateFilter = '';
dateFilter = ` AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') <= ?`; let dateParams = [];
dateParams = [startDate, endDate]; if (year && month) {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate = `${year}-${String(month).padStart(2, '0')}-31`;
dateFilter = ` AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') <= ?`;
dateParams = [startDate, endDate];
}
const summary = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as my_income,
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as dispatch_expense,
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as order_expense,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_expense,
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
COUNT(*) as count
FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter}
`).get(req.user.id, boss, ...dateParams);
const records = db.prepare(`
SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, boss, ...dateParams, validatedLimit, offset);
res.json({
my_income: summary.my_income,
boss_total_expense: summary.dispatch_expense + summary.order_expense + summary.redEnvelope_expense,
take_count: summary.take_count,
dispatch_count: summary.dispatch_count,
records,
page: validatedPage,
limit: validatedLimit,
totalCount: summary.count
});
} catch (err) {
next(err);
} }
// 合并 summary 查询为一次
const summary = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as my_income,
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as dispatch_expense,
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as order_expense,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_expense,
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
COUNT(*) as count
FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter}
`).get(req.user.id, boss, ...dateParams);
const records = db.prepare(`
SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, boss, ...dateParams, Number(limit), offset);
res.json({
my_income: summary.my_income,
boss_total_expense: summary.dispatch_expense + summary.order_expense + summary.redEnvelope_expense,
take_count: summary.take_count,
dispatch_count: summary.dispatch_count,
records,
page: Number(page),
limit: Number(limit),
totalCount: summary.count
});
}); });
// 陪玩交易记录 // 陪玩交易记录
router.get('/partner-records', (req, res) => { router.get('/partner-records', (req, res, next) => {
const partner = req.query.partner; try {
const { page = 1, limit = 20, year, month } = req.query; const partner = req.query.partner;
if (!partner) return res.status(400).json({ error: '缺少陪玩参数' }); const { page = 1, limit = 20, year, month } = req.query;
const offset = (Number(page) - 1) * Number(limit); if (!partner) throw new AppError('缺少陪玩参数', 400);
// 日期过滤条件 const validatedPage = Math.max(1, Number(page) || 1);
let dateFilter = ''; const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
let dateParams = []; const offset = (validatedPage - 1) * validatedLimit;
if (year && month) {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`; // 日期过滤条件
const endDate = `${year}-${String(month).padStart(2, '0')}-31`; let dateFilter = '';
dateFilter = ` AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') <= ?`; let dateParams = [];
dateParams = [startDate, endDate]; if (year && month) {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate = `${year}-${String(month).padStart(2, '0')}-31`;
dateFilter = ` AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') <= ?`;
dateParams = [startDate, endDate];
}
const summary = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * (COALESCE(unit_price, 0) - COALESCE(rebate, amount * 1.0 / CASE WHEN quantity > 0 THEN quantity ELSE 1 END)) ELSE 0 END), 0) as spread_income,
COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as rebate_income,
COALESCE(SUM(CASE WHEN type = 'income' AND category != '派单' AND category != '接单' AND source = ? THEN amount ELSE 0 END), 0) as other_income,
COUNT(*) as count
FROM records WHERE user_id = ? AND (
(category = '派单' AND partner = ?) OR
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
)${dateFilter}
`).get(partner, req.user.id, partner, partner, ...dateParams);
const records = db.prepare(`
SELECT * FROM records
WHERE user_id = ? AND (
(category = '派单' AND partner = ?) OR
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
)${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, partner, partner, ...dateParams, validatedLimit, offset);
res.json({
dispatch_count: summary.dispatch_count,
spread_income: summary.spread_income,
rebate_income: summary.rebate_income,
other_income: summary.other_income,
records,
page: validatedPage,
limit: validatedLimit,
totalCount: summary.count
});
} catch (err) {
next(err);
} }
// 合并 summary 查询为一次
// 注意: source 在 CASE 和 WHERE 中各出现一次,都需要 partner 值
const summary = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * (COALESCE(unit_price, 0) - COALESCE(rebate, amount * 1.0 / CASE WHEN quantity > 0 THEN quantity ELSE 1 END)) ELSE 0 END), 0) as spread_income,
COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as rebate_income,
COALESCE(SUM(CASE WHEN type = 'income' AND category != '派单' AND category != '接单' AND source = ? THEN amount ELSE 0 END), 0) as other_income,
COUNT(*) as count
FROM records WHERE user_id = ? AND (
(category = '派单' AND partner = ?) OR
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
)${dateFilter}
`).get(partner, req.user.id, partner, partner, ...dateParams);
const records = db.prepare(`
SELECT * FROM records
WHERE user_id = ? AND (
(category = '派单' AND partner = ?) OR
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
)${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, partner, partner, ...dateParams, Number(limit), offset);
res.json({
dispatch_count: summary.dispatch_count,
spread_income: summary.spread_income,
rebate_income: summary.rebate_income,
other_income: summary.other_income,
records,
page: Number(page),
limit: Number(limit),
totalCount: summary.count
});
}); });
// 最近7天每日收入 // 最近7天每日收入
@@ -347,57 +376,66 @@ router.get('/daily-income', (req, res) => {
endDay.setDate(endDay.getDate() + 1); endDay.setDate(endDay.getDate() + 1);
const endDate = formatLocalDate(endDay); const endDate = formatLocalDate(endDay);
const rows = db.prepare(` const cacheKey = `daily-income:${req.user.id}:${startDate}`;
SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income getCached(cacheKey, () => {
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? const rows = db.prepare(`
GROUP BY DATE(created_at, 'localtime') SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income
`).all(req.user.id, startDate, endDate); FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
GROUP BY DATE(created_at, 'localtime')
const map = {}; `).all(req.user.id, startDate, endDate);
rows.forEach(r => { map[r.date] = r.income; }); const map = {};
res.json(days.map(d => ({ date: d, income: map[d] || 0 }))); rows.forEach(r => { map[r.date] = r.income; });
return days.map(d => ({ date: d, income: map[d] || 0 }));
}).then(data => res.json(data));
}); });
// 某天收入记录详情 // 某天收入记录详情
router.get('/daily-detail', (req, res) => { router.get('/daily-detail', (req, res, next) => {
const { date, page = 1, limit = 20 } = req.query; try {
if (!date) return res.status(400).json({ error: '缺少日期参数' }); const { date, page = 1, limit = 20 } = req.query;
const offset = (Number(page) - 1) * Number(limit); if (!date) throw new AppError('缺少日期参数', 400);
const nextDay = new Date(date);
nextDay.setDate(nextDay.getDate() + 1);
const endDate = formatLocalDate(nextDay);
const summary = db.prepare(` const validatedPage = Math.max(1, Number(page) || 1);
SELECT COALESCE(SUM(amount), 0) as total_income const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? const offset = (validatedPage - 1) * validatedLimit;
`).get(req.user.id, date, endDate);
const total = db.prepare(` const nextDay = new Date(date);
SELECT COUNT(*) as count FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? nextDay.setDate(nextDay.getDate() + 1);
`).get(req.user.id, date, endDate).count; const endDate = formatLocalDate(nextDay);
const records = db.prepare(` const stats = db.prepare(`
SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? SELECT COALESCE(SUM(amount), 0) as total_income, COUNT(*) as count
ORDER BY created_at DESC LIMIT ? OFFSET ? FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).all(req.user.id, date, endDate, Number(limit), offset); `).get(req.user.id, date, endDate);
res.json({ total_income: summary.total_income, records, page: Number(page), limit: Number(limit), totalCount: total }); const records = db.prepare(`
SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, date, endDate, validatedLimit, offset);
res.json({ total_income: stats.total_income, records, page: validatedPage, limit: validatedLimit, totalCount: stats.count });
} catch (err) {
next(err);
}
}); });
// 老板收入排行 - 首页预览 // 老板收入排行 - 首页预览
router.get('/boss-ranking/preview', (req, res) => { router.get('/boss-ranking/preview', (req, res) => {
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
const rows = db.prepare(` const cacheKey = `boss-ranking-preview:${req.user.id}:${startDate}:${endDate}`;
SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income, getCached(cacheKey, () => {
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count, const rows = db.prepare(`
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count, SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
FROM records COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
WHERE user_id = ? AND type = 'income' AND (boss IS NOT NULL AND boss != '' OR source IS NOT NULL AND source != '') COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? FROM records
GROUP BY COALESCE(boss, source) ORDER BY total_income DESC LIMIT 10 WHERE user_id = ? AND type = 'income' AND (boss IS NOT NULL AND boss != '' OR source IS NOT NULL AND source != '')
`).all(req.user.id, startDate, endDate); AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
res.json(rows); GROUP BY COALESCE(boss, source) ORDER BY total_income DESC LIMIT 10
`).all(req.user.id, startDate, endDate);
return rows;
}).then(rows => res.json(rows));
}); });
// 老板收入排行 - 详情页(支持筛选) // 老板收入排行 - 详情页(支持筛选)
+69
View File
@@ -0,0 +1,69 @@
/**
* 统计数据缓存模块
* 基于 LRU 算法的内存缓存
*/
const config = require('../config');
const logger = require('./logger');
const statsCache = new Map();
const CACHE_TTL = config.cache.ttl;
const MAX_CACHE_SIZE = config.cache.maxSize;
/**
* 获取缓存数据,如果不存在则通过 fetchFn 获取并缓存
* @param {string} key 缓存键
* @param {Function} fetchFn 获取数据的函数
* @returns {Promise} 缓存数据
*/
function getCached(key, fetchFn) {
const cached = statsCache.get(key);
if (cached && Date.now() - cached.ts < CACHE_TTL) {
// LRU: move to end
statsCache.delete(key);
statsCache.set(key, cached);
return Promise.resolve(cached.data);
}
// 超出最大缓存数量时,删除最旧的条目
if (statsCache.size >= MAX_CACHE_SIZE) {
const firstKey = statsCache.keys().next().value;
statsCache.delete(firstKey);
}
return Promise.resolve(fetchFn()).then(data => {
statsCache.set(key, { data, ts: Date.now() });
logger.debug(`缓存更新: ${key}`);
return data;
}).catch(err => {
logger.error('缓存获取失败:', err.message);
throw err;
});
}
/**
* 清除指定用户的所有缓存
* @param {number} userId 用户ID
*/
function clearUserCache(userId) {
const prefix = `:${userId}:`;
for (const key of statsCache.keys()) {
if (key.includes(prefix)) {
statsCache.delete(key);
logger.debug(`缓存清除: ${key}`);
}
}
}
/**
* 清除所有缓存
*/
function clearAllCache() {
statsCache.clear();
logger.debug('所有缓存已清除');
}
module.exports = {
getCached,
clearUserCache,
clearAllCache
};
+94 -3
View File
@@ -1,4 +1,17 @@
// 日期范围辅助函数 /**
* 应用错误类
*/
class AppError extends Error {
constructor(message, status = 400) {
super(message);
this.status = status;
this.expose = true; // 允许向客户端暴露错误信息
}
}
/**
* 日期范围辅助函数
*/
function getMonthRange(qYear, qMonth) { function getMonthRange(qYear, qMonth) {
const now = new Date(); const now = new Date();
const y = Number(qYear) || now.getFullYear(); const y = Number(qYear) || now.getFullYear();
@@ -10,7 +23,9 @@ function getMonthRange(qYear, qMonth) {
return { startDate, endDate }; return { startDate, endDate };
} }
// CSV行解析(处理引号内的逗号) /**
* CSV行解析(处理引号内的逗号)
*/
function parseCSVLine(line) { function parseCSVLine(line) {
const result = []; const result = [];
let current = ''; let current = '';
@@ -31,4 +46,80 @@ function parseCSVLine(line) {
return result; return result;
} }
module.exports = { getMonthRange, parseCSVLine }; /**
* 输入验证函数
*/
function validateRequired(obj, fields) {
const missing = fields.filter(f => obj[f] === undefined || obj[f] === null || obj[f] === '');
if (missing.length > 0) {
throw new AppError(`缺少必填字段: ${missing.join(', ')}`, 400);
}
}
function validateNumber(value, field, min, max) {
const num = Number(value);
if (!Number.isFinite(num)) {
throw new AppError(`${field} 必须是数字`, 400);
}
if (min !== undefined && num < min) {
throw new AppError(`${field} 不能小于 ${min}`, 400);
}
if (max !== undefined && num > max) {
throw new AppError(`${field} 不能大于 ${max}`, 400);
}
return num;
}
function validateString(value, field, maxLength) {
if (typeof value !== 'string') {
throw new AppError(`${field} 必须是字符串`, 400);
}
if (maxLength && value.length > maxLength) {
throw new AppError(`${field} 长度不能超过 ${maxLength}`, 400);
}
return value.trim();
}
function optionalString(value, field, maxLength) {
if (value === undefined || value === null || value === '') return null;
return validateString(String(value), field, maxLength);
}
function validateInList(value, field, list) {
if (!list.includes(value)) {
throw new AppError(`${field} 值无效`, 400);
}
return value;
}
function validatePage(value, defaultValue = 1) {
return Math.max(1, Number.parseInt(value, 10) || defaultValue);
}
function validateLimit(value, defaultValue, maxLimit) {
const limit = Number.parseInt(value, 10) || defaultValue;
return Math.min(Math.max(1, limit), maxLimit);
}
function validateIsoDate(value, field) {
if (value === undefined || value === null || value === '') return new Date().toISOString();
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new AppError(`${field} 格式无效`, 400);
}
return date.toISOString();
}
module.exports = {
AppError,
getMonthRange,
parseCSVLine,
validateRequired,
validateNumber,
validateString,
optionalString,
validateInList,
validatePage,
validateLimit,
validateIsoDate
};
+79
View File
@@ -0,0 +1,79 @@
/**
* 简单日志工具
* 支持日志级别和格式化输出
*/
const config = require('../config');
const LEVELS = {
debug: 0,
info: 1,
warn: 2,
error: 3
};
const COLORS = {
debug: '\x1b[36m', // cyan
info: '\x1b[32m', // green
warn: '\x1b[33m', // yellow
error: '\x1b[31m', // red
reset: '\x1b[0m'
};
const currentLevel = LEVELS[config.log.level] || LEVELS.info;
const colorize = config.log.colorize && process.stdout.isTTY;
function formatTime() {
const d = new Date();
return d.toISOString().replace('T', ' ').slice(0, 19);
}
function formatMessage(level, ...args) {
const timestamp = formatTime();
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
if (colorize) {
return `${COLORS[level]}${prefix}${COLORS.reset} ${args.join(' ')}`;
}
return `${prefix} ${args.join(' ')}`;
}
const logger = {
debug(...args) {
if (currentLevel <= LEVELS.debug) {
console.log(formatMessage('debug', ...args));
}
},
info(...args) {
if (currentLevel <= LEVELS.info) {
console.log(formatMessage('info', ...args));
}
},
warn(...args) {
if (currentLevel <= LEVELS.warn) {
console.warn(formatMessage('warn', ...args));
}
},
error(...args) {
if (currentLevel <= LEVELS.error) {
console.error(formatMessage('error', ...args));
}
},
// 请求日志中间件
requestLogger(req, res, next) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const status = res.statusCode;
const level = status >= 400 ? 'warn' : 'info';
logger[level](`${req.method} ${req.originalUrl || req.url} ${status} ${duration}ms`);
});
next();
}
};
module.exports = logger;