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();
+165 -85
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'; function initList(listId) {
listState[listId] = {
loading: false,
records: [],
page: 1,
hasMore: true,
totalCount: 0
};
const container = document.getElementById(listId);
if (container) container.innerHTML = '';
}
async function loadListData(listId) {
const cfg = config[listId];
const state = listState[listId];
if (!cfg || !state) return;
if (state.loading || !state.hasMore) return;
state.loading = true;
showLoading(listId, true);
try { try {
const res = await fetch(API + config.endpoint + '?page=' + config.getPage() + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); const res = await apiFetch(
if (!res.ok) return; API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth
const data = await res.json(); );
const container = document.getElementById(listId);
if (!append) container.innerHTML = '';
if (data.records.length === 0) { if (!res.ok) {
if (config.getPage() === 1) container.innerHTML = '<div class="ranking-empty">' + config.emptyMsg + '</div>'; console.error('[loadListData] fetch failed:', res.status);
config.setHasMore(false); 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 { } 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 = {}; const grouped = {};
data.records.forEach(r => { state.records.forEach(r => {
const date = formatLocalDate(r.created_at); const date = formatLocalDate(r.created_at);
if (!grouped[date]) grouped[date] = []; if (!grouped[date]) grouped[date] = [];
grouped[date].push(r); grouped[date].push(r);
}); });
let html = ''; container.innerHTML = '';
for (const [date, records] of Object.entries(grouped)) { for (const [date, records] of Object.entries(grouped)) {
html += '<div class="order-date-group" data-date="' + date + '"><div class="order-date-label">' + date + '</div>'; 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 => { records.forEach(r => {
html += config.renderItem(r); const item = document.createElement('div');
item.innerHTML = cfg.renderItem(r);
group.appendChild(item.firstChild);
}); });
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); container.appendChild(group);
document.getElementById(listId.replace('List', 'Loading')).style.display = 'none'; }
} }
// 通用渲染函数 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']
};
+45 -7
View File
@@ -1,13 +1,17 @@
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');
// 创建表 /**
* 创建表结构
*/
function createTables() {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -40,12 +44,21 @@ db.exec(`
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 约束 /**
* 迁移:去掉 category 的 CHECK 约束
*/
function migrateRemoveCategoryCheck() {
try { try {
const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get(); const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get();
if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) { if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) {
logger.info('执行迁移:移除 category CHECK 约束');
db.exec(` db.exec(`
CREATE TABLE records_new ( CREATE TABLE records_new (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -67,26 +80,51 @@ try {
DROP TABLE records; DROP TABLE records;
ALTER TABLE records_new RENAME TO records; ALTER TABLE records_new RENAME TO records;
`); `);
logger.info('迁移完成:category CHECK 约束已移除');
} }
} catch (e) { } catch (e) {
console.log('Migration skipped:', e.message); logger.warn('迁移跳过 (category):', e.message);
}
} }
// 迁移:添加 rebate 列(派单返点) /**
* 迁移:添加 rebate 列
*/
function migrateAddRebateColumn() {
try { try {
const cols = db.prepare("PRAGMA table_info(records)").all(); const cols = db.prepare("PRAGMA table_info(records)").all();
if (!cols.find(c => c.name === 'rebate')) { if (!cols.find(c => c.name === 'rebate')) {
logger.info('执行迁移:添加 rebate 列');
db.exec("ALTER TABLE records ADD COLUMN rebate REAL"); db.exec("ALTER TABLE records ADD COLUMN rebate REAL");
logger.info('迁移完成:rebate 列已添加');
} }
} catch (e) { } catch (e) {
console.log('Rebate migration skipped:', e.message); logger.warn('迁移跳过 (rebate):', e.message);
}
}
/**
* 初始化默认用户
*/
function initDefaultUser() {
if (process.env.NODE_ENV === 'production') {
logger.info('生产环境跳过默认用户初始化');
return;
} }
// 初始化默认用户 lizhilun
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun'); const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun');
if (!existingUser) { if (!existingUser) {
const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync('lizhilun', 10); const hash = bcrypt.hashSync('lizhilun', 10);
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash); 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 };
+129 -44
View File
@@ -1,14 +1,49 @@
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) => {
try {
const format = req.query.format; const format = req.query.format;
if (!format || !['json', 'csv'].includes(format)) { if (!format || !['json', 'csv'].includes(format)) {
return res.status(400).json({ error: '请指定格式: json 或 csv' }); throw new AppError('请指定格式: json 或 csv', 400);
} }
const records = db.prepare( const records = db.prepare(
@@ -26,6 +61,7 @@ router.get('/export', (req, res) => {
recordCount: records.length, recordCount: records.length,
records records
}; };
logger.info(`用户 ${req.user.username} 导出 JSON 数据: ${records.length}`);
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`); res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
return res.send(JSON.stringify(exportData, null, 2)); return res.send(JSON.stringify(exportData, null, 2));
@@ -35,27 +71,23 @@ router.get('/export', (req, res) => {
const BOM = '\uFEFF'; const BOM = '\uFEFF';
const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at']; const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at'];
const csvRows = records.map(r => { const csvRows = records.map(r => {
return cols.map(col => { return cols.map(col => encodeCsvCell(r[col], col)).join(',');
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(',');
}); });
logger.info(`用户 ${req.user.username} 导出 CSV 数据: ${records.length}`);
res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`); res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n')); res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n'));
} catch (err) {
next(err);
}
}); });
// 导入数据 // 导入数据
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);
}
} }
}); });
+131 -18
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) => {
try {
const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body; const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body;
// 输入验证
validateRequired(req.body, ['type', 'category', 'amount']);
validateInList(type, 'type', ['income', 'expense']);
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(` const stmt = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
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()); 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 }); 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) => {
try {
const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body; const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body;
const id = Number(req.params.id);
if (!id || id <= 0) {
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(` db.prepare(`
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ? UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ?
WHERE id = ? AND user_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); `).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 }); 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 {
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 }); res.json({ success: true });
} catch (err) {
next(err);
}
}); });
module.exports = router; module.exports = router;
+140 -102
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,8 +18,11 @@ function formatLocalDate(date) {
} }
// 本月统计 // 本月统计
router.get('/monthly', (req, res) => { router.get('/monthly', (req, res, next) => {
try {
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
const cacheKey = `monthly:${req.user.id}:${startDate}:${endDate}`;
getCached(cacheKey, () => {
const stats = db.prepare(` const stats = db.prepare(`
SELECT SELECT
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_order_count, COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_order_count,
@@ -30,17 +38,25 @@ router.get('/monthly', (req, res) => {
FROM records FROM records
WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, startDate, endDate); `).get(req.user.id, startDate, endDate);
res.json(stats); 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) => {
try {
const { type, page = 1, limit = 20 } = req.query; const { type, page = 1, limit = 20 } = req.query;
if (!type || !['income', 'expense'].includes(type)) return res.status(400).json({ error: '缺少类型参数' }); if (!type || !['income', 'expense'].includes(type)) {
throw new AppError('缺少类型参数或类型无效', 400);
}
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
const offset = (Number(page) - 1) * Number(limit); 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') < ?
@@ -50,26 +66,17 @@ router.get('/monthly-records', (req, res) => {
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,33 +84,51 @@ 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 cacheKey = `partner-ranking-preview:${req.user.id}:${startDate}:${endDate}`;
getCached(cacheKey, () => {
const rows = db.prepare(` const rows = db.prepare(`
SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income
FROM records FROM records
@@ -111,7 +136,8 @@ router.get('/partner-ranking/preview', (req, res) => {
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
GROUP BY partner ORDER BY dispatch_count DESC LIMIT 10 GROUP BY partner ORDER BY dispatch_count DESC LIMIT 10
`).all(req.user.id, startDate, endDate); `).all(req.user.id, startDate, endDate);
res.json(rows); 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,11 +254,15 @@ router.get('/total', (req, res) => {
}); });
// 老板交易记录 // 老板交易记录
router.get('/boss-records', (req, res) => { router.get('/boss-records', (req, res, next) => {
try {
const boss = req.query.boss; const boss = req.query.boss;
const { page = 1, limit = 20, year, month } = req.query; const { page = 1, limit = 20, year, month } = req.query;
if (!boss) return res.status(400).json({ error: '缺少老板参数' }); if (!boss) throw new AppError('缺少老板参数', 400);
const offset = (Number(page) - 1) * Number(limit);
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;
// 日期过滤条件 // 日期过滤条件
let dateFilter = ''; let dateFilter = '';
@@ -252,7 +274,6 @@ router.get('/boss-records', (req, res) => {
dateParams = [startDate, endDate]; dateParams = [startDate, endDate];
} }
// 合并 summary 查询为一次
const summary = db.prepare(` const summary = db.prepare(`
SELECT SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as my_income, COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as my_income,
@@ -267,7 +288,7 @@ router.get('/boss-records', (req, res) => {
const records = db.prepare(` const records = db.prepare(`
SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ? 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); `).all(req.user.id, boss, ...dateParams, validatedLimit, offset);
res.json({ res.json({
my_income: summary.my_income, my_income: summary.my_income,
@@ -275,18 +296,25 @@ router.get('/boss-records', (req, res) => {
take_count: summary.take_count, take_count: summary.take_count,
dispatch_count: summary.dispatch_count, dispatch_count: summary.dispatch_count,
records, records,
page: Number(page), page: validatedPage,
limit: Number(limit), limit: validatedLimit,
totalCount: summary.count totalCount: summary.count
}); });
} catch (err) {
next(err);
}
}); });
// 陪玩交易记录 // 陪玩交易记录
router.get('/partner-records', (req, res) => { router.get('/partner-records', (req, res, next) => {
try {
const partner = req.query.partner; const partner = req.query.partner;
const { page = 1, limit = 20, year, month } = req.query; const { page = 1, limit = 20, year, month } = req.query;
if (!partner) return res.status(400).json({ error: '缺少陪玩参数' }); if (!partner) throw new AppError('缺少陪玩参数', 400);
const offset = (Number(page) - 1) * Number(limit);
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;
// 日期过滤条件 // 日期过滤条件
let dateFilter = ''; let dateFilter = '';
@@ -298,8 +326,6 @@ router.get('/partner-records', (req, res) => {
dateParams = [startDate, endDate]; dateParams = [startDate, endDate];
} }
// 合并 summary 查询为一次
// 注意: source 在 CASE 和 WHERE 中各出现一次,都需要 partner 值
const summary = db.prepare(` const summary = db.prepare(`
SELECT SELECT
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 dispatch_count,
@@ -319,7 +345,7 @@ router.get('/partner-records', (req, res) => {
(category = '派单' AND partner = ?) OR (category = '派单' AND partner = ?) OR
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?) (type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
)${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ? )${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, partner, partner, ...dateParams, Number(limit), offset); `).all(req.user.id, partner, partner, ...dateParams, validatedLimit, offset);
res.json({ res.json({
dispatch_count: summary.dispatch_count, dispatch_count: summary.dispatch_count,
@@ -327,10 +353,13 @@ router.get('/partner-records', (req, res) => {
rebate_income: summary.rebate_income, rebate_income: summary.rebate_income,
other_income: summary.other_income, other_income: summary.other_income,
records, records,
page: Number(page), page: validatedPage,
limit: Number(limit), limit: validatedLimit,
totalCount: summary.count totalCount: summary.count
}); });
} catch (err) {
next(err);
}
}); });
// 最近7天每日收入 // 最近7天每日收入
@@ -347,46 +376,54 @@ 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 cacheKey = `daily-income:${req.user.id}:${startDate}`;
getCached(cacheKey, () => {
const rows = db.prepare(` const rows = db.prepare(`
SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
GROUP BY DATE(created_at, 'localtime') GROUP BY DATE(created_at, 'localtime')
`).all(req.user.id, startDate, endDate); `).all(req.user.id, startDate, endDate);
const map = {}; const map = {};
rows.forEach(r => { map[r.date] = r.income; }); rows.forEach(r => { map[r.date] = r.income; });
res.json(days.map(d => ({ date: d, income: map[d] || 0 }))); 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) => {
try {
const { date, page = 1, limit = 20 } = req.query; const { date, page = 1, limit = 20 } = req.query;
if (!date) return res.status(400).json({ error: '缺少日期参数' }); if (!date) throw new AppError('缺少日期参数', 400);
const offset = (Number(page) - 1) * Number(limit);
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;
const nextDay = new Date(date); const nextDay = new Date(date);
nextDay.setDate(nextDay.getDate() + 1); nextDay.setDate(nextDay.getDate() + 1);
const endDate = formatLocalDate(nextDay); const endDate = formatLocalDate(nextDay);
const summary = db.prepare(` const stats = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total_income SELECT COALESCE(SUM(amount), 0) as total_income, COUNT(*) as count
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, date, endDate); `).get(req.user.id, date, endDate);
const total = db.prepare(`
SELECT COUNT(*) as count FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, date, endDate).count;
const records = db.prepare(` const records = db.prepare(`
SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? 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 ? ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, date, endDate, Number(limit), offset); `).all(req.user.id, date, endDate, validatedLimit, offset);
res.json({ total_income: summary.total_income, records, page: Number(page), limit: Number(limit), totalCount: total }); 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 cacheKey = `boss-ranking-preview:${req.user.id}:${startDate}:${endDate}`;
getCached(cacheKey, () => {
const rows = db.prepare(` const rows = db.prepare(`
SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income, SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income,
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count, COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
@@ -397,7 +434,8 @@ router.get('/boss-ranking/preview', (req, res) => {
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
GROUP BY COALESCE(boss, source) ORDER BY total_income DESC LIMIT 10 GROUP BY COALESCE(boss, source) ORDER BY total_income DESC LIMIT 10
`).all(req.user.id, startDate, endDate); `).all(req.user.id, startDate, endDate);
res.json(rows); 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;