Compare commits

..
19 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
lizhilunandClaude Opus 4.6 3a4048a29e fix: 修复 partner-records 缺少 source 参数
CASE 表达式中的 source=? 需要单独的 partner 参数

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:37:23 +08:00
lizhilunandClaude Opus 4.6 f381f93507 fix: 修复 partner-records SQL 参数顺序错误
参数顺序从 user_id, partner, partner 改为 partner, user_id, partner
以匹配 SQL 中 source, user_id, partner, source 的占位符顺序

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:35:19 +08:00
lizhilunandClaude Opus 4.6 d473e3a431 fix: 修复重构引入的 bug
- partner-records: 修复 SQL 参数顺序错误
- partner-records: 移除对已删除变量的引用
- stats.js: 修复 toFixed 在数字原型上调用的问题
- stats.js: 简化 onclick 处理避免转义问题

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:27:39 +08:00
lizhilunandClaude Opus 4.6 0b450a65ed perf: 优化数据库查询和前端代码质量
- 添加 records 表常用字段索引提升查询性能
- 合并月度/老板/陪玩记录查询减少数据库往返
- 提取重复的横向柱状图渲染逻辑为通用函数
- 移除调试用的 console.log 语句

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:22:15 +08:00
lizhilunandClaude Opus 4.6 14927f74aa fix: 修复柱状图高度比例计算并添加月度筛选功能
- 修复最近7天收入柱状图高度使用百分比导致比例失真问题,改为像素计算
- 老板/陪玩交易详情页添加"全部/当月"切换按钮
- 老板/陪玩交易详情页添加月度导航切换功能
- 老板/陪玩交易详情页添加自动加载更多数据功能

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:13:02 +08:00
lizhilunandClaude Opus 4.6 3d8d03a66e fix: 修复老板/陪玩排行榜预览数据不显示
API 直接返回数组,前端错误地使用了 data.rows 导致数据为空。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:06:33 +08:00
lizhilunandClaude Opus 4.6 a0466fda3b fix: 修复 renderDispatchItem 除零问题
当 r.quantity 为 0 时,原代码 r.amount / r.quantity 会导致除零错误。
修复:优先使用 r.unit_price,仅在 unit_price 不存在时才计算,并添加防护。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 09:58:33 +08:00
lizhilunandClaude Opus 4.6 2ebac98f07 refactor: 重构代码减少重复并添加分页
后端 (records.js):
- 提取通用分页查询函数 queryPagedRecords
- 5个列表接口 (orders/other-income/red-envelope/milk-tea/dispatches) 复用同一函数

后端 (stats.js):
- 为 monthly-records, boss-records, partner-records, daily-detail 添加分页支持
- 新增 page, limit, totalCount 返回字段

前端 (order-list.js):
- 提取通用列表配置 listConfig
- 统一渲染函数 renderOrderItem/renderOtherIncomeItem 等
- 大幅减少代码重复

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 09:53:48 +08:00
lizhilunandClaude Opus 4.6 a03e92d437 perf: 并行加载首页统计数据
将原来顺序执行的4个API调用改为并行执行(Promise.all),
显著减少首页加载时间。同时提取了渲染函数 renderWeeklyChart、
renderBossRankingPreview、renderPartnerRankingPreview,提高代码复用性。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 09:45:36 +08:00
lizhilunandClaude Opus 4.6 4656f3f9ca fix: 移除接单列表页面的自动滚动行为
移除了进入接单列表页面时自动滚动到今天的逻辑,避免界面
在加载后自动往上滚动的问题。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 09:37:49 +08:00
25 changed files with 2244 additions and 983 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"
+34 -2
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;
@@ -159,7 +176,7 @@ body {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
margin-bottom: 16px; margin-bottom: 0;
} }
.weekly-chart-bars { .weekly-chart-bars {
display: flex; display: flex;
@@ -215,6 +232,21 @@ body {
.daily-nav-date { .daily-nav-date {
font-size: 15px; font-weight: 600; color: #333; font-size: 15px; font-weight: 600; color: #333;
} }
.scope-toggle {
margin-left: auto;
padding: 4px 12px;
border-radius: 12px;
border: 1px solid #667eea;
background: #fff;
color: #667eea;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.scope-toggle.active {
background: #667eea;
color: #fff;
}
.daily-summary { .daily-summary {
background: #fff; background: #fff;
border-radius: 12px; border-radius: 12px;
+40 -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">
@@ -284,9 +307,16 @@
<div class="page-header"> <div class="page-header">
<button class="back" onclick="bossDetailGoBack()">&larr;</button> <button class="back" onclick="bossDetailGoBack()">&larr;</button>
<h2 id="bossDetailTitle">交易记录</h2> <h2 id="bossDetailTitle">交易记录</h2>
<button class="scope-toggle" id="bossDetailAllBtn" onclick="toggleBossDetailAll()">全部</button>
</div>
<div class="daily-nav">
<button class="daily-nav-btn" onclick="switchBossDetailScope(-1)">&larr; 上月</button>
<span class="daily-nav-date" id="bossDetailScope">2026年3月</span>
<button class="daily-nav-btn" onclick="switchBossDetailScope(1)">下月 &rarr;</button>
</div> </div>
<div class="daily-summary" id="bossDetailSummary"></div> <div class="daily-summary" id="bossDetailSummary"></div>
<div id="bossDetailList" class="order-list"></div> <div id="bossDetailList" class="order-list"></div>
<div id="bossDetailMore" class="order-loading" style="display:none;text-align:center;padding:12px;color:#667eea">加载更多</div>
</div> </div>
<!-- 陪玩交易详情页 --> <!-- 陪玩交易详情页 -->
@@ -294,9 +324,16 @@
<div class="page-header"> <div class="page-header">
<button class="back" onclick="partnerDetailGoBack()">&larr;</button> <button class="back" onclick="partnerDetailGoBack()">&larr;</button>
<h2 id="partnerDetailTitle">交易记录</h2> <h2 id="partnerDetailTitle">交易记录</h2>
<button class="scope-toggle" id="partnerDetailAllBtn" onclick="togglePartnerDetailAll()">全部</button>
</div>
<div class="daily-nav">
<button class="daily-nav-btn" onclick="switchPartnerDetailScope(-1)">&larr; 上月</button>
<span class="daily-nav-date" id="partnerDetailScope">2026年3月</span>
<button class="daily-nav-btn" onclick="switchPartnerDetailScope(1)">下月 &rarr;</button>
</div> </div>
<div class="daily-summary" id="partnerDetailSummary"></div> <div class="daily-summary" id="partnerDetailSummary"></div>
<div id="partnerDetailList" class="order-list"></div> <div id="partnerDetailList" class="order-list"></div>
<div id="partnerDetailMore" class="order-loading" style="display:none;text-align:center;padding:12px;color:#667eea">加载更多</div>
</div> </div>
<!-- 红包及其他收入列表页 --> <!-- 红包及其他收入列表页 -->
@@ -443,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 -30
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,10 +146,9 @@ 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();
console.log('take-ranking data:', data);
const rows = data.rows || []; const rows = data.rows || [];
// 显示统计卡片 // 显示统计卡片
@@ -91,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 || [];
@@ -128,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>`;
@@ -176,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();
+189 -281
View File
@@ -1,300 +1,208 @@
let orderPage = 1; // ============================================
let orderHasMore = true; // 通用列表管理 - 无限滚动加载
let orderLoading = false; // ============================================
async function loadOrders(append) { const config = {
if (orderLoading || (!append && !orderHasMore)) return; orderList: { endpoint: '/api/records/orders', emptyMsg: '暂无接单记录', renderItem: renderOrderItem },
orderLoading = true; otherIncomeList: { endpoint: '/api/records/other-income', emptyMsg: '暂无红包及其他收入记录', renderItem: renderOtherIncomeItem },
document.getElementById('orderLoading').style.display = 'block'; dispatchList: { endpoint: '/api/records/dispatches', emptyMsg: '暂无派单记录', renderItem: renderDispatchItem },
try { redEnvelopeList: { endpoint: '/api/records/red-envelope', emptyMsg: '暂无红包收入记录', renderItem: renderRedEnvelopeItem },
const res = await fetch(API + '/api/records/orders?page=' + orderPage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); milkTeaList: { endpoint: '/api/records/milk-tea', emptyMsg: '暂无奶茶收入记录', renderItem: renderMilkTeaItem }
if (!res.ok) return; };
const data = await res.json();
const container = document.getElementById('orderList'); const PAGE_SIZE = 20;
if (!append) container.innerHTML = '';
if (data.records.length === 0) { const listState = {};
if (orderPage === 1) container.innerHTML = '<div class="ranking-empty">暂无接单记录</div>';
orderHasMore = false; function initList(listId) {
} else { listState[listId] = {
const grouped = {}; loading: false,
data.records.forEach(r => { records: [],
const date = formatLocalDate(r.created_at); page: 1,
if (!grouped[date]) grouped[date] = []; hasMore: true,
grouped[date].push(r); totalCount: 0
}); };
let html = '';
for (const [date, records] of Object.entries(grouped)) { const container = document.getElementById(listId);
html += `<div class="order-date-group" data-date="${date}"> if (container) container.innerHTML = '';
<div class="order-date-label">${date}</div>`;
records.forEach(r => {
const time = formatLocalTime(r.created_at);
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'>
<div class="order-item-top">
<span class="order-item-boss">${r.boss || '-'}</span>
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
</div>
<div class="order-item-bottom">
<span>${r.quantity || 0}× ${(r.unit_price || 0).toFixed(2)}</span>
<span>${time}${r.partner ? ' · ' + r.partner : ''}</span>
</div>
</div>`;
});
html += '</div>';
}
container.insertAdjacentHTML('beforeend', html);
orderHasMore = data.records.length >= 20;
orderPage++;
if (!append) {
const today = formatLocalDate(new Date());
setTimeout(() => {
const el = container.querySelector(`[data-date="${today}"]`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}
} catch (e) { console.error(e); }
orderLoading = false;
document.getElementById('orderLoading').style.display = 'none';
} }
// 滚动加载 async function loadListData(listId) {
window.addEventListener('scroll', () => { const cfg = config[listId];
if (document.getElementById('orderListPage').classList.contains('active')) { const state = listState[listId];
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) { if (!cfg || !state) return;
if (orderHasMore && !orderLoading) loadOrders(true);
}
}
if (document.getElementById('dispatchListPage').classList.contains('active')) {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
if (dispatchHasMore && !dispatchLoading) loadDispatches(true);
}
}
if (document.getElementById('otherIncomeListPage').classList.contains('active')) {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
if (otherIncomeHasMore && !otherIncomeLoading) loadOtherIncome(true);
}
}
if (document.getElementById('redEnvelopeListPage').classList.contains('active')) {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
if (redEnvelopeHasMore && !redEnvelopeLoading) loadRedEnvelope(true);
}
}
if (document.getElementById('milkTeaListPage').classList.contains('active')) {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
if (milkTeaHasMore && !milkTeaLoading) loadMilkTea(true);
}
}
});
// 红包及其他收入列表 if (state.loading || !state.hasMore) return;
let otherIncomePage = 1;
let otherIncomeHasMore = true; state.loading = true;
let otherIncomeLoading = false; showLoading(listId, true);
async function loadOtherIncome(append) {
if (otherIncomeLoading || (!append && !otherIncomeHasMore)) return;
otherIncomeLoading = true;
document.getElementById('otherIncomeLoading').style.display = 'block';
try { try {
const res = await fetch(API + '/api/records/other-income?page=' + otherIncomePage + '&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('otherIncomeList');
if (!append) container.innerHTML = ''; if (!res.ok) {
if (data.records.length === 0) { console.error('[loadListData] fetch failed:', res.status);
if (otherIncomePage === 1) container.innerHTML = '<div class="ranking-empty">暂无红包及其他收入记录</div>'; state.loading = false;
otherIncomeHasMore = false; showLoading(listId, false);
} else { return;
const grouped = {};
data.records.forEach(r => {
const date = formatLocalDate(r.created_at);
if (!grouped[date]) grouped[date] = [];
grouped[date].push(r);
});
let html = '';
for (const [date, records] of Object.entries(grouped)) {
html += `<div class="order-date-group" data-date="${date}">
<div class="order-date-label">${date}</div>`;
records.forEach(r => {
const time = formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'>
<div class="order-item-top">
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span>
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
</div>
<div class="order-item-bottom">
<span>${r.source || ''}</span>
<span>${time}</span>
</div>
</div>`;
});
html += '</div>';
}
container.insertAdjacentHTML('beforeend', html);
otherIncomeHasMore = data.records.length >= 20;
otherIncomePage++;
} }
} catch (e) { console.error(e); }
otherIncomeLoading = false; const data = await res.json();
document.getElementById('otherIncomeLoading').style.display = 'none'; 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) {
let dispatchPage = 1; const loadingId = listId.replace('List', 'Loading');
let dispatchHasMore = true; const loadingEl = document.getElementById(loadingId);
let dispatchLoading = false; if (loadingEl) {
loadingEl.style.display = show ? 'block' : 'none';
async function loadDispatches(append) { if (!show && listState[listId]) {
if (dispatchLoading || (!append && !dispatchHasMore)) return; const state = listState[listId];
dispatchLoading = true; if (state.hasMore) {
document.getElementById('dispatchLoading').style.display = 'block'; loadingEl.textContent = '下拉加载更多...';
try { } else if (state.records.length > 0) {
const res = await fetch(API + '/api/records/dispatches?page=' + dispatchPage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); loadingEl.textContent = '已加载全部';
if (!res.ok) return; } else {
const data = await res.json(); loadingEl.textContent = '';
const container = document.getElementById('dispatchList');
if (!append) container.innerHTML = '';
if (data.records.length === 0) {
if (dispatchPage === 1) container.innerHTML = '<div class="ranking-empty">暂无派单记录</div>';
dispatchHasMore = false;
} else {
const grouped = {};
data.records.forEach(r => {
const date = formatLocalDate(r.created_at);
if (!grouped[date]) grouped[date] = [];
grouped[date].push(r);
});
let html = '';
for (const [date, records] of Object.entries(grouped)) {
html += `<div class="order-date-group" data-date="${date}">
<div class="order-date-label">${date}</div>`;
records.forEach(r => {
const time = formatLocalTime(r.created_at);
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'>
<div class="order-item-top">
<span class="order-item-boss">${r.boss || '-'}</span>
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
</div>
<div class="order-item-bottom">
<span>${r.quantity || 0}× ${(r.rebate || r.amount / r.quantity).toFixed(2)}(单价${(r.unit_price || 0).toFixed(2)}</span>
<span>${time}${r.partner ? ' · ' + r.partner : ''}</span>
</div>
</div>`;
});
html += '</div>';
} }
container.insertAdjacentHTML('beforeend', html);
dispatchHasMore = data.records.length >= 20;
dispatchPage++;
} }
} catch (e) { console.error(e); } }
dispatchLoading = false;
document.getElementById('dispatchLoading').style.display = 'none';
} }
// 红包收入列表 function renderList(listId) {
let redEnvelopePage = 1; const cfg = config[listId];
let redEnvelopeHasMore = true; const state = listState[listId];
let redEnvelopeLoading = false; if (!cfg || !state) return;
async function loadRedEnvelope(append) { const container = document.getElementById(listId);
if (redEnvelopeLoading || (!append && !redEnvelopeHasMore)) return; if (!container) return;
redEnvelopeLoading = true;
document.getElementById('redEnvelopeLoading').style.display = 'block'; if (state.records.length === 0) {
try { container.innerHTML = '<div class="ranking-empty">' + cfg.emptyMsg + '</div>';
const res = await fetch(API + '/api/records/red-envelope?page=' + redEnvelopePage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); return;
if (!res.ok) return; }
const data = await res.json();
const container = document.getElementById('redEnvelopeList'); const grouped = {};
if (!append) container.innerHTML = ''; state.records.forEach(r => {
if (data.records.length === 0) { const date = formatLocalDate(r.created_at);
if (redEnvelopePage === 1) container.innerHTML = '<div class="ranking-empty">暂无红包收入记录</div>'; if (!grouped[date]) grouped[date] = [];
redEnvelopeHasMore = false; grouped[date].push(r);
} else { });
const grouped = {};
data.records.forEach(r => { container.innerHTML = '';
const date = formatLocalDate(r.created_at);
if (!grouped[date]) grouped[date] = []; for (const [date, records] of Object.entries(grouped)) {
grouped[date].push(r); const group = document.createElement('div');
}); group.className = 'order-date-group';
let html = ''; group.setAttribute('data-date', date);
for (const [date, records] of Object.entries(grouped)) {
html += `<div class="order-date-group" data-date="${date}"> const label = document.createElement('div');
<div class="order-date-label">${date}</div>`; label.className = 'order-date-label';
records.forEach(r => { label.textContent = date;
const time = formatLocalTime(r.created_at); group.appendChild(label);
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'>
<div class="order-item-top"> records.forEach(r => {
<span class="order-item-boss">红包</span> const item = document.createElement('div');
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span> item.innerHTML = cfg.renderItem(r);
</div> group.appendChild(item.firstChild);
<div class="order-item-bottom"> });
<span>${r.source || ''}</span>
<span>${time}</span> container.appendChild(group);
</div> }
</div>`;
});
html += '</div>';
}
container.insertAdjacentHTML('beforeend', html);
redEnvelopeHasMore = data.records.length >= 20;
redEnvelopePage++;
}
} catch (e) { console.error(e); }
redEnvelopeLoading = false;
document.getElementById('redEnvelopeLoading').style.display = 'none';
} }
// 奶茶收入列表 function setupScrollObserver(listId) {
let milkTeaPage = 1; const state = listState[listId];
let milkTeaHasMore = true; if (!state || !state.hasMore) return;
let milkTeaLoading = false;
async function loadMilkTea(append) { const container = document.getElementById(listId);
if (milkTeaLoading || (!append && !milkTeaHasMore)) return; if (!container) return;
milkTeaLoading = true;
document.getElementById('milkTeaLoading').style.display = 'block'; // 移除旧的 sentinel
try { const oldSentinel = container.querySelector('.scroll-sentinel');
const res = await fetch(API + '/api/records/milk-tea?page=' + milkTeaPage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); if (oldSentinel) oldSentinel.remove();
if (!res.ok) return;
const data = await res.json(); // 创建新的 sentinel
const container = document.getElementById('milkTeaList'); const sentinel = document.createElement('div');
if (!append) container.innerHTML = ''; sentinel.className = 'scroll-sentinel';
if (data.records.length === 0) { sentinel.style.height = '1px';
if (milkTeaPage === 1) container.innerHTML = '<div class="ranking-empty">暂无奶茶收入记录</div>'; container.appendChild(sentinel);
milkTeaHasMore = false;
} else { const observer = new IntersectionObserver((entries) => {
const grouped = {}; if (entries[0].isIntersecting && !state.loading && state.hasMore) {
data.records.forEach(r => { loadListData(listId);
const date = formatLocalDate(r.created_at);
if (!grouped[date]) grouped[date] = [];
grouped[date].push(r);
});
let html = '';
for (const [date, records] of Object.entries(grouped)) {
html += `<div class="order-date-group" data-date="${date}">
<div class="order-date-label">${date}</div>`;
records.forEach(r => {
const time = formatLocalTime(r.created_at);
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'>
<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>`;
});
html += '</div>';
}
container.insertAdjacentHTML('beforeend', html);
milkTeaHasMore = data.records.length >= 20;
milkTeaPage++;
} }
} catch (e) { console.error(e); } }, { rootMargin: '100px' });
milkTeaLoading = false;
document.getElementById('milkTeaLoading').style.display = 'none'; observer.observe(sentinel);
// 存储 observer 以便清理
state.observer = observer;
}
// ============================================
// 兼容接口
// ============================================
async function loadOrders() { return loadListData('orderList'); }
async function loadOtherIncome() { return loadListData('otherIncomeList'); }
async function loadDispatches() { return loadListData('dispatchList'); }
async function loadRedEnvelope() { return loadListData('redEnvelopeList'); }
async function loadMilkTea() { return loadListData('milkTeaList'); }
// ============================================
// 渲染函数
// ============================================
function renderOrderItem(r) {
const time = formatLocalTime(r.created_at);
return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<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>' + escapeHtml(time + (r.partner ? ' · ' + r.partner : '')) + '</span></div></div>';
}
function renderOtherIncomeItem(r) {
const time = formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<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>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
}
function renderDispatchItem(r) {
const time = formatLocalTime(r.created_at);
const unitPrice = r.unit_price || (r.quantity ? r.amount / r.quantity : 0);
return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<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>' + escapeHtml(time + (r.partner ? ' · ' + r.partner : '')) + '</span></div></div>';
}
function renderRedEnvelopeItem(r) {
const time = formatLocalTime(r.created_at);
return '<div class="order-item" onclick="openEditModal(JSON.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-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
}
function renderMilkTeaItem(r) {
const time = formatLocalTime(r.created_at);
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-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
} }
+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; }
+467 -167
View File
@@ -1,45 +1,103 @@
async function loadStats() { async function loadStats() {
try { try {
const res = await fetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); // 并行加载所有数据
if (!res.ok) return; const [monthlyRes, bossRankRes, partnerRankRes, weeklyRes] = await Promise.all([
const s = await res.json(); apiFetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth),
document.getElementById('takeCount').textContent = s.take_order_count; apiFetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth),
document.getElementById('dispatchCount').textContent = s.dispatch_order_count; apiFetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth),
document.getElementById('takeIncome').textContent = '' + s.take_order_income.toFixed(2); apiFetch(API + '/api/stats/daily-income')
document.getElementById('dispatchIncome').textContent = '' + s.dispatch_order_income.toFixed(2); ]);
document.getElementById('redEnvelopeCount').textContent = s.redEnvelope_count || 0;
document.getElementById('redEnvelopeIncome').textContent = '' + (s.redEnvelope_income || 0).toFixed(2); if (monthlyRes.ok) {
document.getElementById('milkTeaCount').textContent = s.milkTea_count || 0; const s = await monthlyRes.json();
document.getElementById('milkTeaIncome').textContent = '' + (s.milkTea_income || 0).toFixed(2); document.getElementById('takeCount').textContent = s.take_order_count;
document.getElementById('totalIncome').textContent = '' + s.total_income.toFixed(2); document.getElementById('dispatchCount').textContent = s.dispatch_order_count;
document.getElementById('totalExpense').textContent = '' + s.total_expense.toFixed(2); document.getElementById('takeIncome').textContent = '' + s.take_order_income.toFixed(2);
document.getElementById('netIncome').textContent = '' + (s.total_income - s.total_expense).toFixed(2); document.getElementById('dispatchIncome').textContent = '' + s.dispatch_order_income.toFixed(2);
loadBossRankingPreview(); document.getElementById('redEnvelopeCount').textContent = s.redEnvelope_count || 0;
loadPartnerRankingPreview(); document.getElementById('redEnvelopeIncome').textContent = '' + (s.redEnvelope_income || 0).toFixed(2);
loadWeeklyChart(); document.getElementById('milkTeaCount').textContent = s.milkTea_count || 0;
document.getElementById('milkTeaIncome').textContent = '' + (s.milkTea_income || 0).toFixed(2);
document.getElementById('totalIncome').textContent = '' + s.total_income.toFixed(2);
document.getElementById('totalExpense').textContent = '' + s.total_expense.toFixed(2);
document.getElementById('netIncome').textContent = '' + (s.total_income - s.total_expense).toFixed(2);
}
if (bossRankRes.ok) {
const data = await bossRankRes.json();
renderBossRankingPreview(data || []);
}
if (partnerRankRes.ok) {
const data = await partnerRankRes.json();
renderPartnerRankingPreview(data || []);
}
if (weeklyRes.ok) {
const days = await weeklyRes.json();
renderWeeklyChart(days);
}
} 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();
const container = document.getElementById('weeklyChartBars'); renderWeeklyChart(days);
const max = Math.max(...days.map(d => d.income), 1);
container.innerHTML = days.map(d => {
const pct = Math.max((d.income / max) * 100, 3);
const label = d.date.slice(5).replace('-', '/');
const amount = d.income > 0 ? '' + d.income.toFixed(0) : '0';
return `<div class="weekly-bar-col" onclick="openDailyDetail('${d.date}')">
<span class="weekly-bar-amount">${amount}</span>
<div class="weekly-bar" style="height:${pct}%"></div>
<span class="weekly-bar-date">${label}</span>
</div>`;
}).join('');
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
function renderWeeklyChart(days) {
const container = document.getElementById('weeklyChartBars');
if (!days || days.length === 0) {
container.innerHTML = '<div class="ranking-empty">暂无数据</div>';
return;
}
const max = Math.max(...days.map(d => d.income), 1);
const barMaxHeight = 110; // 柱状图最大可用像素高度
container.innerHTML = days.map(d => {
const height = Math.max((d.income / max) * barMaxHeight, 4);
const label = d.date.slice(5).replace('-', '/');
const amount = d.income > 0 ? '' + d.income.toFixed(0) : '0';
return `<div class="weekly-bar-col" onclick="openDailyDetail(${jsString(d.date)})">
<span class="weekly-bar-amount">${amount}</span>
<div class="weekly-bar" style="height:${height}px"></div>
<span class="weekly-bar-date">${label}</span>
</div>`;
}).join('');
}
let currentDailyDate = ''; let currentDailyDate = '';
async function openDailyDetail(date) { async function openDailyDetail(date) {
@@ -52,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 = `
@@ -66,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); }
} }
@@ -115,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();
@@ -185,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';
@@ -209,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>';
} }
@@ -229,69 +278,185 @@ 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();
const container = document.getElementById('rankingPreviewList'); renderBossRankingPreview(rows);
if (rows.length === 0) {
container.innerHTML = '<div class="ranking-empty">暂无数据</div>';
return;
}
const max = Math.max(...rows.map(r => r.total_income), 1);
container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.total_income / max) * 100, 2);
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'homePage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.boss}">${r.boss}</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>`;
}).join('');
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
// 渲染横向柱状图排行榜的通用函数
function renderHBarRanking(rows, { containerId, getValue, getLabel, onClick, formatValue }) {
const container = document.getElementById(containerId);
if (!rows || rows.length === 0) {
container.innerHTML = '<div class="ranking-empty">暂无数据</div>';
return;
}
const max = Math.max(...rows.map(r => getValue(r)), 1);
container.innerHTML = rows.map((r, i) => {
const pct = Math.max((getValue(r) / max) * 100, 2);
const rawLabel = getLabel(r);
const label = escapeHtml(rawLabel);
const value = formatValue ? formatValue(getValue(r)) : (typeof getValue(r) === 'number' ? getValue(r).toFixed(2) : getValue(r));
return `<div class="hbar-row" onclick="${onClick}(${jsString(rawLabel)}, 'homePage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</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-value">${value}</div>
</div>`;
}).join('');
}
function renderBossRankingPreview(rows) {
renderHBarRanking(rows, {
containerId: 'rankingPreviewList',
getValue: r => r.total_income,
getLabel: r => r.boss,
onClick: 'openBossDetail'
});
}
let currentBossName = ''; let currentBossName = '';
let bossDetailFrom = 'homePage'; let bossDetailFrom = 'homePage';
let bossDetailYear = 0;
let bossDetailMonth = 0;
let bossDetailScopeAll = false;
let bossDetailPage = 1;
let bossDetailLimit = 20;
let bossDetailTotalCount = 0;
let bossDetailAllRecords = [];
let bossDetailLoading = false;
let bossDetailScrollHandler = null;
function openBossDetail(boss, from) { function openBossDetail(boss, from) {
currentBossName = boss; currentBossName = boss;
bossDetailFrom = from || 'homePage'; bossDetailFrom = from || 'homePage';
bossDetailYear = currentYear;
bossDetailMonth = currentMonth;
// 从排行榜进入时默认显示全部
bossDetailScopeAll = (from === 'rankingPage' || from === 'takeRankingPage');
bossDetailPage = 1;
bossDetailLimit = bossDetailScopeAll ? 500 : 20;
bossDetailTotalCount = 0;
bossDetailAllRecords = [];
updateBossDetailNav();
loadBossDetail(); loadBossDetail();
} }
function bossDetailGoBack() { function bossDetailGoBack() {
if (bossDetailScrollHandler) {
window.removeEventListener('scroll', bossDetailScrollHandler);
bossDetailScrollHandler = null;
}
showPage(bossDetailFrom); showPage(bossDetailFrom);
} }
async function loadBossDetail() { function updateBossDetailNav() {
document.getElementById('bossDetailTitle').textContent = currentBossName + ' 交易记录'; const scopeEl = document.getElementById('bossDetailScope');
document.getElementById('bossDetailSummary').innerHTML = '<div class="ranking-empty">加载中...</div>'; const allBtn = document.getElementById('bossDetailAllBtn');
document.getElementById('bossDetailList').innerHTML = ''; if (bossDetailScopeAll) {
showPage('bossDetailPage'); scopeEl.textContent = '全部';
allBtn.textContent = '当月';
allBtn.classList.add('active');
} else {
scopeEl.textContent = `${bossDetailYear}${bossDetailMonth}`;
allBtn.textContent = '全部';
allBtn.classList.remove('active');
}
}
function switchBossDetailScope(offset) {
if (bossDetailScopeAll) return;
bossDetailMonth += offset;
if (bossDetailMonth > 12) { bossDetailMonth = 1; bossDetailYear++; }
if (bossDetailMonth < 1) { bossDetailMonth = 12; bossDetailYear--; }
bossDetailPage = 1;
bossDetailAllRecords = [];
updateBossDetailNav();
loadBossDetail();
}
function toggleBossDetailAll() {
bossDetailScopeAll = !bossDetailScopeAll;
bossDetailPage = 1;
bossDetailAllRecords = [];
if (bossDetailScopeAll) {
bossDetailLimit = 500;
} else {
bossDetailLimit = 20;
bossDetailYear = currentYear;
bossDetailMonth = currentMonth;
}
updateBossDetailNav();
loadBossDetail();
}
function loadMoreBossDetail() {
bossDetailPage++;
loadBossDetail(true);
}
async function loadBossDetail(append) {
if (bossDetailLoading) return;
bossDetailLoading = true;
if (!append) {
document.getElementById('bossDetailTitle').textContent = currentBossName + ' 交易记录';
document.getElementById('bossDetailSummary').innerHTML = '<div class="ranking-empty">加载中...</div>';
document.getElementById('bossDetailList').innerHTML = '';
showPage('bossDetailPage');
}
let url = API + '/api/stats/boss-records?boss=' + encodeURIComponent(currentBossName) + '&page=' + bossDetailPage + '&limit=' + bossDetailLimit;
if (!bossDetailScopeAll) {
url += '&year=' + bossDetailYear + '&month=' + bossDetailMonth;
}
try { try {
const res = await fetch(API + '/api/stats/boss-records?boss=' + encodeURIComponent(currentBossName), { headers: headers() }); const res = await apiFetch(url);
if (!res.ok) return; if (!res.ok) { bossDetailLoading = false; return; }
const data = await res.json(); const data = await res.json();
document.getElementById('bossDetailSummary').innerHTML = `
<div style="display:flex;justify-content:space-around;flex-wrap:wrap;gap:8px"> if (!append) {
<div style="text-align:center"> document.getElementById('bossDetailSummary').innerHTML = `
<div class="daily-summary-amount" style="font-size:24px;color:#e74c3c">${data.boss_total_expense.toFixed(2)}</div> <div style="display:flex;justify-content:space-around;flex-wrap:wrap;gap:8px">
<div class="daily-summary-label">老板总支出</div> <div style="text-align:center">
<div class="daily-summary-amount" style="font-size:24px;color:#e74c3c">${data.boss_total_expense.toFixed(2)}</div>
<div class="daily-summary-label">老板总支出</div>
</div>
<div style="text-align:center">
<div class="daily-summary-amount" style="font-size:24px;color:#27ae60">${data.my_income.toFixed(2)}</div>
<div class="daily-summary-label">我的总收入</div>
</div>
<div style="text-align:center">
<div class="daily-summary-amount" style="font-size:24px;color:#3498db">${data.take_count || 0}</div>
<div class="daily-summary-label">接单数</div>
</div>
<div style="text-align:center">
<div class="daily-summary-amount" style="font-size:24px;color:#9b59b6">${data.dispatch_count || 0}</div>
<div class="daily-summary-label">派单数</div>
</div>
</div> </div>
<div style="text-align:center"> `;
<div class="daily-summary-amount" style="font-size:24px;color:#27ae60">${data.my_income.toFixed(2)}</div> bossDetailAllRecords = data.records || [];
<div class="daily-summary-label">我的总收入</div> setupBossDetailScroll();
</div> } else {
</div> bossDetailAllRecords = bossDetailAllRecords.concat(data.records || []);
`; }
bossDetailTotalCount = data.totalCount || 0;
const container = document.getElementById('bossDetailList'); const container = document.getElementById('bossDetailList');
if (data.records.length === 0) { const moreBtn = document.getElementById('bossDetailMore');
if (bossDetailAllRecords.length === 0) {
container.innerHTML = '<div class="ranking-empty">暂无交易记录</div>'; container.innerHTML = '<div class="ranking-empty">暂无交易记录</div>';
if (moreBtn) moreBtn.style.display = 'none';
bossDetailLoading = false;
return; return;
} }
const grouped = {}; const grouped = {};
data.records.forEach(r => { bossDetailAllRecords.forEach(r => {
const d = new Date(r.created_at); const d = new Date(r.created_at);
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
if (!grouped[key]) grouped[key] = { records: [], total: 0 }; if (!grouped[key]) grouped[key] = { records: [], total: 0 };
@@ -303,47 +468,72 @@ async function loadBossDetail() {
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>';
} }
container.innerHTML = html; container.innerHTML = html;
} catch (e) { console.error(e); }
// Show/hide load more indicator
if (moreBtn) {
if (bossDetailAllRecords.length < bossDetailTotalCount) {
moreBtn.style.display = 'block';
moreBtn.textContent = '加载中... (' + bossDetailAllRecords.length + '/' + bossDetailTotalCount + ')';
} else {
moreBtn.style.display = 'none';
}
}
bossDetailLoading = false;
} catch (e) {
bossDetailLoading = false;
console.error(e);
}
}
function setupBossDetailScroll() {
if (bossDetailScrollHandler) {
window.removeEventListener('scroll', bossDetailScrollHandler);
}
bossDetailScrollHandler = function() {
if (bossDetailLoading) return;
if (bossDetailScopeAll && bossDetailAllRecords.length >= bossDetailTotalCount) {
window.removeEventListener('scroll', bossDetailScrollHandler);
return;
}
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const docHeight = document.documentElement.scrollHeight;
if (scrollY + windowHeight >= docHeight - 200) {
bossDetailPage++;
loadBossDetail(true);
}
};
window.addEventListener('scroll', bossDetailScrollHandler);
} }
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();
const container = document.getElementById('partnerRankingPreviewList'); renderPartnerRankingPreview(rows);
if (rows.length === 0) {
container.innerHTML = '<div class="ranking-empty">暂无数据</div>';
return;
}
const max = Math.max(...rows.map(r => r.dispatch_count), 1);
container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.dispatch_count / max) * 100, 2);
return `<div class="hbar-row" onclick="openPartnerDetail('${r.partner.replace(/'/g, "\\'")}', 'homePage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.partner}">${r.partner}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.dispatch_count}单</div>
</div>`;
}).join('');
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
function renderPartnerRankingPreview(rows) {
renderHBarRanking(rows, {
containerId: 'partnerRankingPreviewList',
getValue: r => r.dispatch_count,
getLabel: r => r.partner,
onClick: 'openPartnerDetail',
formatValue: v => v + '单'
});
}
function openPartnerRankingPage() { function openPartnerRankingPage() {
const now = new Date(); const now = new Date();
const y = now.getFullYear(); const y = now.getFullYear();
@@ -366,20 +556,15 @@ async function loadPartnerRankingDetail() {
params.push('endDate=' + formatLocalDate(d)); params.push('endDate=' + formatLocalDate(d));
} }
if (params.length) url += '?' + params.join('&'); if (params.length) url += '?' + params.join('&');
console.log('partner-ranking url:', url);
try { try {
const res = await fetch(url, { headers: headers() }); const res = await apiFetch(url);
console.log('partner-ranking res.ok:', res.ok);
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
console.log('partner-ranking data:', data);
const rows = data.rows || []; const rows = data.rows || [];
console.log('rows:', rows);
const summary = data.summary || { total_partner_income: 0, total_rebate_income: 0, total_milkTea_income: 0 }; const summary = data.summary || { total_partner_income: 0, total_rebate_income: 0, total_milkTea_income: 0 };
// 计算派单总数(从列表) // 计算派单总数(从列表)
const totalDispatch = rows.reduce((sum, r) => sum + (r.dispatch_count || 0), 0); const totalDispatch = rows.reduce((sum, r) => sum + (r.dispatch_count || 0), 0);
console.log('totalDispatch:', totalDispatch);
// 更新统计卡片 // 更新统计卡片
document.getElementById('partnerTotalDispatchCount').textContent = totalDispatch; document.getElementById('partnerTotalDispatchCount').textContent = totalDispatch;
@@ -395,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>`;
@@ -407,54 +593,140 @@ async function loadPartnerRankingDetail() {
let currentPartnerName = ''; let currentPartnerName = '';
let partnerDetailFrom = 'homePage'; let partnerDetailFrom = 'homePage';
let partnerDetailYear = 0;
let partnerDetailMonth = 0;
let partnerDetailScopeAll = false;
let partnerDetailPage = 1;
let partnerDetailLimit = 20;
let partnerDetailTotalCount = 0;
let partnerDetailAllRecords = [];
let partnerDetailLoading = false;
let partnerDetailScrollHandler = null;
function openPartnerDetail(partner, from) { function openPartnerDetail(partner, from) {
currentPartnerName = partner; currentPartnerName = partner;
partnerDetailFrom = from || 'homePage'; partnerDetailFrom = from || 'homePage';
partnerDetailYear = currentYear;
partnerDetailMonth = currentMonth;
// 从排行榜进入时默认显示全部
partnerDetailScopeAll = (from === 'dispatchRankingPage' || from === 'partnerRankingPage');
partnerDetailPage = 1;
partnerDetailLimit = partnerDetailScopeAll ? 500 : 20;
partnerDetailTotalCount = 0;
partnerDetailAllRecords = [];
updatePartnerDetailNav();
loadPartnerDetail(); loadPartnerDetail();
} }
function partnerDetailGoBack() { function partnerDetailGoBack() {
if (partnerDetailScrollHandler) {
window.removeEventListener('scroll', partnerDetailScrollHandler);
partnerDetailScrollHandler = null;
}
showPage(partnerDetailFrom); showPage(partnerDetailFrom);
} }
async function loadPartnerDetail() { function updatePartnerDetailNav() {
document.getElementById('partnerDetailTitle').textContent = currentPartnerName + ' 交易记录'; const scopeEl = document.getElementById('partnerDetailScope');
document.getElementById('partnerDetailSummary').innerHTML = '<div class="ranking-empty">加载中...</div>'; const allBtn = document.getElementById('partnerDetailAllBtn');
document.getElementById('partnerDetailList').innerHTML = ''; if (partnerDetailScopeAll) {
showPage('partnerDetailPage'); scopeEl.textContent = '全部';
allBtn.textContent = '当月';
allBtn.classList.add('active');
} else {
scopeEl.textContent = `${partnerDetailYear}${partnerDetailMonth}`;
allBtn.textContent = '全部';
allBtn.classList.remove('active');
}
}
function switchPartnerDetailScope(offset) {
if (partnerDetailScopeAll) return;
partnerDetailMonth += offset;
if (partnerDetailMonth > 12) { partnerDetailMonth = 1; partnerDetailYear++; }
if (partnerDetailMonth < 1) { partnerDetailMonth = 12; partnerDetailYear--; }
partnerDetailPage = 1;
partnerDetailAllRecords = [];
updatePartnerDetailNav();
loadPartnerDetail();
}
function togglePartnerDetailAll() {
partnerDetailScopeAll = !partnerDetailScopeAll;
partnerDetailPage = 1;
partnerDetailAllRecords = [];
if (partnerDetailScopeAll) {
partnerDetailLimit = 500;
} else {
partnerDetailLimit = 20;
partnerDetailYear = currentYear;
partnerDetailMonth = currentMonth;
}
updatePartnerDetailNav();
loadPartnerDetail();
}
async function loadPartnerDetail(append) {
if (partnerDetailLoading) return;
partnerDetailLoading = true;
if (!append) {
document.getElementById('partnerDetailTitle').textContent = currentPartnerName + ' 交易记录';
document.getElementById('partnerDetailSummary').innerHTML = '<div class="ranking-empty">加载中...</div>';
document.getElementById('partnerDetailList').innerHTML = '';
showPage('partnerDetailPage');
}
let url = API + '/api/stats/partner-records?partner=' + encodeURIComponent(currentPartnerName) + '&page=' + partnerDetailPage + '&limit=' + partnerDetailLimit;
if (!partnerDetailScopeAll) {
url += '&year=' + partnerDetailYear + '&month=' + partnerDetailMonth;
}
try { try {
const res = await fetch(API + '/api/stats/partner-records?partner=' + encodeURIComponent(currentPartnerName), { headers: headers() }); const res = await apiFetch(url);
if (!res.ok) return; if (!res.ok) { partnerDetailLoading = false; return; }
const data = await res.json(); const data = await res.json();
document.getElementById('partnerDetailSummary').innerHTML = `
<div style="display:flex;justify-content:space-around;flex-wrap:wrap;gap:8px"> if (!append) {
<div style="text-align:center"> document.getElementById('partnerDetailSummary').innerHTML = `
<div class="daily-summary-amount" style="font-size:24px">${data.dispatch_count}</div> <div style="display:flex;justify-content:space-around;flex-wrap:wrap;gap:8px">
<div class="daily-summary-label">总派单数</div> <div style="text-align:center">
<div class="daily-summary-amount" style="font-size:24px">${data.dispatch_count}</div>
<div class="daily-summary-label">总派单数</div>
</div>
<div style="text-align:center">
<div class="daily-summary-amount" style="font-size:24px;color:#e67e22">${data.spread_income.toFixed(2)}</div>
<div class="daily-summary-label">陪玩总收入</div>
</div>
<div style="text-align:center">
<div class="daily-summary-amount" style="font-size:24px;color:#27ae60">${data.rebate_income.toFixed(2)}</div>
<div class="daily-summary-label">总返点收入</div>
</div>
<div style="text-align:center">
<div class="daily-summary-amount" style="font-size:24px;color:#3498db">${data.other_income.toFixed(2)}</div>
<div class="daily-summary-label">红包及其他</div>
</div>
</div> </div>
<div style="text-align:center"> `;
<div class="daily-summary-amount" style="font-size:24px;color:#e67e22">${data.spread_income.toFixed(2)}</div> partnerDetailAllRecords = data.records || [];
<div class="daily-summary-label">陪玩总收入</div> setupPartnerDetailScroll();
</div> } else {
<div style="text-align:center"> partnerDetailAllRecords = partnerDetailAllRecords.concat(data.records || []);
<div class="daily-summary-amount" style="font-size:24px;color:#27ae60">${data.rebate_income.toFixed(2)}</div> }
<div class="daily-summary-label">总返点收入</div> partnerDetailTotalCount = data.totalCount || 0;
</div>
<div style="text-align:center">
<div class="daily-summary-amount" style="font-size:24px;color:#3498db">${data.other_income.toFixed(2)}</div>
<div class="daily-summary-label">红包及其他</div>
</div>
</div>
`;
const container = document.getElementById('partnerDetailList'); const container = document.getElementById('partnerDetailList');
if (data.records.length === 0) { const moreBtn = document.getElementById('partnerDetailMore');
if (partnerDetailAllRecords.length === 0) {
container.innerHTML = '<div class="ranking-empty">暂无交易记录</div>'; container.innerHTML = '<div class="ranking-empty">暂无交易记录</div>';
if (moreBtn) moreBtn.style.display = 'none';
partnerDetailLoading = false;
return; return;
} }
const grouped = {}; const grouped = {};
data.records.forEach(r => { partnerDetailAllRecords.forEach(r => {
const d = new Date(r.created_at); const d = new Date(r.created_at);
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
if (!grouped[key]) grouped[key] = { records: [], total: 0 }; if (!grouped[key]) grouped[key] = { records: [], total: 0 };
@@ -466,20 +738,48 @@ async function loadPartnerDetail() {
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>';
} }
container.innerHTML = html; container.innerHTML = html;
} catch (e) { console.error(e); }
if (moreBtn) {
if (partnerDetailAllRecords.length < partnerDetailTotalCount) {
moreBtn.style.display = 'block';
moreBtn.textContent = '加载中... (' + partnerDetailAllRecords.length + '/' + partnerDetailTotalCount + ')';
} else {
moreBtn.style.display = 'none';
}
}
partnerDetailLoading = false;
} catch (e) {
partnerDetailLoading = false;
console.error(e);
}
}
function setupPartnerDetailScroll() {
if (partnerDetailScrollHandler) {
window.removeEventListener('scroll', partnerDetailScrollHandler);
}
partnerDetailScrollHandler = function() {
if (partnerDetailLoading) return;
if (partnerDetailScopeAll && partnerDetailAllRecords.length >= partnerDetailTotalCount) {
window.removeEventListener('scroll', partnerDetailScrollHandler);
return;
}
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const docHeight = document.documentElement.scrollHeight;
if (scrollY + windowHeight >= docHeight - 200) {
partnerDetailPage++;
loadPartnerDetail(true);
}
};
window.addEventListener('scroll', partnerDetailScrollHandler);
} }
+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']
};
+111 -65
View File
@@ -1,84 +1,130 @@
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const path = require('path'); const path = require('path');
const logger = require('./utils/logger');
const db = new Database(path.join(__dirname, '..', 'data.db')); const db = new Database(path.join(__dirname, '..', 'data.db'));
// 启用 WAL 模式和外键约束
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
// 创建表 /**
db.exec(` * 创建表结构
CREATE TABLE IF NOT EXISTS users ( */
id INTEGER PRIMARY KEY AUTOINCREMENT, function createTables() {
username TEXT UNIQUE NOT NULL, db.exec(`
password TEXT NOT NULL, CREATE TABLE IF NOT EXISTS users (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP id INTEGER PRIMARY KEY AUTOINCREMENT,
); username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS records ( CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK(type IN ('income', 'expense')), type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
category TEXT NOT NULL, category TEXT NOT NULL,
amount REAL NOT NULL, amount REAL NOT NULL,
quantity INTEGER, quantity INTEGER,
unit_price REAL, unit_price REAL,
boss TEXT, boss TEXT,
partner TEXT, partner TEXT,
source TEXT, source TEXT,
destination TEXT, destination TEXT,
note TEXT, note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
); );
`);
// 迁移:去掉 category 的 CHECK 约束 CREATE INDEX IF NOT EXISTS idx_records_user_id ON records(user_id);
try { CREATE INDEX IF NOT EXISTS idx_records_category ON records(category);
const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get(); CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at);
if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) { CREATE INDEX IF NOT EXISTS idx_records_type ON records(type);
db.exec(` CREATE INDEX IF NOT EXISTS idx_records_boss ON records(boss);
CREATE TABLE records_new ( CREATE INDEX IF NOT EXISTS idx_records_partner ON records(partner);
id INTEGER PRIMARY KEY AUTOINCREMENT, CREATE INDEX IF NOT EXISTS idx_records_source ON records(source);
user_id INTEGER NOT NULL, CREATE INDEX IF NOT EXISTS idx_records_user_date ON records(user_id, created_at);
type TEXT NOT NULL CHECK(type IN ('income', 'expense')), CREATE INDEX IF NOT EXISTS idx_records_user_category ON records(user_id, category, created_at);
category TEXT NOT NULL, CREATE INDEX IF NOT EXISTS idx_records_destination ON records(destination);
amount REAL NOT NULL, `);
quantity INTEGER, logger.debug('表结构创建完成');
unit_price REAL, }
boss TEXT,
partner TEXT, /**
source TEXT, * 迁移:去掉 category 的 CHECK 约束
destination TEXT, */
note TEXT, function migrateRemoveCategoryCheck() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, try {
FOREIGN KEY (user_id) REFERENCES users(id) const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get();
); if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) {
INSERT INTO records_new SELECT * FROM records; logger.info('执行迁移:移除 category CHECK 约束');
DROP TABLE records; db.exec(`
ALTER TABLE records_new RENAME TO records; CREATE TABLE records_new (
`); id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
category TEXT NOT NULL,
amount REAL NOT NULL,
quantity INTEGER,
unit_price REAL,
boss TEXT,
partner TEXT,
source TEXT,
destination TEXT,
note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
INSERT INTO records_new SELECT * FROM records;
DROP TABLE records;
ALTER TABLE records_new RENAME TO records;
`);
logger.info('迁移完成:category CHECK 约束已移除');
}
} catch (e) {
logger.warn('迁移跳过 (category):', e.message);
} }
} catch (e) {
console.log('Migration skipped:', e.message);
} }
// 迁移:添加 rebate 列(派单返点) /**
try { * 迁移:添加 rebate 列
const cols = db.prepare("PRAGMA table_info(records)").all(); */
if (!cols.find(c => c.name === 'rebate')) { function migrateAddRebateColumn() {
db.exec("ALTER TABLE records ADD COLUMN rebate REAL"); try {
const cols = db.prepare("PRAGMA table_info(records)").all();
if (!cols.find(c => c.name === 'rebate')) {
logger.info('执行迁移:添加 rebate 列');
db.exec("ALTER TABLE records ADD COLUMN rebate REAL");
logger.info('迁移完成:rebate 列已添加');
}
} catch (e) {
logger.warn('迁移跳过 (rebate):', e.message);
} }
} catch (e) {
console.log('Rebate migration skipped:', e.message);
} }
// 初始化默认用户 lizhilun /**
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun'); * 初始化默认用户
if (!existingUser) { */
const hash = bcrypt.hashSync('lizhilun', 10); function initDefaultUser() {
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash); if (process.env.NODE_ENV === 'production') {
logger.info('生产环境跳过默认用户初始化');
return;
}
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun');
if (!existingUser) {
const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync('lizhilun', 10);
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash);
logger.info('开发默认用户 lizhilun 已创建');
}
} }
// 执行初始化
createTables();
migrateRemoveCategoryCheck();
migrateAddRebateColumn();
initDefaultUser();
module.exports = db; module.exports = db;
+92 -3
View File
@@ -2,16 +2,54 @@ const express = require('express');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const db = require('../db'); const db = require('../db');
const config = require('../config');
const logger = require('../utils/logger');
const router = express.Router(); const router = express.Router();
const SECRET = 'gamer-order-secret-2024';
const loginAttempts = new Map();
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const LOGIN_MAX_FAILURES = 5;
function normalizeLoginKey(req, username) {
return `${req.ip || req.socket.remoteAddress || 'unknown'}:${String(username || '').trim().toLowerCase()}`;
}
function getLoginAttempt(key) {
const now = Date.now();
const attempt = loginAttempts.get(key);
if (!attempt || attempt.resetAt <= now) {
const fresh = { failures: 0, resetAt: now + LOGIN_WINDOW_MS };
loginAttempts.set(key, fresh);
return fresh;
}
return attempt;
}
function clearExpiredLoginAttempts() {
const now = Date.now();
for (const [key, attempt] of loginAttempts) {
if (attempt.resetAt <= now) loginAttempts.delete(key);
}
}
function recordFailedLogin(key) {
const attempt = getLoginAttempt(key);
attempt.failures += 1;
if (loginAttempts.size > 1000) clearExpiredLoginAttempts();
return attempt;
}
function resetLoginAttempts(key) {
loginAttempts.delete(key);
}
// JWT 中间件 // JWT 中间件
function auth(req, res, next) { function auth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1]; const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: '未登录' }); if (!token) return res.status(401).json({ error: '未登录' });
try { try {
req.user = jwt.verify(token, SECRET); req.user = jwt.verify(token, config.jwtSecret);
next(); next();
} catch { } catch {
res.status(401).json({ error: '登录已过期' }); res.status(401).json({ error: '登录已过期' });
@@ -21,12 +59,63 @@ function auth(req, res, next) {
// 登录 // 登录
router.post('/login', (req, res) => { router.post('/login', (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' });
}
const loginKey = normalizeLoginKey(req, username);
const attempt = getLoginAttempt(loginKey);
if (attempt.failures >= LOGIN_MAX_FAILURES) {
const retryAfter = Math.ceil((attempt.resetAt - Date.now()) / 1000);
res.setHeader('Retry-After', String(Math.max(retryAfter, 1)));
return res.status(429).json({ error: '登录失败次数过多,请稍后再试' });
}
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user || !bcrypt.compareSync(password, user.password)) { if (!user || !bcrypt.compareSync(password, user.password)) {
recordFailedLogin(loginKey);
logger.warn(`登录失败: ${username}`);
return res.status(401).json({ error: '用户名或密码错误' }); return res.status(401).json({ error: '用户名或密码错误' });
} }
const token = jwt.sign({ id: user.id, username: user.username }, SECRET, { expiresIn: '7d' });
resetLoginAttempts(loginKey);
const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
logger.info(`用户登录: ${username}`);
res.json({ token, username: user.username }); res.json({ token, username: user.username });
}); });
// 注册
router.post('/register', (req, res) => {
if (!config.enableRegistration) {
return res.status(403).json({ error: '注册已关闭' });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' });
}
if (username.length < 2 || username.length > 20) {
return res.status(400).json({ error: '用户名长度需在2-20个字符之间' });
}
if (password.length < 6) {
return res.status(400).json({ error: '密码长度不能少于6个字符' });
}
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing) {
return res.status(409).json({ error: '用户名已存在' });
}
const hash = bcrypt.hashSync(password, 10);
const result = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run(username, hash);
logger.info(`新用户注册: ${username}`);
const token = jwt.sign({ id: result.lastInsertRowid, username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
res.json({ token, username });
});
module.exports = { router, auth }; module.exports = { router, auth };
+160 -75
View File
@@ -1,61 +1,93 @@
const express = require('express'); const express = require('express');
const db = require('../db'); const db = require('../db');
const { parseCSVLine } = require('../utils/helpers'); const config = require('../config');
const logger = require('../utils/logger');
const {
parseCSVLine,
AppError,
validateNumber,
validateInList,
validateIsoDate,
optionalString
} = require('../utils/helpers');
const router = express.Router(); const router = express.Router();
function emptyToNull(value) {
return value === undefined || value === null || value === '' ? null : value;
}
function nullableDbValue(value) {
return value === undefined || value === null || value === '' ? null : value;
}
const csvTextFields = new Set(['type', 'category', 'boss', 'partner', 'source', 'destination', 'note', 'created_at']);
function escapeCsvFormula(value) {
return /^[\t\r=+\-@]/.test(value) || /^[ \t]+[=+\-@]/.test(value)
? `'${value}`
: value;
}
function encodeCsvCell(value, col) {
if (value === null || value === undefined) return '';
const str = csvTextFields.has(col) ? escapeCsvFormula(String(value)) : String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
// 导出数据 // 导出数据
router.get('/export', (req, res) => { router.get('/export', (req, res, next) => {
const format = req.query.format; try {
if (!format || !['json', 'csv'].includes(format)) { const format = req.query.format;
return res.status(400).json({ error: '请指定格式: json 或 csv' }); if (!format || !['json', 'csv'].includes(format)) {
throw new AppError('请指定格式: json 或 csv', 400);
}
const records = db.prepare(
'SELECT type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at FROM records WHERE user_id = ? ORDER BY created_at DESC'
).all(req.user.id);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `gamer-export-${timestamp}`;
if (format === 'json') {
const exportData = {
exportVersion: 1,
exportedAt: new Date().toISOString(),
username: req.user.username,
recordCount: records.length,
records
};
logger.info(`用户 ${req.user.username} 导出 JSON 数据: ${records.length}`);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
return res.send(JSON.stringify(exportData, null, 2));
}
// CSV with UTF-8 BOM
const BOM = '\uFEFF';
const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at'];
const csvRows = records.map(r => {
return cols.map(col => encodeCsvCell(r[col], col)).join(',');
});
logger.info(`用户 ${req.user.username} 导出 CSV 数据: ${records.length}`);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n'));
} catch (err) {
next(err);
} }
const records = db.prepare(
'SELECT type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at FROM records WHERE user_id = ? ORDER BY created_at DESC'
).all(req.user.id);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `gamer-export-${timestamp}`;
if (format === 'json') {
const exportData = {
exportVersion: 1,
exportedAt: new Date().toISOString(),
username: req.user.username,
recordCount: records.length,
records
};
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
return res.send(JSON.stringify(exportData, null, 2));
}
// CSV with UTF-8 BOM
const BOM = '\uFEFF';
const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at'];
const csvRows = records.map(r => {
return cols.map(col => {
const val = r[col];
if (val === null || val === undefined) return '';
const str = String(val);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}).join(',');
});
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n'));
}); });
// 导入数据 // 导入数据
router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res) => { router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res, next) => {
try { try {
const raw = req.body; const raw = req.body;
if (!raw || typeof raw !== 'string') { if (!raw || typeof raw !== 'string') {
return res.status(400).json({ error: '未收到文件内容' }); throw new AppError('未收到文件内容', 400);
} }
let records = []; let records = [];
@@ -66,45 +98,79 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res)
if (trimmed.startsWith('{')) { if (trimmed.startsWith('{')) {
const data = JSON.parse(trimmed); const data = JSON.parse(trimmed);
if (!data.exportVersion || !Array.isArray(data.records)) { if (!data.exportVersion || !Array.isArray(data.records)) {
return res.status(400).json({ error: '无效的JSON导出文件格式' }); throw new AppError('无效的JSON导出文件格式', 400);
} }
records = data.records; records = data.records;
} else { } else {
const lines = trimmed.split('\n').map(l => l.replace(/\r$/, '')); const lines = trimmed.split('\n').map(l => l.replace(/\r$/, ''));
if (lines.length < 2) { if (lines.length < 2) {
return res.status(400).json({ error: 'CSV文件为空或格式不正确' }); throw new AppError('CSV文件为空或格式不正确', 400);
} }
if (lines[0] !== allFields.join(',')) { if (lines[0] !== allFields.join(',')) {
return res.status(400).json({ error: 'CSV列头不匹配,请使用本系统导出的文件' }); throw new AppError('CSV列头不匹配,请使用本系统导出的文件', 400);
} }
const csvHeaders = lines[0].split(','); const csvHeaders = lines[0].split(',');
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue; if (!lines[i].trim()) continue;
const values = parseCSVLine(lines[i]); const values = parseCSVLine(lines[i]);
const record = {}; const record = {};
csvHeaders.forEach((h, idx) => { record[h] = values[idx] || null; }); csvHeaders.forEach((h, idx) => { record[h] = emptyToNull(values[idx]); });
if (record.amount) record.amount = parseFloat(record.amount); if (record.amount !== null) record.amount = parseFloat(record.amount);
if (record.quantity) record.quantity = parseInt(record.quantity) || null; if (record.quantity !== null) record.quantity = parseInt(record.quantity, 10);
if (record.unit_price) record.unit_price = parseFloat(record.unit_price) || null; if (record.unit_price !== null) record.unit_price = parseFloat(record.unit_price);
if (record.rebate) record.rebate = parseFloat(record.rebate) || null; if (record.rebate !== null) record.rebate = parseFloat(record.rebate);
records.push(record); records.push(record);
} }
} }
for (const r of records) { if (records.length > config.import.maxRecords) {
for (const f of requiredFields) { throw new AppError(`单次最多导入 ${config.import.maxRecords} 条记录`, 400);
if (!r[f] && r[f] !== 0) {
return res.status(400).json({ error: `记录缺少必填字段: ${f}` });
}
}
if (!['income', 'expense'].includes(r.type)) {
return res.status(400).json({ error: `无效的类型: ${r.type}` });
}
} }
const existingCheck = db.prepare( const allCategories = [...config.categories.income, ...config.categories.expense];
'SELECT id FROM records WHERE user_id = ? AND created_at = ? AND category = ? AND amount = ?' const normalizedRecords = records.map((r, index) => {
); for (const f of requiredFields) {
if (!r[f] && r[f] !== 0) {
throw new AppError(`${index + 1} 条记录缺少必填字段: ${f}`, 400);
}
}
validateInList(r.type, 'type', ['income', 'expense']);
validateInList(r.category, 'category', allCategories);
const amount = r.category === '派单'
? validateNumber(r.amount, '金额')
: validateNumber(r.amount, '金额', 0);
return {
type: r.type,
category: r.category,
amount,
quantity: r.quantity === undefined || r.quantity === null || r.quantity === '' ? null : validateNumber(r.quantity, '数量', 0),
unit_price: r.unit_price === undefined || r.unit_price === null || r.unit_price === '' ? null : validateNumber(r.unit_price, '单价', 0),
rebate: r.rebate === undefined || r.rebate === null || r.rebate === '' ? null : validateNumber(r.rebate, '返点'),
boss: optionalString(r.boss, '老板', 100),
partner: optionalString(r.partner, '陪玩', 100),
source: optionalString(r.source, '来源', 100),
destination: optionalString(r.destination, '去向', 100),
note: optionalString(r.note, '备注', 500),
created_at: validateIsoDate(r.created_at, '创建时间')
};
});
const existingCheck = db.prepare(`
SELECT id FROM records
WHERE user_id = ?
AND type = ?
AND category = ?
AND amount = ?
AND COALESCE(quantity, '') = COALESCE(?, '')
AND COALESCE(unit_price, '') = COALESCE(?, '')
AND COALESCE(rebate, '') = COALESCE(?, '')
AND COALESCE(boss, '') = COALESCE(?, '')
AND COALESCE(partner, '') = COALESCE(?, '')
AND COALESCE(source, '') = COALESCE(?, '')
AND COALESCE(destination, '') = COALESCE(?, '')
AND COALESCE(note, '') = COALESCE(?, '')
AND created_at = ?
`);
const insertStmt = db.prepare(` const insertStmt = db.prepare(`
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at) INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -113,23 +179,42 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res)
let imported = 0, skipped = 0; let imported = 0, skipped = 0;
const insertMany = db.transaction((recs) => { const insertMany = db.transaction((recs) => {
for (const r of recs) { for (const r of recs) {
if (existingCheck.get(req.user.id, r.created_at, r.category, r.amount)) { const values = [
req.user.id,
r.type,
r.category,
r.amount,
r.quantity,
r.unit_price,
r.rebate,
r.boss,
r.partner,
r.source,
r.destination,
r.note,
r.created_at
];
if (existingCheck.get(...values)) {
skipped++; continue; skipped++; continue;
} }
insertStmt.run(req.user.id, r.type, r.category, r.amount, insertStmt.run(req.user.id, r.type, r.category, r.amount,
r.quantity || null, r.unit_price || null, r.rebate || null, nullableDbValue(r.quantity), nullableDbValue(r.unit_price), nullableDbValue(r.rebate),
r.boss || null, r.partner || null, nullableDbValue(r.boss), nullableDbValue(r.partner),
r.source || null, r.destination || null, nullableDbValue(r.source), nullableDbValue(r.destination),
r.note || null, r.created_at); nullableDbValue(r.note), r.created_at);
imported++; imported++;
} }
}); });
insertMany(records); insertMany(normalizedRecords);
res.json({ imported, skipped, total: records.length }); logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped}`);
} catch (e) { res.json({ imported, skipped, total: normalizedRecords.length });
console.error('Import error:', e); } catch (err) {
res.status(400).json({ error: '文件解析失败: ' + e.message }); if (err instanceof SyntaxError) {
next(new AppError('文件解析失败: ' + err.message, 400));
} else {
next(err);
}
} }
}); });
+190 -86
View File
@@ -1,109 +1,162 @@
const express = require('express'); const express = require('express');
const db = require('../db'); const db = require('../db');
const { getMonthRange } = require('../utils/helpers'); const config = require('../config');
const logger = require('../utils/logger');
const {
getMonthRange,
AppError,
validateRequired,
validateNumber,
validateInList,
validatePage,
validateLimit,
validateIsoDate,
optionalString
} = require('../utils/helpers');
const router = express.Router(); const router = express.Router();
// 新增记录 // 新增记录
router.post('/', (req, res) => { router.post('/', (req, res, next) => {
const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body; try {
const stmt = db.prepare(` const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body;
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) // 输入验证
`); validateRequired(req.body, ['type', 'category', 'amount']);
const result = stmt.run(req.user.id, type, category, amount, quantity || null, unit_price || null, rebate || null, boss || null, partner || null, source || null, destination || null, note || null, created_at || new Date().toISOString()); validateInList(type, 'type', ['income', 'expense']);
res.json({ id: result.lastInsertRowid });
const allCategories = [...config.categories.income, ...config.categories.expense];
validateInList(category, 'category', allCategories);
// 派单的返点可为负数,因此金额也允许为负数
const validatedAmount = category === '派单'
? validateNumber(amount, '金额')
: validateNumber(amount, '金额', 0);
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
const stmt = db.prepare(`
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
req.user.id,
type,
category,
validatedAmount,
validatedQuantity,
validatedUnitPrice,
validatedRebate,
optionalString(boss, '老板', 100),
optionalString(partner, '陪玩', 100),
optionalString(source, '来源', 100),
optionalString(destination, '去向', 100),
optionalString(note, '备注', 500),
validatedCreatedAt
);
logger.info(`用户 ${req.user.username} 新增记录: ${category} ${validatedAmount}`);
res.json({ id: result.lastInsertRowid });
} catch (err) {
next(err);
}
}); });
// 通用分页查询函数
function queryPagedRecords(userId, year, month, options) {
const page = validatePage(options.page, 1);
const limit = validateLimit(options.limit, config.pagination.defaultLimit, config.pagination.maxLimit);
const offset = (page - 1) * limit;
const { startDate, endDate } = getMonthRange(year, month);
let whereClause = `user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`;
const params = [userId, startDate, endDate];
if (options.category) {
whereClause += ` AND category = ?`;
params.push(options.category);
}
if (options.type) {
whereClause += ` AND type = ?`;
params.push(options.type);
}
if (options.excludeCategories && options.excludeCategories.length > 0) {
whereClause += ` AND category NOT IN (${options.excludeCategories.map(() => '?').join(',')})`;
params.push(...options.excludeCategories);
}
const total = db.prepare(
`SELECT COUNT(*) as count FROM records WHERE ${whereClause}`
).get(...params).count;
const records = db.prepare(
`SELECT * FROM records WHERE ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`
).all(...params, limit, offset);
return { records, total, page, limit };
}
// 接单列表 // 接单列表
router.get('/orders', (req, res) => { router.get('/orders', (req, res) => {
const { page = 1, limit = 20 } = req.query; const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
const offset = (Number(page) - 1) * Number(limit); category: '接单',
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); page: req.query.page,
limit: req.query.limit
const total = db.prepare( });
`SELECT COUNT(*) as count FROM records WHERE user_id = ? AND category = '接单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?` res.json(result);
).get(req.user.id, startDate, endDate).count;
const records = db.prepare(
`SELECT * FROM records WHERE user_id = ? AND category = '接单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
).all(req.user.id, startDate, endDate, Number(limit), offset);
res.json({ records, total, page: Number(page), limit: Number(limit) });
}); });
// 红包及其他收入列表 // 红包及其他收入列表
router.get('/other-income', (req, res) => { router.get('/other-income', (req, res) => {
const { page = 1, limit = 20 } = req.query; const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
const offset = (Number(page) - 1) * Number(limit); type: 'income',
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); excludeCategories: ['接单', '派单'],
page: req.query.page,
const total = db.prepare( limit: req.query.limit
`SELECT COUNT(*) as count FROM records WHERE user_id = ? AND type = 'income' AND category != '接单' AND category != '派单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?` });
).get(req.user.id, startDate, endDate).count; res.json(result);
const records = db.prepare(
`SELECT * FROM records WHERE user_id = ? AND type = 'income' AND category != '接单' AND category != '派单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
).all(req.user.id, startDate, endDate, Number(limit), offset);
res.json({ records, total, page: Number(page), limit: Number(limit) });
}); });
// 红包收入列表 // 红包收入列表
router.get('/red-envelope', (req, res) => { router.get('/red-envelope', (req, res) => {
const { page = 1, limit = 20 } = req.query; const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
const offset = (Number(page) - 1) * Number(limit); type: 'income',
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); category: '红包收入',
page: req.query.page,
const total = db.prepare( limit: req.query.limit
`SELECT COUNT(*) as count FROM records WHERE user_id = ? AND type = 'income' AND category = '红包收入' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?` });
).get(req.user.id, startDate, endDate).count; res.json(result);
const records = db.prepare(
`SELECT * FROM records WHERE user_id = ? AND type = 'income' AND category = '红包收入' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
).all(req.user.id, startDate, endDate, Number(limit), offset);
res.json({ records, total, page: Number(page), limit: Number(limit) });
}); });
// 奶茶收入列表 // 奶茶收入列表
router.get('/milk-tea', (req, res) => { router.get('/milk-tea', (req, res) => {
const { page = 1, limit = 20 } = req.query; const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
const offset = (Number(page) - 1) * Number(limit); type: 'income',
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); category: '奶茶',
page: req.query.page,
const total = db.prepare( limit: req.query.limit
`SELECT COUNT(*) as count FROM records WHERE user_id = ? AND type = 'income' AND category = '奶茶' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?` });
).get(req.user.id, startDate, endDate).count; res.json(result);
const records = db.prepare(
`SELECT * FROM records WHERE user_id = ? AND type = 'income' AND category = '奶茶' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
).all(req.user.id, startDate, endDate, Number(limit), offset);
res.json({ records, total, page: Number(page), limit: Number(limit) });
}); });
// 派单列表 // 派单列表
router.get('/dispatches', (req, res) => { router.get('/dispatches', (req, res) => {
const { page = 1, limit = 20 } = req.query; const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
const offset = (Number(page) - 1) * Number(limit); category: '派单',
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); page: req.query.page,
limit: req.query.limit
const total = db.prepare( });
`SELECT COUNT(*) as count FROM records WHERE user_id = ? AND category = '派单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?` res.json(result);
).get(req.user.id, startDate, endDate).count;
const records = db.prepare(
`SELECT * FROM records WHERE user_id = ? AND category = '派单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
).all(req.user.id, startDate, endDate, Number(limit), offset);
res.json({ records, total, page: Number(page), limit: Number(limit) });
}); });
// 获取记录列表 // 获取记录列表
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) {
@@ -111,24 +164,75 @@ router.get('/', (req, res) => {
params.push(type); params.push(type);
} }
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(Number(limit), Number(offset)); params.push(validatedLimit, offset);
res.json(db.prepare(sql).all(...params)); res.json(db.prepare(sql).all(...params));
}); });
// 更新记录 // 更新记录
router.put('/:id', (req, res) => { router.put('/:id', (req, res, next) => {
const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body; try {
db.prepare(` const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body;
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ? const id = Number(req.params.id);
WHERE id = ? AND user_id = ?
`).run(quantity || null, unit_price || null, rebate || null, amount, boss || null, partner || null, source || null, destination || null, note || null, created_at || null, req.params.id, req.user.id); if (!id || id <= 0) {
res.json({ success: true }); throw new AppError('无效的记录ID', 400);
}
// 获取原有记录以判断类别(派单允许负数金额)
const existing = db.prepare('SELECT category FROM records WHERE id = ? AND user_id = ?').get(id, req.user.id);
if (!existing) {
throw new AppError('记录不存在', 404);
}
const validatedAmount = existing.category === '派单'
? validateNumber(amount, '金额')
: validateNumber(amount, '金额', 0);
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
db.prepare(`
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ?
WHERE id = ? AND user_id = ?
`).run(
validatedQuantity,
validatedUnitPrice,
validatedRebate,
validatedAmount,
optionalString(boss, '老板', 100),
optionalString(partner, '陪玩', 100),
optionalString(source, '来源', 100),
optionalString(destination, '去向', 100),
optionalString(note, '备注', 500),
validatedCreatedAt,
id,
req.user.id
);
logger.debug(`用户 ${req.user.username} 更新记录: ${id}`);
res.json({ success: true });
} catch (err) {
next(err);
}
}); });
// 删除记录 // 删除记录
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res, next) => {
db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id); try {
res.json({ success: true }); const id = Number(req.params.id);
if (!id || id <= 0) {
throw new AppError('无效的记录ID', 400);
}
db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?').run(id, req.user.id);
logger.info(`用户 ${req.user.username} 删除记录: ${id}`);
res.json({ success: true });
} catch (err) {
next(err);
}
}); });
module.exports = router; module.exports = router;
+267 -193
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,61 +18,65 @@ function formatLocalDate(date) {
} }
// 本月统计 // 本月统计
router.get('/monthly', (req, res) => { router.get('/monthly', (req, res, next) => {
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); try {
const stats = db.prepare(` const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
SELECT const cacheKey = `monthly:${req.user.id}:${startDate}:${endDate}`;
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_order_count, getCached(cacheKey, () => {
COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as take_order_income, const stats = db.prepare(`
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_order_count, SELECT
COALESCE(SUM(CASE WHEN category = '单' THEN amount ELSE 0 END), 0) as dispatch_order_income, COALESCE(SUM(CASE WHEN category = '单' THEN quantity ELSE 0 END), 0) as take_order_count,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income, COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as take_order_income,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN 1 ELSE 0 END), 0) as redEnvelope_count, COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_order_count,
COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as milkTea_income, COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as dispatch_order_income,
COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count, COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income, COALESCE(SUM(CASE WHEN category = '红包收入' THEN 1 ELSE 0 END), 0) as redEnvelope_count,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as milkTea_income,
FROM records COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count,
WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
`).get(req.user.id, startDate, endDate); COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
res.json(stats); FROM records
WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, startDate, endDate);
return stats;
}).then(stats => res.json(stats)).catch(next);
} catch (err) {
next(err);
}
}); });
// 月收入/支出记录列表 // 月收入/支出记录列表
router.get('/monthly-records', (req, res) => { router.get('/monthly-records', (req, res, next) => {
const { type } = req.query; try {
if (!type || !['income', 'expense'].includes(type)) return res.status(400).json({ error: '缺少类型参数' }); const { type, page = 1, limit = 20 } = req.query;
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); if (!type || !['income', 'expense'].includes(type)) {
throw new AppError('缺少类型参数或类型无效', 400);
}
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
const validatedPage = Math.max(1, Number(page) || 1);
const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
const offset = (validatedPage - 1) * validatedLimit;
const summary = db.prepare(` const stats = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total SELECT COALESCE(SUM(amount), 0) as total, COUNT(*) as count
FROM records WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? FROM records WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, type, startDate, endDate); `).get(req.user.id, type, startDate, endDate);
const records = db.prepare(` const records = db.prepare(`
SELECT * FROM records SELECT * FROM records
WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
ORDER BY created_at DESC ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, type, startDate, endDate); `).all(req.user.id, type, startDate, endDate, validatedLimit, offset);
res.json({ total: summary.total, records }); 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,
@@ -75,41 +84,60 @@ router.get('/yearly-net-income', (req, res) => {
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
FROM records FROM records
WHERE user_id = ? WHERE user_id = ?
GROUP BY year, month ORDER BY year DESC, month GROUP BY year, month
`).all(req.user.id); UNION ALL
// 按年份分组每月数据
const monthlyData = {};
monthlyByYear.forEach(m => {
if (!monthlyData[m.year]) monthlyData[m.year] = [];
monthlyData[m.year].push({
month: m.month,
total_income: m.total_income,
total_expense: m.total_expense
});
});
const grandTotal = db.prepare(`
SELECT SELECT
strftime('%Y', created_at) as year,
'00' as month,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income, COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
FROM records WHERE user_id = ? FROM records
`).get(req.user.id); WHERE user_id = ?
GROUP BY year
`).all(req.user.id, req.user.id);
const yearlyStats = [];
const monthlyData = {};
let grandTotal = { total_income: 0, total_expense: 0 };
combined.forEach(row => {
// grandTotal 只累加年度汇总数据(month='00'),避免重复计算月明细
if (row.month === '00') {
grandTotal.total_income += row.total_income;
grandTotal.total_expense += row.total_expense;
yearlyStats.push({
year: row.year,
total_income: row.total_income,
total_expense: row.total_expense
});
} else {
if (!monthlyData[row.year]) monthlyData[row.year] = [];
monthlyData[row.year].push({
month: row.month,
total_income: row.total_income,
total_expense: row.total_expense
});
}
});
yearlyStats.sort((a, b) => b.year - a.year);
res.json({ yearlyStats, monthlyData, grandTotal }); res.json({ yearlyStats, monthlyData, grandTotal });
}); });
// 陪玩派单榜预览 // 陪玩派单榜预览
router.get('/partner-ranking/preview', (req, res) => { router.get('/partner-ranking/preview', (req, res) => {
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
const rows = db.prepare(` const cacheKey = `partner-ranking-preview:${req.user.id}:${startDate}:${endDate}`;
SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income getCached(cacheKey, () => {
FROM records const rows = db.prepare(`
WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != '' SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? FROM records
GROUP BY partner ORDER BY dispatch_count DESC LIMIT 10 WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != ''
`).all(req.user.id, startDate, endDate); AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
res.json(rows); GROUP BY partner ORDER BY dispatch_count DESC LIMIT 10
`).all(req.user.id, startDate, endDate);
return rows;
}).then(rows => res.json(rows));
}); });
// 陪玩派单榜详情 // 陪玩派单榜详情
@@ -139,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 });
}); });
@@ -167,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);
}); });
@@ -202,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);
}); });
@@ -234,72 +254,112 @@ router.get('/total', (req, res) => {
}); });
// 老板交易记录 // 老板交易记录
router.get('/boss-records', (req, res) => { router.get('/boss-records', (req, res, next) => {
const boss = req.query.boss; try {
if (!boss) return res.status(400).json({ error: '缺少老板参数' }); const boss = req.query.boss;
const { page = 1, limit = 20, year, month } = req.query;
if (!boss) throw new AppError('缺少老板参数', 400);
// 我的收入:只统计 type='income' 的记录(接单、派单返点、红包收入等) const validatedPage = Math.max(1, Number(page) || 1);
// 使用 COALESCE(boss, source) 兼容旧数据(之前的红包收入只有 source 字段) const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
const myIncomeSummary = db.prepare(` const offset = (validatedPage - 1) * validatedLimit;
SELECT COALESCE(SUM(amount), 0) as total
FROM records WHERE user_id = ? AND COALESCE(boss, source) = ? AND type = 'income'
`).get(req.user.id, boss);
// 老板支出:派单支出 + 接单支出 + 红包收入(老板发的红包也算老板支出) // 日期过滤条件
const expenseSummary = db.prepare(` let dateFilter = '';
SELECT let dateParams = [];
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as dispatch_expense, if (year && month) {
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as order_expense, const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_expense const endDate = `${year}-${String(month).padStart(2, '0')}-31`;
FROM records WHERE user_id = ? AND COALESCE(boss, source) = ? dateFilter = ` AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') <= ?`;
`).get(req.user.id, boss); dateParams = [startDate, endDate];
}
const boss_total_expense = expenseSummary.dispatch_expense + expenseSummary.order_expense + expenseSummary.redEnvelope_expense; const summary = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as my_income,
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as dispatch_expense,
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as order_expense,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_expense,
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
COUNT(*) as count
FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter}
`).get(req.user.id, boss, ...dateParams);
const records = db.prepare(` const records = db.prepare(`
SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ? ORDER BY created_at DESC SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, boss); `).all(req.user.id, boss, ...dateParams, validatedLimit, offset);
res.json({ res.json({
my_income: myIncomeSummary.total, my_income: summary.my_income,
boss_total_expense: boss_total_expense, boss_total_expense: summary.dispatch_expense + summary.order_expense + summary.redEnvelope_expense,
records take_count: summary.take_count,
}); dispatch_count: summary.dispatch_count,
records,
page: validatedPage,
limit: validatedLimit,
totalCount: summary.count
});
} catch (err) {
next(err);
}
}); });
// 陪玩交易记录 // 陪玩交易记录
router.get('/partner-records', (req, res) => { router.get('/partner-records', (req, res, next) => {
const partner = req.query.partner; try {
if (!partner) return res.status(400).json({ error: '缺少陪玩参数' }); const partner = req.query.partner;
const { page = 1, limit = 20, year, month } = req.query;
if (!partner) throw new AppError('缺少陪玩参数', 400);
const dispatchStats = db.prepare(` const validatedPage = Math.max(1, Number(page) || 1);
SELECT const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
COALESCE(SUM(quantity), 0) as dispatch_count, const offset = (validatedPage - 1) * validatedLimit;
COALESCE(SUM(quantity * (COALESCE(unit_price, 0) - COALESCE(rebate, amount * 1.0 / CASE WHEN quantity > 0 THEN quantity ELSE 1 END))), 0) as spread_income,
COALESCE(SUM(amount), 0) as rebate_income
FROM records WHERE user_id = ? AND category = '派单' AND partner = ?
`).get(req.user.id, partner);
const otherIncome = db.prepare(` // 日期过滤条件
SELECT COALESCE(SUM(amount), 0) as total let dateFilter = '';
FROM records WHERE user_id = ? AND type = 'income' AND category != '派单' AND category != '接单' AND source = ? let dateParams = [];
`).get(req.user.id, partner); if (year && month) {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate = `${year}-${String(month).padStart(2, '0')}-31`;
dateFilter = ` AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') <= ?`;
dateParams = [startDate, endDate];
}
const records = db.prepare(` const summary = db.prepare(`
SELECT * FROM records SELECT
WHERE user_id = ? AND ( COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
(category = '派单' AND partner = ?) OR COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * (COALESCE(unit_price, 0) - COALESCE(rebate, amount * 1.0 / CASE WHEN quantity > 0 THEN quantity ELSE 1 END)) ELSE 0 END), 0) as spread_income,
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?) COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as rebate_income,
) ORDER BY created_at DESC COALESCE(SUM(CASE WHEN type = 'income' AND category != '派单' AND category != '接单' AND source = ? THEN amount ELSE 0 END), 0) as other_income,
`).all(req.user.id, partner, partner); COUNT(*) as count
FROM records WHERE user_id = ? AND (
(category = '派单' AND partner = ?) OR
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
)${dateFilter}
`).get(partner, req.user.id, partner, partner, ...dateParams);
res.json({ const records = db.prepare(`
dispatch_count: dispatchStats.dispatch_count, SELECT * FROM records
spread_income: dispatchStats.spread_income, WHERE user_id = ? AND (
rebate_income: dispatchStats.rebate_income, (category = '派单' AND partner = ?) OR
other_income: otherIncome.total, (type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
records )${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
}); `).all(req.user.id, partner, partner, ...dateParams, validatedLimit, offset);
res.json({
dispatch_count: summary.dispatch_count,
spread_income: summary.spread_income,
rebate_income: summary.rebate_income,
other_income: summary.other_income,
records,
page: validatedPage,
limit: validatedLimit,
totalCount: summary.count
});
} catch (err) {
next(err);
}
}); });
// 最近7天每日收入 // 最近7天每日收入
@@ -316,52 +376,66 @@ router.get('/daily-income', (req, res) => {
endDay.setDate(endDay.getDate() + 1); endDay.setDate(endDay.getDate() + 1);
const endDate = formatLocalDate(endDay); const endDate = formatLocalDate(endDay);
const rows = db.prepare(` const cacheKey = `daily-income:${req.user.id}:${startDate}`;
SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income getCached(cacheKey, () => {
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? const rows = db.prepare(`
GROUP BY DATE(created_at, 'localtime') SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income
`).all(req.user.id, startDate, endDate); FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
GROUP BY DATE(created_at, 'localtime')
const map = {}; `).all(req.user.id, startDate, endDate);
rows.forEach(r => { map[r.date] = r.income; }); const map = {};
res.json(days.map(d => ({ date: d, income: map[d] || 0 }))); rows.forEach(r => { map[r.date] = r.income; });
return days.map(d => ({ date: d, income: map[d] || 0 }));
}).then(data => res.json(data));
}); });
// 某天收入记录详情 // 某天收入记录详情
router.get('/daily-detail', (req, res) => { router.get('/daily-detail', (req, res, next) => {
const { date } = req.query; try {
if (!date) return res.status(400).json({ error: '缺少日期参数' }); const { date, page = 1, limit = 20 } = req.query;
const nextDay = new Date(date); if (!date) throw new AppError('缺少日期参数', 400);
nextDay.setDate(nextDay.getDate() + 1);
const endDate = formatLocalDate(nextDay);
const summary = db.prepare(` const validatedPage = Math.max(1, Number(page) || 1);
SELECT COALESCE(SUM(amount), 0) as total_income const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? const offset = (validatedPage - 1) * validatedLimit;
`).get(req.user.id, date, endDate);
const records = db.prepare(` const nextDay = new Date(date);
SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? nextDay.setDate(nextDay.getDate() + 1);
ORDER BY created_at DESC const endDate = formatLocalDate(nextDay);
`).all(req.user.id, date, endDate);
res.json({ total_income: summary.total_income, records }); const stats = db.prepare(`
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') < ?
`).get(req.user.id, date, endDate);
const records = db.prepare(`
SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, date, endDate, validatedLimit, offset);
res.json({ total_income: stats.total_income, records, page: validatedPage, limit: validatedLimit, totalCount: stats.count });
} catch (err) {
next(err);
}
}); });
// 老板收入排行 - 首页预览 // 老板收入排行 - 首页预览
router.get('/boss-ranking/preview', (req, res) => { router.get('/boss-ranking/preview', (req, res) => {
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
const rows = db.prepare(` const cacheKey = `boss-ranking-preview:${req.user.id}:${startDate}:${endDate}`;
SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income, getCached(cacheKey, () => {
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count, const rows = db.prepare(`
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count, SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
FROM records COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
WHERE user_id = ? AND type = 'income' AND (boss IS NOT NULL AND boss != '' OR source IS NOT NULL AND source != '') COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? FROM records
GROUP BY COALESCE(boss, source) ORDER BY total_income DESC LIMIT 10 WHERE user_id = ? AND type = 'income' AND (boss IS NOT NULL AND boss != '' OR source IS NOT NULL AND source != '')
`).all(req.user.id, startDate, endDate); AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
res.json(rows); GROUP BY COALESCE(boss, source) ORDER BY total_income DESC LIMIT 10
`).all(req.user.id, startDate, endDate);
return rows;
}).then(rows => res.json(rows));
}); });
// 老板收入排行 - 详情页(支持筛选) // 老板收入排行 - 详情页(支持筛选)
+69
View File
@@ -0,0 +1,69 @@
/**
* 统计数据缓存模块
* 基于 LRU 算法的内存缓存
*/
const config = require('../config');
const logger = require('./logger');
const statsCache = new Map();
const CACHE_TTL = config.cache.ttl;
const MAX_CACHE_SIZE = config.cache.maxSize;
/**
* 获取缓存数据,如果不存在则通过 fetchFn 获取并缓存
* @param {string} key 缓存键
* @param {Function} fetchFn 获取数据的函数
* @returns {Promise} 缓存数据
*/
function getCached(key, fetchFn) {
const cached = statsCache.get(key);
if (cached && Date.now() - cached.ts < CACHE_TTL) {
// LRU: move to end
statsCache.delete(key);
statsCache.set(key, cached);
return Promise.resolve(cached.data);
}
// 超出最大缓存数量时,删除最旧的条目
if (statsCache.size >= MAX_CACHE_SIZE) {
const firstKey = statsCache.keys().next().value;
statsCache.delete(firstKey);
}
return Promise.resolve(fetchFn()).then(data => {
statsCache.set(key, { data, ts: Date.now() });
logger.debug(`缓存更新: ${key}`);
return data;
}).catch(err => {
logger.error('缓存获取失败:', err.message);
throw err;
});
}
/**
* 清除指定用户的所有缓存
* @param {number} userId 用户ID
*/
function clearUserCache(userId) {
const prefix = `:${userId}:`;
for (const key of statsCache.keys()) {
if (key.includes(prefix)) {
statsCache.delete(key);
logger.debug(`缓存清除: ${key}`);
}
}
}
/**
* 清除所有缓存
*/
function clearAllCache() {
statsCache.clear();
logger.debug('所有缓存已清除');
}
module.exports = {
getCached,
clearUserCache,
clearAllCache
};
+94 -3
View File
@@ -1,4 +1,17 @@
// 日期范围辅助函数 /**
* 应用错误类
*/
class AppError extends Error {
constructor(message, status = 400) {
super(message);
this.status = status;
this.expose = true; // 允许向客户端暴露错误信息
}
}
/**
* 日期范围辅助函数
*/
function getMonthRange(qYear, qMonth) { function getMonthRange(qYear, qMonth) {
const now = new Date(); const now = new Date();
const y = Number(qYear) || now.getFullYear(); const y = Number(qYear) || now.getFullYear();
@@ -10,7 +23,9 @@ function getMonthRange(qYear, qMonth) {
return { startDate, endDate }; return { startDate, endDate };
} }
// CSV行解析(处理引号内的逗号) /**
* CSV行解析(处理引号内的逗号)
*/
function parseCSVLine(line) { function parseCSVLine(line) {
const result = []; const result = [];
let current = ''; let current = '';
@@ -31,4 +46,80 @@ function parseCSVLine(line) {
return result; return result;
} }
module.exports = { getMonthRange, parseCSVLine }; /**
* 输入验证函数
*/
function validateRequired(obj, fields) {
const missing = fields.filter(f => obj[f] === undefined || obj[f] === null || obj[f] === '');
if (missing.length > 0) {
throw new AppError(`缺少必填字段: ${missing.join(', ')}`, 400);
}
}
function validateNumber(value, field, min, max) {
const num = Number(value);
if (!Number.isFinite(num)) {
throw new AppError(`${field} 必须是数字`, 400);
}
if (min !== undefined && num < min) {
throw new AppError(`${field} 不能小于 ${min}`, 400);
}
if (max !== undefined && num > max) {
throw new AppError(`${field} 不能大于 ${max}`, 400);
}
return num;
}
function validateString(value, field, maxLength) {
if (typeof value !== 'string') {
throw new AppError(`${field} 必须是字符串`, 400);
}
if (maxLength && value.length > maxLength) {
throw new AppError(`${field} 长度不能超过 ${maxLength}`, 400);
}
return value.trim();
}
function optionalString(value, field, maxLength) {
if (value === undefined || value === null || value === '') return null;
return validateString(String(value), field, maxLength);
}
function validateInList(value, field, list) {
if (!list.includes(value)) {
throw new AppError(`${field} 值无效`, 400);
}
return value;
}
function validatePage(value, defaultValue = 1) {
return Math.max(1, Number.parseInt(value, 10) || defaultValue);
}
function validateLimit(value, defaultValue, maxLimit) {
const limit = Number.parseInt(value, 10) || defaultValue;
return Math.min(Math.max(1, limit), maxLimit);
}
function validateIsoDate(value, field) {
if (value === undefined || value === null || value === '') return new Date().toISOString();
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new AppError(`${field} 格式无效`, 400);
}
return date.toISOString();
}
module.exports = {
AppError,
getMonthRange,
parseCSVLine,
validateRequired,
validateNumber,
validateString,
optionalString,
validateInList,
validatePage,
validateLimit,
validateIsoDate
};
+79
View File
@@ -0,0 +1,79 @@
/**
* 简单日志工具
* 支持日志级别和格式化输出
*/
const config = require('../config');
const LEVELS = {
debug: 0,
info: 1,
warn: 2,
error: 3
};
const COLORS = {
debug: '\x1b[36m', // cyan
info: '\x1b[32m', // green
warn: '\x1b[33m', // yellow
error: '\x1b[31m', // red
reset: '\x1b[0m'
};
const currentLevel = LEVELS[config.log.level] || LEVELS.info;
const colorize = config.log.colorize && process.stdout.isTTY;
function formatTime() {
const d = new Date();
return d.toISOString().replace('T', ' ').slice(0, 19);
}
function formatMessage(level, ...args) {
const timestamp = formatTime();
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
if (colorize) {
return `${COLORS[level]}${prefix}${COLORS.reset} ${args.join(' ')}`;
}
return `${prefix} ${args.join(' ')}`;
}
const logger = {
debug(...args) {
if (currentLevel <= LEVELS.debug) {
console.log(formatMessage('debug', ...args));
}
},
info(...args) {
if (currentLevel <= LEVELS.info) {
console.log(formatMessage('info', ...args));
}
},
warn(...args) {
if (currentLevel <= LEVELS.warn) {
console.warn(formatMessage('warn', ...args));
}
},
error(...args) {
if (currentLevel <= LEVELS.error) {
console.error(formatMessage('error', ...args));
}
},
// 请求日志中间件
requestLogger(req, res, next) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const status = res.statusCode;
const level = status >= 400 ? 'warn' : 'info';
logger[level](`${req.method} ${req.originalUrl || req.url} ${status} ${duration}ms`);
});
next();
}
};
module.exports = logger;