Compare commits
19
Commits
a0d7bfdc3d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b728ae08e4 | ||
|
|
77cd66a854 | ||
|
|
e69930799c | ||
|
|
6467048f38 | ||
|
|
ac2d21a5a0 | ||
|
|
d551841035 | ||
|
|
19e5a48f07 | ||
|
|
9126348d1b | ||
|
|
df3a9daa65 | ||
|
|
3a4048a29e | ||
|
|
f381f93507 | ||
|
|
d473e3a431 | ||
|
|
0b450a65ed | ||
|
|
14927f74aa | ||
|
|
3d8d03a66e | ||
|
|
a0466fda3b | ||
|
|
2ebac98f07 | ||
|
|
a03e92d437 | ||
|
|
4656f3f9ca |
@@ -14,7 +14,28 @@ mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
# 备份数据库
|
||||
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
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 数据库备份完成"
|
||||
else
|
||||
|
||||
+25
-1
@@ -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 = {
|
||||
apps: [{
|
||||
name: 'gamer',
|
||||
script: 'server/app.js',
|
||||
env: {
|
||||
...runtimeEnv,
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3000
|
||||
PORT: 3000,
|
||||
HOST: runtimeEnv.HOST || '0.0.0.0',
|
||||
TRUST_PROXY: runtimeEnv.TRUST_PROXY || 'loopback'
|
||||
},
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
|
||||
Generated
+87
-22
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"compression": "^1.8.1",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3"
|
||||
@@ -190,6 +191,60 @@
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
@@ -372,9 +427,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -578,9 +633,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -905,6 +960,15 @@
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -924,9 +988,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -983,12 +1047,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -1156,14 +1221,14 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
@@ -1175,13 +1240,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"compression": "^1.8.1",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3"
|
||||
|
||||
+34
-2
@@ -12,7 +12,7 @@ body {
|
||||
.page { display: none; }
|
||||
.page.active { display: flex; flex-direction: column; }
|
||||
|
||||
#loginPage {
|
||||
#loginPage, #registerPage {
|
||||
min-height: 100vh;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -68,6 +68,12 @@ body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #667eea;
|
||||
border: 1px solid #667eea;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.login-error {
|
||||
color: #e74c3c;
|
||||
text-align: center;
|
||||
@@ -76,6 +82,17 @@ body {
|
||||
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 {
|
||||
min-height: 100vh;
|
||||
@@ -159,7 +176,7 @@ body {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.weekly-chart-bars {
|
||||
display: flex;
|
||||
@@ -215,6 +232,21 @@ body {
|
||||
.daily-nav-date {
|
||||
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 {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
|
||||
+40
-3
@@ -21,10 +21,33 @@
|
||||
<input type="password" id="loginPass" placeholder="请输入密码" autocomplete="current-password">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="doLogin()">登 录</button>
|
||||
<button class="btn btn-secondary" onclick="showRegisterPage()">注册账号</button>
|
||||
<div class="login-error" id="loginError"></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 class="home-header">
|
||||
@@ -284,9 +307,16 @@
|
||||
<div class="page-header">
|
||||
<button class="back" onclick="bossDetailGoBack()">←</button>
|
||||
<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)">← 上月</button>
|
||||
<span class="daily-nav-date" id="bossDetailScope">2026年3月</span>
|
||||
<button class="daily-nav-btn" onclick="switchBossDetailScope(1)">下月 →</button>
|
||||
</div>
|
||||
<div class="daily-summary" id="bossDetailSummary"></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>
|
||||
|
||||
<!-- 陪玩交易详情页 -->
|
||||
@@ -294,9 +324,16 @@
|
||||
<div class="page-header">
|
||||
<button class="back" onclick="partnerDetailGoBack()">←</button>
|
||||
<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)">← 上月</button>
|
||||
<span class="daily-nav-date" id="partnerDetailScope">2026年3月</span>
|
||||
<button class="daily-nav-btn" onclick="switchPartnerDetailScope(1)">下月 →</button>
|
||||
</div>
|
||||
<div class="daily-summary" id="partnerDetailSummary"></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>
|
||||
|
||||
<!-- 红包及其他收入列表页 -->
|
||||
@@ -443,12 +480,12 @@
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<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/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/order-list.js"></script>
|
||||
<script src="js/order-list.js?v=20260326"></script>
|
||||
<script src="js/edit-modal.js"></script>
|
||||
<script src="js/record-form.js"></script>
|
||||
<script src="js/import-export.js"></script>
|
||||
|
||||
+110
-30
@@ -1,16 +1,100 @@
|
||||
// ============================================
|
||||
// 应用状态管理
|
||||
// ============================================
|
||||
const API = '';
|
||||
let token = localStorage.getItem('token');
|
||||
let username = localStorage.getItem('username');
|
||||
let currentType = 'income';
|
||||
let currentCategory = '接单';
|
||||
let currentYear = new Date().getFullYear();
|
||||
let currentMonth = new Date().getMonth() + 1;
|
||||
let currentNav = 'stats';
|
||||
|
||||
const AppState = {
|
||||
// 认证状态
|
||||
auth: {
|
||||
token: localStorage.getItem('token'),
|
||||
username: localStorage.getItem('username')
|
||||
},
|
||||
|
||||
// 导航状态
|
||||
nav: {
|
||||
currentType: 'income',
|
||||
currentCategory: '接单',
|
||||
currentYear: new Date().getFullYear(),
|
||||
currentMonth: new Date().getMonth() + 1,
|
||||
currentNav: 'stats'
|
||||
},
|
||||
|
||||
// 状态更新方法
|
||||
setAuth(token, username) {
|
||||
this.auth.token = token;
|
||||
this.auth.username = username;
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('username', username);
|
||||
},
|
||||
|
||||
clearAuth() {
|
||||
this.auth.token = null;
|
||||
this.auth.username = null;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('username');
|
||||
},
|
||||
|
||||
setCategory(type, category) {
|
||||
this.nav.currentType = type;
|
||||
this.nav.currentCategory = category;
|
||||
},
|
||||
|
||||
setYearMonth(year, month) {
|
||||
this.nav.currentYear = year;
|
||||
this.nav.currentMonth = month;
|
||||
},
|
||||
|
||||
setNav(nav) {
|
||||
this.nav.currentNav = nav;
|
||||
}
|
||||
};
|
||||
|
||||
// 兼容旧代码的全局变量
|
||||
let token = AppState.auth.token;
|
||||
let username = AppState.auth.username;
|
||||
let currentType = AppState.nav.currentType;
|
||||
let currentCategory = AppState.nav.currentCategory;
|
||||
let currentYear = AppState.nav.currentYear;
|
||||
let currentMonth = AppState.nav.currentMonth;
|
||||
let currentNav = AppState.nav.currentNav;
|
||||
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[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) {
|
||||
if (!isoStr) return '';
|
||||
const d = new Date(isoStr);
|
||||
@@ -30,7 +114,7 @@ function formatLocalDate(input) {
|
||||
|
||||
async function loadTotalStats() {
|
||||
try {
|
||||
const res = await fetch(API + '/api/stats/total', { headers: headers() });
|
||||
const res = await apiFetch(API + '/api/stats/total');
|
||||
if (!res.ok) return;
|
||||
const s = await res.json();
|
||||
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);
|
||||
container.innerHTML = rows.map((r, i) => {
|
||||
const pct = Math.max((r.count / max) * 100, 2);
|
||||
const onclick = clickHandler ? ` onclick="${clickHandler}('${r.name.replace(/'/g, "\\'")}', '${fromPage}')"` : '';
|
||||
const onclick = clickHandler ? ` onclick="${clickHandler}(${jsString(r.name)}, ${jsString(fromPage)})"` : '';
|
||||
const name = escapeHtml(r.name);
|
||||
return `<div class="hbar-row"${onclick}>
|
||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||
<div class="hbar-label" title="${r.name}">${r.name}</div>
|
||||
<div class="hbar-label" title="${escapeAttr(r.name)}">${name}</div>
|
||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||
<div class="hbar-value">${r.count}单</div>
|
||||
</div>`;
|
||||
@@ -61,10 +146,9 @@ function renderHorizontalChart(containerId, rows, clickHandler, fromPage) {
|
||||
async function openTakeRanking() {
|
||||
showPage('takeRankingPage');
|
||||
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;
|
||||
const data = await res.json();
|
||||
console.log('take-ranking data:', data);
|
||||
const rows = data.rows || [];
|
||||
|
||||
// 显示统计卡片
|
||||
@@ -91,7 +175,7 @@ async function openTakeRanking() {
|
||||
async function openDispatchRanking() {
|
||||
showPage('dispatchRankingPage');
|
||||
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;
|
||||
const data = await res.json();
|
||||
const rows = data.rows || [];
|
||||
@@ -128,9 +212,10 @@ async function openDispatchRanking() {
|
||||
const container = document.getElementById('dispatchRankingChart');
|
||||
container.innerHTML = rows.map((r, i) => {
|
||||
const pct = Math.max(((r.dispatch_count || 0) / max) * 100, 2);
|
||||
return `<div class="hbar-row" onclick="openPartnerDetail('${r.name.replace(/'/g, "\\'")}', 'dispatchRankingPage')">
|
||||
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-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-value">${r.dispatch_count}单</div>
|
||||
</div>`;
|
||||
@@ -176,31 +261,26 @@ function showPage(pageId) {
|
||||
}
|
||||
if (pageId === 'orderListPage') {
|
||||
document.getElementById('orderListTitle').textContent = `${currentYear}年${currentMonth}月接单列表`;
|
||||
orderPage = 1; orderHasMore = true;
|
||||
document.getElementById('orderList').innerHTML = '';
|
||||
loadOrders();
|
||||
initList('orderList');
|
||||
loadListData('orderList');
|
||||
}
|
||||
if (pageId === 'otherIncomeListPage') {
|
||||
document.getElementById('otherIncomeListTitle').textContent = `${currentYear}年${currentMonth}月红包及其他收入`;
|
||||
otherIncomePage = 1; otherIncomeHasMore = true;
|
||||
document.getElementById('otherIncomeList').innerHTML = '';
|
||||
loadOtherIncome();
|
||||
initList('otherIncomeList');
|
||||
loadListData('otherIncomeList');
|
||||
}
|
||||
if (pageId === 'dispatchListPage') {
|
||||
document.getElementById('dispatchListTitle').textContent = `${currentYear}年${currentMonth}月派单列表`;
|
||||
dispatchPage = 1; dispatchHasMore = true;
|
||||
document.getElementById('dispatchList').innerHTML = '';
|
||||
loadDispatches();
|
||||
initList('dispatchList');
|
||||
loadListData('dispatchList');
|
||||
}
|
||||
if (pageId === 'redEnvelopeListPage') {
|
||||
redEnvelopePage = 1; redEnvelopeHasMore = true;
|
||||
document.getElementById('redEnvelopeList').innerHTML = '';
|
||||
loadRedEnvelope();
|
||||
initList('redEnvelopeList');
|
||||
loadListData('redEnvelopeList');
|
||||
}
|
||||
if (pageId === 'milkTeaListPage') {
|
||||
milkTeaPage = 1; milkTeaHasMore = true;
|
||||
document.getElementById('milkTeaList').innerHTML = '';
|
||||
loadMilkTea();
|
||||
initList('milkTeaList');
|
||||
loadListData('milkTeaList');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
token = null;
|
||||
username = null;
|
||||
|
||||
@@ -4,7 +4,7 @@ const _acCache = {};
|
||||
async function acFetchNames(field) {
|
||||
if (_acCache[field] && Date.now() - _acCache[field].ts < 60000) return _acCache[field].data;
|
||||
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 [];
|
||||
const data = await res.json();
|
||||
_acCache[field] = { data, ts: Date.now() };
|
||||
@@ -45,14 +45,16 @@ function acSetup(inputId, field) {
|
||||
if (filtered.length === 0) { list.classList.remove('show'); return; }
|
||||
activeIdx = -1;
|
||||
list.innerHTML = filtered.slice(0, 10).map((n, i) => {
|
||||
let display = n;
|
||||
let display = escapeHtml(n);
|
||||
if (q) {
|
||||
const idx = n.toLowerCase().indexOf(q);
|
||||
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, '"')}">${display}</div>`;
|
||||
return `<div class="ac-item" data-idx="${i}" data-value="${escapeAttr(n)}">${display}</div>`;
|
||||
}).join('');
|
||||
list.classList.add('show');
|
||||
}
|
||||
|
||||
+24
-28
@@ -12,46 +12,46 @@ function openEditModal(record) {
|
||||
if (cat === '接单' || cat === '派单') {
|
||||
document.getElementById('editModalTitle').textContent = cat === '派单' ? '编辑派单记录' : '编辑接单记录';
|
||||
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" step="0.01" id="editPrice" value="${record.unit_price || ''}"></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="${escapeAttr(record.unit_price ?? '')}"></div>
|
||||
`;
|
||||
if (cat === '派单') {
|
||||
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 += `
|
||||
<div class="form-group"><label>老板</label><input type="text" id="editBoss" value="${record.boss || ''}"></div>
|
||||
<div class="form-group"><label>${cat === '派单' ? '负责打单的陪玩' : '一起的陪玩'}</label><input type="text" id="editPartner" value="${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="text" id="editBoss" value="${escapeAttr(record.boss || '')}"></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="${escapeAttr(localTime)}"></div>
|
||||
`;
|
||||
body.innerHTML = fields;
|
||||
} else if (cat === '红包收入' || cat === '奶茶') {
|
||||
document.getElementById('editModalTitle').textContent = '编辑' + cat + '记录';
|
||||
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="text" id="editSource" value="${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="number" step="0.01" id="editAmount" value="${escapeAttr(record.amount ?? '')}"></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="${escapeAttr(localTime)}"></div>
|
||||
`;
|
||||
} else if (cat === '红包支出' || cat === '比心' || cat === '其他支出') {
|
||||
document.getElementById('editModalTitle').textContent = '编辑' + cat + '记录';
|
||||
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 !== '比心') {
|
||||
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 {
|
||||
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 === '其他支出') {
|
||||
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;
|
||||
} else {
|
||||
document.getElementById('editModalTitle').textContent = '编辑记录';
|
||||
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="datetime-local" id="editTime" value="${localTime}"></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="${escapeAttr(localTime)}"></div>
|
||||
`;
|
||||
}
|
||||
// 绑定自动补全
|
||||
@@ -74,17 +74,14 @@ function closeEditModal() {
|
||||
function refreshCurrentList() {
|
||||
const cat = document.getElementById('editCategory').value;
|
||||
if (cat === '派单') {
|
||||
dispatchPage = 1; dispatchHasMore = true;
|
||||
document.getElementById('dispatchList').innerHTML = '';
|
||||
loadDispatches();
|
||||
initList('dispatchList');
|
||||
loadListData('dispatchList');
|
||||
} else if (cat === '接单') {
|
||||
orderPage = 1; orderHasMore = true;
|
||||
document.getElementById('orderList').innerHTML = '';
|
||||
loadOrders();
|
||||
initList('orderList');
|
||||
loadListData('orderList');
|
||||
} else {
|
||||
otherIncomePage = 1; otherIncomeHasMore = true;
|
||||
document.getElementById('otherIncomeList').innerHTML = '';
|
||||
loadOtherIncome();
|
||||
initList('otherIncomeList');
|
||||
loadListData('otherIncomeList');
|
||||
}
|
||||
// 如果在日详情页也刷新
|
||||
if (document.getElementById('dailyDetailPage').classList.contains('active') && currentDailyDate) {
|
||||
@@ -138,9 +135,8 @@ async function saveEditRecord() {
|
||||
if (time) body.created_at = new Date(time).toISOString();
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/api/records/' + id, {
|
||||
const res = await apiFetch(API + '/api/records/' + id, {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) { showToast('保存失败'); return; }
|
||||
@@ -155,7 +151,7 @@ async function deleteEditRecord() {
|
||||
const id = document.getElementById('editId').value;
|
||||
if (!confirm('确定删除这条记录?')) return;
|
||||
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; }
|
||||
showToast('已删除');
|
||||
closeEditModal();
|
||||
|
||||
@@ -11,7 +11,7 @@ async function doExport(format) {
|
||||
closeExportModal();
|
||||
showToast('正在导出...');
|
||||
try {
|
||||
const res = await fetch(API + '/api/export?format=' + format, { headers: headers() });
|
||||
const res = await apiFetch(API + '/api/export?format=' + format);
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
showToast(err.error || '导出失败');
|
||||
@@ -88,9 +88,9 @@ async function confirmImport() {
|
||||
if (!pendingImportContent) return;
|
||||
showToast('正在导入...');
|
||||
try {
|
||||
const res = await fetch(API + '/api/import', {
|
||||
const res = await apiFetch(API + '/api/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Authorization': 'Bearer ' + token },
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
body: pendingImportContent
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
+189
-281
@@ -1,300 +1,208 @@
|
||||
let orderPage = 1;
|
||||
let orderHasMore = true;
|
||||
let orderLoading = false;
|
||||
// ============================================
|
||||
// 通用列表管理 - 无限滚动加载
|
||||
// ============================================
|
||||
|
||||
async function loadOrders(append) {
|
||||
if (orderLoading || (!append && !orderHasMore)) return;
|
||||
orderLoading = true;
|
||||
document.getElementById('orderLoading').style.display = 'block';
|
||||
try {
|
||||
const res = await fetch(API + '/api/records/orders?page=' + orderPage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const container = document.getElementById('orderList');
|
||||
if (!append) container.innerHTML = '';
|
||||
if (data.records.length === 0) {
|
||||
if (orderPage === 1) container.innerHTML = '<div class="ranking-empty">暂无接单记录</div>';
|
||||
orderHasMore = 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, "'")})'>
|
||||
<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';
|
||||
const config = {
|
||||
orderList: { endpoint: '/api/records/orders', emptyMsg: '暂无接单记录', renderItem: renderOrderItem },
|
||||
otherIncomeList: { endpoint: '/api/records/other-income', emptyMsg: '暂无红包及其他收入记录', renderItem: renderOtherIncomeItem },
|
||||
dispatchList: { endpoint: '/api/records/dispatches', emptyMsg: '暂无派单记录', renderItem: renderDispatchItem },
|
||||
redEnvelopeList: { endpoint: '/api/records/red-envelope', emptyMsg: '暂无红包收入记录', renderItem: renderRedEnvelopeItem },
|
||||
milkTeaList: { endpoint: '/api/records/milk-tea', emptyMsg: '暂无奶茶收入记录', renderItem: renderMilkTeaItem }
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const listState = {};
|
||||
|
||||
function initList(listId) {
|
||||
listState[listId] = {
|
||||
loading: false,
|
||||
records: [],
|
||||
page: 1,
|
||||
hasMore: true,
|
||||
totalCount: 0
|
||||
};
|
||||
|
||||
const container = document.getElementById(listId);
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
|
||||
// 滚动加载
|
||||
window.addEventListener('scroll', () => {
|
||||
if (document.getElementById('orderListPage').classList.contains('active')) {
|
||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
async function loadListData(listId) {
|
||||
const cfg = config[listId];
|
||||
const state = listState[listId];
|
||||
if (!cfg || !state) return;
|
||||
|
||||
// 红包及其他收入列表
|
||||
let otherIncomePage = 1;
|
||||
let otherIncomeHasMore = true;
|
||||
let otherIncomeLoading = false;
|
||||
if (state.loading || !state.hasMore) return;
|
||||
|
||||
state.loading = true;
|
||||
showLoading(listId, true);
|
||||
|
||||
async function loadOtherIncome(append) {
|
||||
if (otherIncomeLoading || (!append && !otherIncomeHasMore)) return;
|
||||
otherIncomeLoading = true;
|
||||
document.getElementById('otherIncomeLoading').style.display = 'block';
|
||||
try {
|
||||
const res = await fetch(API + '/api/records/other-income?page=' + otherIncomePage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const container = document.getElementById('otherIncomeList');
|
||||
if (!append) container.innerHTML = '';
|
||||
if (data.records.length === 0) {
|
||||
if (otherIncomePage === 1) container.innerHTML = '<div class="ranking-empty">暂无红包及其他收入记录</div>';
|
||||
otherIncomeHasMore = 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);
|
||||
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
||||
<div class="order-item-top">
|
||||
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span>
|
||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="order-item-bottom">
|
||||
<span>${r.source || ''}</span>
|
||||
<span>${time}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
otherIncomeHasMore = data.records.length >= 20;
|
||||
otherIncomePage++;
|
||||
const res = await apiFetch(
|
||||
API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('[loadListData] fetch failed:', res.status);
|
||||
state.loading = false;
|
||||
showLoading(listId, false);
|
||||
return;
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
otherIncomeLoading = false;
|
||||
document.getElementById('otherIncomeLoading').style.display = 'none';
|
||||
|
||||
const data = await res.json();
|
||||
const newRecords = data.records || [];
|
||||
const total = data.total || 0;
|
||||
|
||||
state.records = state.records.concat(newRecords);
|
||||
state.totalCount = total;
|
||||
state.hasMore = state.records.length < total;
|
||||
state.page++;
|
||||
|
||||
renderList(listId);
|
||||
setupScrollObserver(listId);
|
||||
} catch (e) {
|
||||
console.error('[loadListData] error:', e);
|
||||
} finally {
|
||||
state.loading = false;
|
||||
showLoading(listId, false);
|
||||
}
|
||||
}
|
||||
|
||||
// 派单列表
|
||||
let dispatchPage = 1;
|
||||
let dispatchHasMore = true;
|
||||
let dispatchLoading = false;
|
||||
|
||||
async function loadDispatches(append) {
|
||||
if (dispatchLoading || (!append && !dispatchHasMore)) return;
|
||||
dispatchLoading = true;
|
||||
document.getElementById('dispatchLoading').style.display = 'block';
|
||||
try {
|
||||
const res = await fetch(API + '/api/records/dispatches?page=' + dispatchPage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
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, "'")})'>
|
||||
<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>';
|
||||
function showLoading(listId, show) {
|
||||
const loadingId = listId.replace('List', 'Loading');
|
||||
const loadingEl = document.getElementById(loadingId);
|
||||
if (loadingEl) {
|
||||
loadingEl.style.display = show ? 'block' : 'none';
|
||||
if (!show && listState[listId]) {
|
||||
const state = listState[listId];
|
||||
if (state.hasMore) {
|
||||
loadingEl.textContent = '下拉加载更多...';
|
||||
} else if (state.records.length > 0) {
|
||||
loadingEl.textContent = '已加载全部';
|
||||
} else {
|
||||
loadingEl.textContent = '';
|
||||
}
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
dispatchHasMore = data.records.length >= 20;
|
||||
dispatchPage++;
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
dispatchLoading = false;
|
||||
document.getElementById('dispatchLoading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 红包收入列表
|
||||
let redEnvelopePage = 1;
|
||||
let redEnvelopeHasMore = true;
|
||||
let redEnvelopeLoading = false;
|
||||
function renderList(listId) {
|
||||
const cfg = config[listId];
|
||||
const state = listState[listId];
|
||||
if (!cfg || !state) return;
|
||||
|
||||
async function loadRedEnvelope(append) {
|
||||
if (redEnvelopeLoading || (!append && !redEnvelopeHasMore)) return;
|
||||
redEnvelopeLoading = true;
|
||||
document.getElementById('redEnvelopeLoading').style.display = 'block';
|
||||
try {
|
||||
const res = await fetch(API + '/api/records/red-envelope?page=' + redEnvelopePage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const container = document.getElementById('redEnvelopeList');
|
||||
if (!append) container.innerHTML = '';
|
||||
if (data.records.length === 0) {
|
||||
if (redEnvelopePage === 1) container.innerHTML = '<div class="ranking-empty">暂无红包收入记录</div>';
|
||||
redEnvelopeHasMore = 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, "'")})'>
|
||||
<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);
|
||||
redEnvelopeHasMore = data.records.length >= 20;
|
||||
redEnvelopePage++;
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
redEnvelopeLoading = false;
|
||||
document.getElementById('redEnvelopeLoading').style.display = 'none';
|
||||
const container = document.getElementById(listId);
|
||||
if (!container) return;
|
||||
|
||||
if (state.records.length === 0) {
|
||||
container.innerHTML = '<div class="ranking-empty">' + cfg.emptyMsg + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const grouped = {};
|
||||
state.records.forEach(r => {
|
||||
const date = formatLocalDate(r.created_at);
|
||||
if (!grouped[date]) grouped[date] = [];
|
||||
grouped[date].push(r);
|
||||
});
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const [date, records] of Object.entries(grouped)) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'order-date-group';
|
||||
group.setAttribute('data-date', date);
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'order-date-label';
|
||||
label.textContent = date;
|
||||
group.appendChild(label);
|
||||
|
||||
records.forEach(r => {
|
||||
const item = document.createElement('div');
|
||||
item.innerHTML = cfg.renderItem(r);
|
||||
group.appendChild(item.firstChild);
|
||||
});
|
||||
|
||||
container.appendChild(group);
|
||||
}
|
||||
}
|
||||
|
||||
// 奶茶收入列表
|
||||
let milkTeaPage = 1;
|
||||
let milkTeaHasMore = true;
|
||||
let milkTeaLoading = false;
|
||||
function setupScrollObserver(listId) {
|
||||
const state = listState[listId];
|
||||
if (!state || !state.hasMore) return;
|
||||
|
||||
async function loadMilkTea(append) {
|
||||
if (milkTeaLoading || (!append && !milkTeaHasMore)) return;
|
||||
milkTeaLoading = true;
|
||||
document.getElementById('milkTeaLoading').style.display = 'block';
|
||||
try {
|
||||
const res = await fetch(API + '/api/records/milk-tea?page=' + milkTeaPage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const container = document.getElementById('milkTeaList');
|
||||
if (!append) container.innerHTML = '';
|
||||
if (data.records.length === 0) {
|
||||
if (milkTeaPage === 1) container.innerHTML = '<div class="ranking-empty">暂无奶茶收入记录</div>';
|
||||
milkTeaHasMore = 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, "'")})'>
|
||||
<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++;
|
||||
const container = document.getElementById(listId);
|
||||
if (!container) return;
|
||||
|
||||
// 移除旧的 sentinel
|
||||
const oldSentinel = container.querySelector('.scroll-sentinel');
|
||||
if (oldSentinel) oldSentinel.remove();
|
||||
|
||||
// 创建新的 sentinel
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.className = 'scroll-sentinel';
|
||||
sentinel.style.height = '1px';
|
||||
container.appendChild(sentinel);
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !state.loading && state.hasMore) {
|
||||
loadListData(listId);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
milkTeaLoading = false;
|
||||
document.getElementById('milkTeaLoading').style.display = 'none';
|
||||
}, { rootMargin: '100px' });
|
||||
|
||||
observer.observe(sentinel);
|
||||
|
||||
// 存储 observer 以便清理
|
||||
state.observer = observer;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 兼容接口
|
||||
// ============================================
|
||||
async function loadOrders() { return loadListData('orderList'); }
|
||||
async function loadOtherIncome() { return loadListData('otherIncomeList'); }
|
||||
async function loadDispatches() { return loadListData('dispatchList'); }
|
||||
async function loadRedEnvelope() { return loadListData('redEnvelopeList'); }
|
||||
async function loadMilkTea() { return loadListData('milkTeaList'); }
|
||||
|
||||
// ============================================
|
||||
// 渲染函数
|
||||
// ============================================
|
||||
|
||||
function renderOrderItem(r) {
|
||||
const time = formatLocalTime(r.created_at);
|
||||
return '<div class="order-item" onclick="openEditModal(JSON.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
@@ -17,7 +17,7 @@ async function loadRankingDetail() {
|
||||
url += '&endDate=' + formatLocalDate(d);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, { headers: headers() });
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) return;
|
||||
const rows = await res.json();
|
||||
const container = document.getElementById('rankingDetailList');
|
||||
@@ -29,9 +29,10 @@ async function loadRankingDetail() {
|
||||
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="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-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-value">${r.dispatch_count}单</div>
|
||||
</div>`;
|
||||
@@ -40,9 +41,10 @@ async function loadRankingDetail() {
|
||||
const max = Math.max(...rows.map(r => r.take_count), 1);
|
||||
container.innerHTML = rows.map((r, i) => {
|
||||
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-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-value">${r.take_count}单</div>
|
||||
</div>`;
|
||||
@@ -51,9 +53,10 @@ async function loadRankingDetail() {
|
||||
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, "\\'")}', '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-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-value">${r.total_income.toFixed(2)}</div>
|
||||
</div>`;
|
||||
|
||||
@@ -123,11 +123,12 @@ async function submitRecord() {
|
||||
const partner = document.getElementById('fPartner')?.value.trim() || '';
|
||||
if (currentCategory === '派单') {
|
||||
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.unit_price = unit_price;
|
||||
body.rebate = rebate;
|
||||
body.amount = quantity * rebate;
|
||||
body.partner = partner;
|
||||
} else {
|
||||
if (!quantity || !unit_price || !boss) { showToast('请填写完整信息'); return; }
|
||||
body.quantity = quantity;
|
||||
@@ -135,7 +136,6 @@ async function submitRecord() {
|
||||
body.amount = quantity * unit_price;
|
||||
}
|
||||
body.boss = boss;
|
||||
body.partner = partner;
|
||||
} else if (currentCategory === '红包收入' || currentCategory === '奶茶') {
|
||||
const amount = parseFloat(document.getElementById('fAmount').value);
|
||||
const source = document.getElementById('fSource').value.trim();
|
||||
@@ -159,9 +159,8 @@ async function submitRecord() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/api/records', {
|
||||
const res = await apiFetch(API + '/api/records', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) { showToast('保存失败'); return; }
|
||||
|
||||
+467
-167
@@ -1,45 +1,103 @@
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
const s = await res.json();
|
||||
document.getElementById('takeCount').textContent = s.take_order_count;
|
||||
document.getElementById('dispatchCount').textContent = s.dispatch_order_count;
|
||||
document.getElementById('takeIncome').textContent = '' + s.take_order_income.toFixed(2);
|
||||
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);
|
||||
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);
|
||||
loadBossRankingPreview();
|
||||
loadPartnerRankingPreview();
|
||||
loadWeeklyChart();
|
||||
// 并行加载所有数据
|
||||
const [monthlyRes, bossRankRes, partnerRankRes, weeklyRes] = await Promise.all([
|
||||
apiFetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth),
|
||||
apiFetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth),
|
||||
apiFetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth),
|
||||
apiFetch(API + '/api/stats/daily-income')
|
||||
]);
|
||||
|
||||
if (monthlyRes.ok) {
|
||||
const s = await monthlyRes.json();
|
||||
document.getElementById('takeCount').textContent = s.take_order_count;
|
||||
document.getElementById('dispatchCount').textContent = s.dispatch_order_count;
|
||||
document.getElementById('takeIncome').textContent = '' + s.take_order_income.toFixed(2);
|
||||
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);
|
||||
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); }
|
||||
}
|
||||
|
||||
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() {
|
||||
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;
|
||||
const days = await res.json();
|
||||
const container = document.getElementById('weeklyChartBars');
|
||||
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('');
|
||||
renderWeeklyChart(days);
|
||||
} 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 = '';
|
||||
|
||||
async function openDailyDetail(date) {
|
||||
@@ -52,7 +110,7 @@ async function openDailyDetail(date) {
|
||||
showPage('dailyDetailPage');
|
||||
}
|
||||
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;
|
||||
const data = await res.json();
|
||||
document.getElementById('dailySummary').innerHTML = `
|
||||
@@ -66,17 +124,12 @@ async function openDailyDetail(date) {
|
||||
}
|
||||
container.innerHTML = data.records.map(r => {
|
||||
const time = formatLocalTime(r.created_at);
|
||||
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
||||
return `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
||||
<div class="order-item-top">
|
||||
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span>
|
||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="order-item-bottom">
|
||||
<span>${r.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>`;
|
||||
return renderRecordItem(r, {
|
||||
time,
|
||||
suffix: r.boss ? ' · ' + r.boss : '',
|
||||
line: recordLineText(r, 'source'),
|
||||
partnerTime: true
|
||||
});
|
||||
}).join('');
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
@@ -115,7 +168,7 @@ async function loadYearlyNetIncome() {
|
||||
}
|
||||
|
||||
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;
|
||||
const data = await res.json();
|
||||
|
||||
@@ -185,7 +238,7 @@ async function loadMonthlyDetail(type) {
|
||||
}
|
||||
|
||||
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;
|
||||
const data = await res.json();
|
||||
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>`;
|
||||
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, "'")})'>
|
||||
<div class="order-item-top">
|
||||
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : ''}</span>
|
||||
<span class="order-item-amount">${type === 'expense' ? '-' : ''}${(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}${r.partner ? ' · ' + r.partner : ''}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
html += renderRecordItem(r, {
|
||||
time,
|
||||
suffix: r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : '',
|
||||
line: recordLineText(r, 'note'),
|
||||
amountPrefix: type === 'expense' ? '-' : '',
|
||||
partnerTime: true
|
||||
});
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
@@ -229,69 +278,185 @@ async function loadMonthlyDetail(type) {
|
||||
|
||||
async function loadBossRankingPreview() {
|
||||
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;
|
||||
const rows = await res.json();
|
||||
const container = document.getElementById('rankingPreviewList');
|
||||
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('');
|
||||
renderBossRankingPreview(rows);
|
||||
} 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 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) {
|
||||
currentBossName = boss;
|
||||
bossDetailFrom = from || 'homePage';
|
||||
bossDetailYear = currentYear;
|
||||
bossDetailMonth = currentMonth;
|
||||
// 从排行榜进入时默认显示全部
|
||||
bossDetailScopeAll = (from === 'rankingPage' || from === 'takeRankingPage');
|
||||
bossDetailPage = 1;
|
||||
bossDetailLimit = bossDetailScopeAll ? 500 : 20;
|
||||
bossDetailTotalCount = 0;
|
||||
bossDetailAllRecords = [];
|
||||
updateBossDetailNav();
|
||||
loadBossDetail();
|
||||
}
|
||||
|
||||
function bossDetailGoBack() {
|
||||
if (bossDetailScrollHandler) {
|
||||
window.removeEventListener('scroll', bossDetailScrollHandler);
|
||||
bossDetailScrollHandler = null;
|
||||
}
|
||||
showPage(bossDetailFrom);
|
||||
}
|
||||
|
||||
async function loadBossDetail() {
|
||||
document.getElementById('bossDetailTitle').textContent = currentBossName + ' 交易记录';
|
||||
document.getElementById('bossDetailSummary').innerHTML = '<div class="ranking-empty">加载中...</div>';
|
||||
document.getElementById('bossDetailList').innerHTML = '';
|
||||
showPage('bossDetailPage');
|
||||
function updateBossDetailNav() {
|
||||
const scopeEl = document.getElementById('bossDetailScope');
|
||||
const allBtn = document.getElementById('bossDetailAllBtn');
|
||||
if (bossDetailScopeAll) {
|
||||
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 {
|
||||
const res = await fetch(API + '/api/stats/boss-records?boss=' + encodeURIComponent(currentBossName), { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) { bossDetailLoading = false; return; }
|
||||
const data = await res.json();
|
||||
document.getElementById('bossDetailSummary').innerHTML = `
|
||||
<div style="display:flex;justify-content:space-around;flex-wrap:wrap;gap:8px">
|
||||
<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>
|
||||
|
||||
if (!append) {
|
||||
document.getElementById('bossDetailSummary').innerHTML = `
|
||||
<div style="display:flex;justify-content:space-around;flex-wrap:wrap;gap:8px">
|
||||
<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 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>
|
||||
`;
|
||||
`;
|
||||
bossDetailAllRecords = data.records || [];
|
||||
setupBossDetailScroll();
|
||||
} else {
|
||||
bossDetailAllRecords = bossDetailAllRecords.concat(data.records || []);
|
||||
}
|
||||
bossDetailTotalCount = data.totalCount || 0;
|
||||
|
||||
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>';
|
||||
if (moreBtn) moreBtn.style.display = 'none';
|
||||
bossDetailLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const grouped = {};
|
||||
data.records.forEach(r => {
|
||||
bossDetailAllRecords.forEach(r => {
|
||||
const d = new Date(r.created_at);
|
||||
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '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>`;
|
||||
group.records.forEach(r => {
|
||||
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
|
||||
const displayCategory = r.category === '红包收入' ? '红包' : r.category;
|
||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
||||
<div class="order-item-top">
|
||||
<span class="order-item-boss">${displayCategory}${r.partner ? ' · ' + r.partner : ''}</span>
|
||||
<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 += renderRecordItem(r, {
|
||||
time,
|
||||
suffix: r.partner ? ' · ' + r.partner : '',
|
||||
line: recordLineText(r, 'note')
|
||||
});
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
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() {
|
||||
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;
|
||||
const rows = await res.json();
|
||||
const container = document.getElementById('partnerRankingPreviewList');
|
||||
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('');
|
||||
renderPartnerRankingPreview(rows);
|
||||
} 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() {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
@@ -366,20 +556,15 @@ async function loadPartnerRankingDetail() {
|
||||
params.push('endDate=' + formatLocalDate(d));
|
||||
}
|
||||
if (params.length) url += '?' + params.join('&');
|
||||
console.log('partner-ranking url:', url);
|
||||
try {
|
||||
const res = await fetch(url, { headers: headers() });
|
||||
console.log('partner-ranking res.ok:', res.ok);
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
console.log('partner-ranking data:', data);
|
||||
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 totalDispatch = rows.reduce((sum, r) => sum + (r.dispatch_count || 0), 0);
|
||||
console.log('totalDispatch:', totalDispatch);
|
||||
|
||||
// 更新统计卡片
|
||||
document.getElementById('partnerTotalDispatchCount').textContent = totalDispatch;
|
||||
@@ -395,9 +580,10 @@ async function loadPartnerRankingDetail() {
|
||||
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, "\\'")}', '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-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-value">${r.dispatch_count}单</div>
|
||||
</div>`;
|
||||
@@ -407,54 +593,140 @@ async function loadPartnerRankingDetail() {
|
||||
|
||||
let currentPartnerName = '';
|
||||
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) {
|
||||
currentPartnerName = partner;
|
||||
partnerDetailFrom = from || 'homePage';
|
||||
partnerDetailYear = currentYear;
|
||||
partnerDetailMonth = currentMonth;
|
||||
// 从排行榜进入时默认显示全部
|
||||
partnerDetailScopeAll = (from === 'dispatchRankingPage' || from === 'partnerRankingPage');
|
||||
partnerDetailPage = 1;
|
||||
partnerDetailLimit = partnerDetailScopeAll ? 500 : 20;
|
||||
partnerDetailTotalCount = 0;
|
||||
partnerDetailAllRecords = [];
|
||||
updatePartnerDetailNav();
|
||||
loadPartnerDetail();
|
||||
}
|
||||
|
||||
function partnerDetailGoBack() {
|
||||
if (partnerDetailScrollHandler) {
|
||||
window.removeEventListener('scroll', partnerDetailScrollHandler);
|
||||
partnerDetailScrollHandler = null;
|
||||
}
|
||||
showPage(partnerDetailFrom);
|
||||
}
|
||||
|
||||
async function loadPartnerDetail() {
|
||||
document.getElementById('partnerDetailTitle').textContent = currentPartnerName + ' 交易记录';
|
||||
document.getElementById('partnerDetailSummary').innerHTML = '<div class="ranking-empty">加载中...</div>';
|
||||
document.getElementById('partnerDetailList').innerHTML = '';
|
||||
showPage('partnerDetailPage');
|
||||
function updatePartnerDetailNav() {
|
||||
const scopeEl = document.getElementById('partnerDetailScope');
|
||||
const allBtn = document.getElementById('partnerDetailAllBtn');
|
||||
if (partnerDetailScopeAll) {
|
||||
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 {
|
||||
const res = await fetch(API + '/api/stats/partner-records?partner=' + encodeURIComponent(currentPartnerName), { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) { partnerDetailLoading = false; return; }
|
||||
const data = await res.json();
|
||||
document.getElementById('partnerDetailSummary').innerHTML = `
|
||||
<div style="display:flex;justify-content:space-around;flex-wrap:wrap;gap:8px">
|
||||
<div style="text-align:center">
|
||||
<div class="daily-summary-amount" style="font-size:24px">${data.dispatch_count}</div>
|
||||
<div class="daily-summary-label">总派单数</div>
|
||||
|
||||
if (!append) {
|
||||
document.getElementById('partnerDetailSummary').innerHTML = `
|
||||
<div style="display:flex;justify-content:space-around;flex-wrap:wrap;gap:8px">
|
||||
<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 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>
|
||||
`;
|
||||
`;
|
||||
partnerDetailAllRecords = data.records || [];
|
||||
setupPartnerDetailScroll();
|
||||
} else {
|
||||
partnerDetailAllRecords = partnerDetailAllRecords.concat(data.records || []);
|
||||
}
|
||||
partnerDetailTotalCount = data.totalCount || 0;
|
||||
|
||||
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>';
|
||||
if (moreBtn) moreBtn.style.display = 'none';
|
||||
partnerDetailLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const grouped = {};
|
||||
data.records.forEach(r => {
|
||||
partnerDetailAllRecords.forEach(r => {
|
||||
const d = new Date(r.created_at);
|
||||
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '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>`;
|
||||
group.records.forEach(r => {
|
||||
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
|
||||
const displayCategory = r.category === '红包收入' ? '红包' : r.category;
|
||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
||||
<div class="order-item-top">
|
||||
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span>
|
||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="order-item-bottom">
|
||||
<span>${r.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 += renderRecordItem(r, {
|
||||
time,
|
||||
suffix: r.boss ? ' · ' + r.boss : '',
|
||||
line: recordLineText(r, 'source')
|
||||
});
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
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
@@ -1,7 +1,11 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
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 { router: authRouter, auth } = require('./routes/auth');
|
||||
const statsRouter = require('./routes/stats');
|
||||
@@ -10,11 +14,42 @@ const ioRouter = require('./routes/io');
|
||||
|
||||
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.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) => {
|
||||
const field = req.query.field;
|
||||
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', auth, ioRouter);
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`服务器运行在 http://0.0.0.0:${PORT}`);
|
||||
// 404 处理
|
||||
app.use((req, res) => {
|
||||
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}`);
|
||||
});
|
||||
|
||||
@@ -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
@@ -1,84 +1,130 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
const logger = require('./utils/logger');
|
||||
|
||||
const db = new Database(path.join(__dirname, '..', 'data.db'));
|
||||
|
||||
// 启用 WAL 模式和外键约束
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// 创建表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
/**
|
||||
* 创建表结构
|
||||
*/
|
||||
function createTables() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
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 (
|
||||
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)
|
||||
);
|
||||
`);
|
||||
CREATE TABLE IF NOT EXISTS records (
|
||||
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)
|
||||
);
|
||||
|
||||
// 迁移:去掉 category 的 CHECK 约束
|
||||
try {
|
||||
const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get();
|
||||
if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) {
|
||||
db.exec(`
|
||||
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;
|
||||
`);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_user_id ON records(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_category ON records(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_type ON records(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_boss ON records(boss);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_partner ON records(partner);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_source ON records(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_user_date ON records(user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_user_category ON records(user_id, category, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_destination ON records(destination);
|
||||
`);
|
||||
logger.debug('表结构创建完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移:去掉 category 的 CHECK 约束
|
||||
*/
|
||||
function migrateRemoveCategoryCheck() {
|
||||
try {
|
||||
const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get();
|
||||
if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) {
|
||||
logger.info('执行迁移:移除 category CHECK 约束');
|
||||
db.exec(`
|
||||
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 {
|
||||
const cols = db.prepare("PRAGMA table_info(records)").all();
|
||||
if (!cols.find(c => c.name === 'rebate')) {
|
||||
db.exec("ALTER TABLE records ADD COLUMN rebate REAL");
|
||||
/**
|
||||
* 迁移:添加 rebate 列
|
||||
*/
|
||||
function migrateAddRebateColumn() {
|
||||
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);
|
||||
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash);
|
||||
/**
|
||||
* 初始化默认用户
|
||||
*/
|
||||
function initDefaultUser() {
|
||||
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;
|
||||
|
||||
+92
-3
@@ -2,16 +2,54 @@ const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('../db');
|
||||
const config = require('../config');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
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 中间件
|
||||
function auth(req, res, next) {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) return res.status(401).json({ error: '未登录' });
|
||||
try {
|
||||
req.user = jwt.verify(token, SECRET);
|
||||
req.user = jwt.verify(token, config.jwtSecret);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: '登录已过期' });
|
||||
@@ -21,12 +59,63 @@ function auth(req, res, next) {
|
||||
// 登录
|
||||
router.post('/login', (req, res) => {
|
||||
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);
|
||||
if (!user || !bcrypt.compareSync(password, user.password)) {
|
||||
recordFailedLogin(loginKey);
|
||||
logger.warn(`登录失败: ${username}`);
|
||||
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 });
|
||||
});
|
||||
|
||||
// 注册
|
||||
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 };
|
||||
|
||||
+160
-75
@@ -1,61 +1,93 @@
|
||||
const express = require('express');
|
||||
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();
|
||||
|
||||
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) => {
|
||||
const format = req.query.format;
|
||||
if (!format || !['json', 'csv'].includes(format)) {
|
||||
return res.status(400).json({ error: '请指定格式: json 或 csv' });
|
||||
router.get('/export', (req, res, next) => {
|
||||
try {
|
||||
const format = req.query.format;
|
||||
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 {
|
||||
const raw = req.body;
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
return res.status(400).json({ error: '未收到文件内容' });
|
||||
throw new AppError('未收到文件内容', 400);
|
||||
}
|
||||
|
||||
let records = [];
|
||||
@@ -66,45 +98,79 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res)
|
||||
if (trimmed.startsWith('{')) {
|
||||
const data = JSON.parse(trimmed);
|
||||
if (!data.exportVersion || !Array.isArray(data.records)) {
|
||||
return res.status(400).json({ error: '无效的JSON导出文件格式' });
|
||||
throw new AppError('无效的JSON导出文件格式', 400);
|
||||
}
|
||||
records = data.records;
|
||||
} else {
|
||||
const lines = trimmed.split('\n').map(l => l.replace(/\r$/, ''));
|
||||
if (lines.length < 2) {
|
||||
return res.status(400).json({ error: 'CSV文件为空或格式不正确' });
|
||||
throw new AppError('CSV文件为空或格式不正确', 400);
|
||||
}
|
||||
if (lines[0] !== allFields.join(',')) {
|
||||
return res.status(400).json({ error: 'CSV列头不匹配,请使用本系统导出的文件' });
|
||||
throw new AppError('CSV列头不匹配,请使用本系统导出的文件', 400);
|
||||
}
|
||||
const csvHeaders = lines[0].split(',');
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (!lines[i].trim()) continue;
|
||||
const values = parseCSVLine(lines[i]);
|
||||
const record = {};
|
||||
csvHeaders.forEach((h, idx) => { record[h] = values[idx] || null; });
|
||||
if (record.amount) record.amount = parseFloat(record.amount);
|
||||
if (record.quantity) record.quantity = parseInt(record.quantity) || null;
|
||||
if (record.unit_price) record.unit_price = parseFloat(record.unit_price) || null;
|
||||
if (record.rebate) record.rebate = parseFloat(record.rebate) || null;
|
||||
csvHeaders.forEach((h, idx) => { record[h] = emptyToNull(values[idx]); });
|
||||
if (record.amount !== null) record.amount = parseFloat(record.amount);
|
||||
if (record.quantity !== null) record.quantity = parseInt(record.quantity, 10);
|
||||
if (record.unit_price !== null) record.unit_price = parseFloat(record.unit_price);
|
||||
if (record.rebate !== null) record.rebate = parseFloat(record.rebate);
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of records) {
|
||||
for (const f of requiredFields) {
|
||||
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}` });
|
||||
}
|
||||
if (records.length > config.import.maxRecords) {
|
||||
throw new AppError(`单次最多导入 ${config.import.maxRecords} 条记录`, 400);
|
||||
}
|
||||
|
||||
const existingCheck = db.prepare(
|
||||
'SELECT id FROM records WHERE user_id = ? AND created_at = ? AND category = ? AND amount = ?'
|
||||
);
|
||||
const allCategories = [...config.categories.income, ...config.categories.expense];
|
||||
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(`
|
||||
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -113,23 +179,42 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res)
|
||||
let imported = 0, skipped = 0;
|
||||
const insertMany = db.transaction((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;
|
||||
}
|
||||
insertStmt.run(req.user.id, r.type, r.category, r.amount,
|
||||
r.quantity || null, r.unit_price || null, r.rebate || null,
|
||||
r.boss || null, r.partner || null,
|
||||
r.source || null, r.destination || null,
|
||||
r.note || null, r.created_at);
|
||||
nullableDbValue(r.quantity), nullableDbValue(r.unit_price), nullableDbValue(r.rebate),
|
||||
nullableDbValue(r.boss), nullableDbValue(r.partner),
|
||||
nullableDbValue(r.source), nullableDbValue(r.destination),
|
||||
nullableDbValue(r.note), r.created_at);
|
||||
imported++;
|
||||
}
|
||||
});
|
||||
insertMany(records);
|
||||
insertMany(normalizedRecords);
|
||||
|
||||
res.json({ imported, skipped, total: records.length });
|
||||
} catch (e) {
|
||||
console.error('Import error:', e);
|
||||
res.status(400).json({ error: '文件解析失败: ' + e.message });
|
||||
logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped} 条`);
|
||||
res.json({ imported, skipped, total: normalizedRecords.length });
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
next(new AppError('文件解析失败: ' + err.message, 400));
|
||||
} else {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+190
-86
@@ -1,109 +1,162 @@
|
||||
const express = require('express');
|
||||
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();
|
||||
|
||||
// 新增记录
|
||||
router.post('/', (req, res) => {
|
||||
const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body;
|
||||
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, amount, quantity || null, unit_price || null, rebate || null, boss || null, partner || null, source || null, destination || null, note || null, created_at || new Date().toISOString());
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
router.post('/', (req, res, next) => {
|
||||
try {
|
||||
const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body;
|
||||
|
||||
// 输入验证
|
||||
validateRequired(req.body, ['type', 'category', 'amount']);
|
||||
validateInList(type, 'type', ['income', 'expense']);
|
||||
|
||||
const allCategories = [...config.categories.income, ...config.categories.expense];
|
||||
validateInList(category, 'category', allCategories);
|
||||
|
||||
// 派单的返点可为负数,因此金额也允许为负数
|
||||
const validatedAmount = category === '派单'
|
||||
? validateNumber(amount, '金额')
|
||||
: validateNumber(amount, '金额', 0);
|
||||
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
|
||||
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
|
||||
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
|
||||
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
|
||||
|
||||
const stmt = db.prepare(`
|
||||
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) => {
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const offset = (Number(page) - 1) * Number(limit);
|
||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||
|
||||
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') < ?`
|
||||
).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) });
|
||||
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
|
||||
category: '接单',
|
||||
page: req.query.page,
|
||||
limit: req.query.limit
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// 红包及其他收入列表
|
||||
router.get('/other-income', (req, res) => {
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const offset = (Number(page) - 1) * Number(limit);
|
||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||
|
||||
const total = db.prepare(
|
||||
`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;
|
||||
|
||||
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) });
|
||||
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
|
||||
type: 'income',
|
||||
excludeCategories: ['接单', '派单'],
|
||||
page: req.query.page,
|
||||
limit: req.query.limit
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// 红包收入列表
|
||||
router.get('/red-envelope', (req, res) => {
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const offset = (Number(page) - 1) * Number(limit);
|
||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||
|
||||
const total = db.prepare(
|
||||
`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;
|
||||
|
||||
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) });
|
||||
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
|
||||
type: 'income',
|
||||
category: '红包收入',
|
||||
page: req.query.page,
|
||||
limit: req.query.limit
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// 奶茶收入列表
|
||||
router.get('/milk-tea', (req, res) => {
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const offset = (Number(page) - 1) * Number(limit);
|
||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||
|
||||
const total = db.prepare(
|
||||
`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;
|
||||
|
||||
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) });
|
||||
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
|
||||
type: 'income',
|
||||
category: '奶茶',
|
||||
page: req.query.page,
|
||||
limit: req.query.limit
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// 派单列表
|
||||
router.get('/dispatches', (req, res) => {
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const offset = (Number(page) - 1) * Number(limit);
|
||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||
|
||||
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') < ?`
|
||||
).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) });
|
||||
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
|
||||
category: '派单',
|
||||
page: req.query.page,
|
||||
limit: req.query.limit
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// 获取记录列表
|
||||
router.get('/', (req, res) => {
|
||||
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 = ?';
|
||||
const params = [req.user.id];
|
||||
if (type) {
|
||||
@@ -111,24 +164,75 @@ router.get('/', (req, res) => {
|
||||
params.push(type);
|
||||
}
|
||||
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));
|
||||
});
|
||||
|
||||
// 更新记录
|
||||
router.put('/:id', (req, res) => {
|
||||
const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body;
|
||||
db.prepare(`
|
||||
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ?
|
||||
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);
|
||||
res.json({ success: true });
|
||||
router.put('/:id', (req, res, next) => {
|
||||
try {
|
||||
const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body;
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!id || id <= 0) {
|
||||
throw new AppError('无效的记录ID', 400);
|
||||
}
|
||||
|
||||
// 获取原有记录以判断类别(派单允许负数金额)
|
||||
const existing = db.prepare('SELECT category FROM records WHERE id = ? AND user_id = ?').get(id, req.user.id);
|
||||
if (!existing) {
|
||||
throw new AppError('记录不存在', 404);
|
||||
}
|
||||
|
||||
const validatedAmount = existing.category === '派单'
|
||||
? validateNumber(amount, '金额')
|
||||
: validateNumber(amount, '金额', 0);
|
||||
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
|
||||
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
|
||||
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
|
||||
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
|
||||
|
||||
db.prepare(`
|
||||
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) => {
|
||||
db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
|
||||
res.json({ success: true });
|
||||
router.delete('/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!id || id <= 0) {
|
||||
throw new AppError('无效的记录ID', 400);
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?').run(id, req.user.id);
|
||||
|
||||
logger.info(`用户 ${req.user.username} 删除记录: ${id}`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+267
-193
@@ -1,9 +1,14 @@
|
||||
const express = require('express');
|
||||
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();
|
||||
|
||||
// 缓存模块已移至 utils/cache.js
|
||||
|
||||
// 格式化本地日期为 YYYY-MM-DD
|
||||
function formatLocalDate(date) {
|
||||
const y = date.getFullYear();
|
||||
@@ -13,61 +18,65 @@ function formatLocalDate(date) {
|
||||
}
|
||||
|
||||
// 本月统计
|
||||
router.get('/monthly', (req, res) => {
|
||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
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 take_order_income,
|
||||
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 dispatch_order_income,
|
||||
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income,
|
||||
COALESCE(SUM(CASE WHEN category = '红包收入' THEN 1 ELSE 0 END), 0) as redEnvelope_count,
|
||||
COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as milkTea_income,
|
||||
COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count,
|
||||
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 = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||
`).get(req.user.id, startDate, endDate);
|
||||
res.json(stats);
|
||||
router.get('/monthly', (req, res, next) => {
|
||||
try {
|
||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||
const cacheKey = `monthly:${req.user.id}:${startDate}:${endDate}`;
|
||||
getCached(cacheKey, () => {
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
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 take_order_income,
|
||||
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 dispatch_order_income,
|
||||
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income,
|
||||
COALESCE(SUM(CASE WHEN category = '红包收入' THEN 1 ELSE 0 END), 0) as redEnvelope_count,
|
||||
COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as milkTea_income,
|
||||
COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count,
|
||||
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 = ? 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) => {
|
||||
const { type } = req.query;
|
||||
if (!type || !['income', 'expense'].includes(type)) return res.status(400).json({ error: '缺少类型参数' });
|
||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||
router.get('/monthly-records', (req, res, next) => {
|
||||
try {
|
||||
const { type, page = 1, limit = 20 } = req.query;
|
||||
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(`
|
||||
SELECT COALESCE(SUM(amount), 0) as total
|
||||
FROM records WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||
`).get(req.user.id, type, startDate, endDate);
|
||||
const stats = db.prepare(`
|
||||
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') < ?
|
||||
`).get(req.user.id, type, startDate, endDate);
|
||||
|
||||
const records = db.prepare(`
|
||||
SELECT * FROM records
|
||||
WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||
ORDER BY created_at DESC
|
||||
`).all(req.user.id, type, startDate, endDate);
|
||||
const records = db.prepare(`
|
||||
SELECT * FROM records
|
||||
WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||
`).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) => {
|
||||
// 获取所有年份数据
|
||||
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(`
|
||||
const combined = db.prepare(`
|
||||
SELECT
|
||||
strftime('%Y', created_at) as year,
|
||||
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
|
||||
FROM records
|
||||
WHERE user_id = ?
|
||||
GROUP BY year, month ORDER BY year DESC, month
|
||||
`).all(req.user.id);
|
||||
|
||||
// 按年份分组每月数据
|
||||
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(`
|
||||
GROUP BY year, month
|
||||
UNION ALL
|
||||
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 = 'expense' THEN amount ELSE 0 END), 0) as total_expense
|
||||
FROM records WHERE user_id = ?
|
||||
`).get(req.user.id);
|
||||
FROM records
|
||||
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 });
|
||||
});
|
||||
|
||||
// 陪玩派单榜预览
|
||||
router.get('/partner-ranking/preview', (req, res) => {
|
||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||
const rows = db.prepare(`
|
||||
SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income
|
||||
FROM records
|
||||
WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != ''
|
||||
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||
GROUP BY partner ORDER BY dispatch_count DESC LIMIT 10
|
||||
`).all(req.user.id, startDate, endDate);
|
||||
res.json(rows);
|
||||
const cacheKey = `partner-ranking-preview:${req.user.id}:${startDate}:${endDate}`;
|
||||
getCached(cacheKey, () => {
|
||||
const rows = db.prepare(`
|
||||
SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income
|
||||
FROM records
|
||||
WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != ''
|
||||
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||
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 => {
|
||||
row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0);
|
||||
});
|
||||
const summary = db.prepare(`
|
||||
const summaryStats = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(quantity), 0) as total_dispatch_count,
|
||||
COALESCE(SUM(quantity * COALESCE(unit_price, 0)), 0) as total_dispatch_income,
|
||||
COALESCE(SUM(amount), 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 quantity ELSE 0 END), 0) as total_dispatch_count,
|
||||
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as total_dispatch_income,
|
||||
COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as total_rebate_income,
|
||||
COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as total_milkTea_income
|
||||
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') < ?
|
||||
`).get(req.user.id, start, end);
|
||||
// 获取奶茶收入
|
||||
const milkTeaStats = db.prepare(`
|
||||
SELECT COALESCE(SUM(amount), 0) as total_milkTea_income
|
||||
FROM records
|
||||
WHERE user_id = ? AND type = 'income' AND category = '奶茶'
|
||||
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||
`).get(req.user.id, start, end);
|
||||
summary.total_milkTea_income = milkTeaStats.total_milkTea_income;
|
||||
const summary = {
|
||||
total_dispatch_count: summaryStats.total_dispatch_count,
|
||||
total_dispatch_income: summaryStats.total_dispatch_income,
|
||||
total_rebate_income: summaryStats.total_rebate_income,
|
||||
total_partner_income: summaryStats.total_dispatch_income - summaryStats.total_rebate_income,
|
||||
total_milkTea_income: summaryStats.total_milkTea_income
|
||||
};
|
||||
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 != ''
|
||||
GROUP BY boss ORDER BY count DESC
|
||||
`).all(req.user.id);
|
||||
// 获取接单总收入和红包收入
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(quantity), 0) as total_take_count,
|
||||
COALESCE(SUM(amount), 0) as total_take_income
|
||||
FROM records WHERE user_id = ? AND category = '接单'
|
||||
`).get(req.user.id);
|
||||
const redEnvelopeStats = db.prepare(`
|
||||
SELECT COALESCE(SUM(amount), 0) as total_redEnvelope_income
|
||||
FROM records WHERE user_id = ? AND type = 'income' AND category = '红包收入'
|
||||
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as total_take_count,
|
||||
COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as total_take_income,
|
||||
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as total_redEnvelope_income
|
||||
FROM records WHERE user_id = ? AND type = 'income' AND category IN ('接单', '红包收入')
|
||||
`).get(req.user.id);
|
||||
const result = {
|
||||
rows: rows,
|
||||
total_take_count: stats.total_take_count,
|
||||
total_take_income: stats.total_take_income,
|
||||
total_redEnvelope_income: redEnvelopeStats.total_redEnvelope_income
|
||||
total_redEnvelope_income: stats.total_redEnvelope_income
|
||||
};
|
||||
res.json(result);
|
||||
});
|
||||
@@ -202,19 +225,16 @@ router.get('/dispatch-ranking', (req, res) => {
|
||||
rows.forEach(row => {
|
||||
row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0);
|
||||
});
|
||||
// 获取派单总数和奶茶收入
|
||||
const dispatchTotal = db.prepare(`
|
||||
SELECT COALESCE(SUM(quantity), 0) as total_dispatch_count
|
||||
FROM records WHERE user_id = ? AND category = '派单'
|
||||
`).get(req.user.id);
|
||||
const milkTeaStats = db.prepare(`
|
||||
SELECT COALESCE(SUM(amount), 0) as total_milkTea_income
|
||||
FROM records WHERE user_id = ? AND type = 'income' AND category = '奶茶'
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as total_dispatch_count,
|
||||
COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as total_milkTea_income
|
||||
FROM records WHERE user_id = ? AND type = 'income' AND category IN ('派单', '奶茶')
|
||||
`).get(req.user.id);
|
||||
const result = {
|
||||
rows: rows,
|
||||
total_dispatch_count: dispatchTotal.total_dispatch_count,
|
||||
total_milkTea_income: milkTeaStats.total_milkTea_income
|
||||
total_dispatch_count: stats.total_dispatch_count,
|
||||
total_milkTea_income: stats.total_milkTea_income
|
||||
};
|
||||
res.json(result);
|
||||
});
|
||||
@@ -234,72 +254,112 @@ router.get('/total', (req, res) => {
|
||||
});
|
||||
|
||||
// 老板交易记录
|
||||
router.get('/boss-records', (req, res) => {
|
||||
const boss = req.query.boss;
|
||||
if (!boss) return res.status(400).json({ error: '缺少老板参数' });
|
||||
router.get('/boss-records', (req, res, next) => {
|
||||
try {
|
||||
const boss = req.query.boss;
|
||||
const { page = 1, limit = 20, year, month } = req.query;
|
||||
if (!boss) throw new AppError('缺少老板参数', 400);
|
||||
|
||||
// 我的收入:只统计 type='income' 的记录(接单、派单返点、红包收入等)
|
||||
// 使用 COALESCE(boss, source) 兼容旧数据(之前的红包收入只有 source 字段)
|
||||
const myIncomeSummary = db.prepare(`
|
||||
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 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 expenseSummary = db.prepare(`
|
||||
SELECT
|
||||
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
|
||||
FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?
|
||||
`).get(req.user.id, boss);
|
||||
// 日期过滤条件
|
||||
let dateFilter = '';
|
||||
let dateParams = [];
|
||||
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 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(`
|
||||
SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ? ORDER BY created_at DESC
|
||||
`).all(req.user.id, boss);
|
||||
const records = db.prepare(`
|
||||
SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||
`).all(req.user.id, boss, ...dateParams, validatedLimit, offset);
|
||||
|
||||
res.json({
|
||||
my_income: myIncomeSummary.total,
|
||||
boss_total_expense: boss_total_expense,
|
||||
records
|
||||
});
|
||||
res.json({
|
||||
my_income: summary.my_income,
|
||||
boss_total_expense: summary.dispatch_expense + summary.order_expense + summary.redEnvelope_expense,
|
||||
take_count: summary.take_count,
|
||||
dispatch_count: summary.dispatch_count,
|
||||
records,
|
||||
page: validatedPage,
|
||||
limit: validatedLimit,
|
||||
totalCount: summary.count
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// 陪玩交易记录
|
||||
router.get('/partner-records', (req, res) => {
|
||||
const partner = req.query.partner;
|
||||
if (!partner) return res.status(400).json({ error: '缺少陪玩参数' });
|
||||
router.get('/partner-records', (req, res, next) => {
|
||||
try {
|
||||
const partner = req.query.partner;
|
||||
const { page = 1, limit = 20, year, month } = req.query;
|
||||
if (!partner) throw new AppError('缺少陪玩参数', 400);
|
||||
|
||||
const dispatchStats = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(quantity), 0) as dispatch_count,
|
||||
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 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 otherIncome = db.prepare(`
|
||||
SELECT COALESCE(SUM(amount), 0) as total
|
||||
FROM records WHERE user_id = ? AND type = 'income' AND category != '派单' AND category != '接单' AND source = ?
|
||||
`).get(req.user.id, partner);
|
||||
// 日期过滤条件
|
||||
let dateFilter = '';
|
||||
let dateParams = [];
|
||||
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(`
|
||||
SELECT * FROM records
|
||||
WHERE user_id = ? AND (
|
||||
(category = '派单' AND partner = ?) OR
|
||||
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
|
||||
) ORDER BY created_at DESC
|
||||
`).all(req.user.id, partner, partner);
|
||||
const summary = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
|
||||
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * (COALESCE(unit_price, 0) - COALESCE(rebate, amount * 1.0 / CASE WHEN quantity > 0 THEN quantity ELSE 1 END)) ELSE 0 END), 0) as spread_income,
|
||||
COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as rebate_income,
|
||||
COALESCE(SUM(CASE WHEN type = 'income' AND category != '派单' AND category != '接单' AND source = ? THEN amount ELSE 0 END), 0) as other_income,
|
||||
COUNT(*) as count
|
||||
FROM records WHERE user_id = ? AND (
|
||||
(category = '派单' AND partner = ?) OR
|
||||
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
|
||||
)${dateFilter}
|
||||
`).get(partner, req.user.id, partner, partner, ...dateParams);
|
||||
|
||||
res.json({
|
||||
dispatch_count: dispatchStats.dispatch_count,
|
||||
spread_income: dispatchStats.spread_income,
|
||||
rebate_income: dispatchStats.rebate_income,
|
||||
other_income: otherIncome.total,
|
||||
records
|
||||
});
|
||||
const records = db.prepare(`
|
||||
SELECT * FROM records
|
||||
WHERE user_id = ? AND (
|
||||
(category = '派单' AND partner = ?) OR
|
||||
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
|
||||
)${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||
`).all(req.user.id, partner, partner, ...dateParams, validatedLimit, offset);
|
||||
|
||||
res.json({
|
||||
dispatch_count: summary.dispatch_count,
|
||||
spread_income: summary.spread_income,
|
||||
rebate_income: summary.rebate_income,
|
||||
other_income: summary.other_income,
|
||||
records,
|
||||
page: validatedPage,
|
||||
limit: validatedLimit,
|
||||
totalCount: summary.count
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// 最近7天每日收入
|
||||
@@ -316,52 +376,66 @@ router.get('/daily-income', (req, res) => {
|
||||
endDay.setDate(endDay.getDate() + 1);
|
||||
const endDate = formatLocalDate(endDay);
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income
|
||||
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||
GROUP BY DATE(created_at, 'localtime')
|
||||
`).all(req.user.id, startDate, endDate);
|
||||
|
||||
const map = {};
|
||||
rows.forEach(r => { map[r.date] = r.income; });
|
||||
res.json(days.map(d => ({ date: d, income: map[d] || 0 })));
|
||||
const cacheKey = `daily-income:${req.user.id}:${startDate}`;
|
||||
getCached(cacheKey, () => {
|
||||
const rows = db.prepare(`
|
||||
SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income
|
||||
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||
GROUP BY DATE(created_at, 'localtime')
|
||||
`).all(req.user.id, startDate, endDate);
|
||||
const map = {};
|
||||
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) => {
|
||||
const { date } = req.query;
|
||||
if (!date) return res.status(400).json({ error: '缺少日期参数' });
|
||||
const nextDay = new Date(date);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
const endDate = formatLocalDate(nextDay);
|
||||
router.get('/daily-detail', (req, res, next) => {
|
||||
try {
|
||||
const { date, page = 1, limit = 20 } = req.query;
|
||||
if (!date) throw new AppError('缺少日期参数', 400);
|
||||
|
||||
const summary = db.prepare(`
|
||||
SELECT COALESCE(SUM(amount), 0) as total_income
|
||||
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 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 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
|
||||
`).all(req.user.id, date, endDate);
|
||||
const nextDay = new Date(date);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
const endDate = formatLocalDate(nextDay);
|
||||
|
||||
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) => {
|
||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||
const rows = db.prepare(`
|
||||
SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income,
|
||||
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
|
||||
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
|
||||
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income
|
||||
FROM records
|
||||
WHERE user_id = ? AND type = 'income' AND (boss IS NOT NULL AND boss != '' OR source IS NOT NULL AND source != '')
|
||||
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||
GROUP BY COALESCE(boss, source) ORDER BY total_income DESC LIMIT 10
|
||||
`).all(req.user.id, startDate, endDate);
|
||||
res.json(rows);
|
||||
const cacheKey = `boss-ranking-preview:${req.user.id}:${startDate}:${endDate}`;
|
||||
getCached(cacheKey, () => {
|
||||
const rows = db.prepare(`
|
||||
SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income,
|
||||
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
|
||||
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
|
||||
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income
|
||||
FROM records
|
||||
WHERE user_id = ? AND type = 'income' AND (boss IS NOT NULL AND boss != '' OR source IS NOT NULL AND source != '')
|
||||
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||
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));
|
||||
});
|
||||
|
||||
// 老板收入排行 - 详情页(支持筛选)
|
||||
|
||||
@@ -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
@@ -1,4 +1,17 @@
|
||||
// 日期范围辅助函数
|
||||
/**
|
||||
* 应用错误类
|
||||
*/
|
||||
class AppError extends Error {
|
||||
constructor(message, status = 400) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.expose = true; // 允许向客户端暴露错误信息
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期范围辅助函数
|
||||
*/
|
||||
function getMonthRange(qYear, qMonth) {
|
||||
const now = new Date();
|
||||
const y = Number(qYear) || now.getFullYear();
|
||||
@@ -10,7 +23,9 @@ function getMonthRange(qYear, qMonth) {
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
// CSV行解析(处理引号内的逗号)
|
||||
/**
|
||||
* CSV行解析(处理引号内的逗号)
|
||||
*/
|
||||
function parseCSVLine(line) {
|
||||
const result = [];
|
||||
let current = '';
|
||||
@@ -31,4 +46,80 @@ function parseCSVLine(line) {
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user