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')] 开始备份数据库..."
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始备份数据库..."
|
||||||
cp "${SOURCE_DIR}/data.db" "${BACKUP_DIR}/data.db"
|
export SOURCE_DIR BACKUP_DIR
|
||||||
|
node <<'NODE'
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const sourceDir = process.env.SOURCE_DIR;
|
||||||
|
const backupDir = process.env.BACKUP_DIR;
|
||||||
|
const Database = require(path.join(sourceDir, 'node_modules', 'better-sqlite3'));
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const db = new Database(path.join(sourceDir, 'data.db'), { readonly: true });
|
||||||
|
try {
|
||||||
|
await db.backup(path.join(backupDir, 'data.db'));
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
NODE
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 数据库备份完成"
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 数据库备份完成"
|
||||||
else
|
else
|
||||||
|
|||||||
+25
-1
@@ -1,10 +1,34 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function loadEnvFile(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return {};
|
||||||
|
return fs.readFileSync(filePath, 'utf8')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.reduce((env, line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) return env;
|
||||||
|
const idx = trimmed.indexOf('=');
|
||||||
|
if (idx === -1) return env;
|
||||||
|
const key = trimmed.slice(0, idx).trim();
|
||||||
|
const value = trimmed.slice(idx + 1).trim();
|
||||||
|
env[key] = value.replace(/^['"]|['"]$/g, '');
|
||||||
|
return env;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeEnv = loadEnvFile(path.join(__dirname, '.env'));
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [{
|
apps: [{
|
||||||
name: 'gamer',
|
name: 'gamer',
|
||||||
script: 'server/app.js',
|
script: 'server/app.js',
|
||||||
env: {
|
env: {
|
||||||
|
...runtimeEnv,
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
PORT: 3000
|
PORT: 3000,
|
||||||
|
HOST: runtimeEnv.HOST || '0.0.0.0',
|
||||||
|
TRUST_PROXY: runtimeEnv.TRUST_PROXY || 'loopback'
|
||||||
},
|
},
|
||||||
instances: 1,
|
instances: 1,
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
|
|||||||
Generated
+87
-22
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"compression": "^1.8.1",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3"
|
"jsonwebtoken": "^9.0.3"
|
||||||
@@ -190,6 +191,60 @@
|
|||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/compressible": {
|
||||||
|
"version": "2.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||||
|
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": ">= 1.43.0 < 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "3.1.2",
|
||||||
|
"compressible": "~2.0.18",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"negotiator": "~0.6.4",
|
||||||
|
"on-headers": "~1.1.0",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/negotiator": {
|
||||||
|
"version": "0.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||||
|
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||||
@@ -372,9 +427,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -578,9 +633,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -905,6 +960,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-headers": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -924,9 +988,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.3.0",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -983,12 +1047,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"es-define-property": "^1.0.1",
|
||||||
|
"side-channel": "^1.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
@@ -1156,14 +1221,14 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3",
|
"object-inspect": "^1.13.4",
|
||||||
"side-channel-list": "^1.0.0",
|
"side-channel-list": "^1.0.1",
|
||||||
"side-channel-map": "^1.0.1",
|
"side-channel-map": "^1.0.1",
|
||||||
"side-channel-weakmap": "^1.0.2"
|
"side-channel-weakmap": "^1.0.2"
|
||||||
},
|
},
|
||||||
@@ -1175,13 +1240,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel-list": {
|
"node_modules/side-channel-list": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3"
|
"object-inspect": "^1.13.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"compression": "^1.8.1",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3"
|
"jsonwebtoken": "^9.0.3"
|
||||||
|
|||||||
+34
-2
@@ -12,7 +12,7 @@ body {
|
|||||||
.page { display: none; }
|
.page { display: none; }
|
||||||
.page.active { display: flex; flex-direction: column; }
|
.page.active { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
#loginPage {
|
#loginPage, #registerPage {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -68,6 +68,12 @@ body {
|
|||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #667eea;
|
||||||
|
border: 1px solid #667eea;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
.login-error {
|
.login-error {
|
||||||
color: #e74c3c;
|
color: #e74c3c;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -76,6 +82,17 @@ body {
|
|||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-register-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-register-link a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 首页 */
|
/* 首页 */
|
||||||
#homePage {
|
#homePage {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -159,7 +176,7 @@ body {
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: #333;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.weekly-chart-bars {
|
.weekly-chart-bars {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -215,6 +232,21 @@ body {
|
|||||||
.daily-nav-date {
|
.daily-nav-date {
|
||||||
font-size: 15px; font-weight: 600; color: #333;
|
font-size: 15px; font-weight: 600; color: #333;
|
||||||
}
|
}
|
||||||
|
.scope-toggle {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #667eea;
|
||||||
|
background: #fff;
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.scope-toggle.active {
|
||||||
|
background: #667eea;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.daily-summary {
|
.daily-summary {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
+40
-3
@@ -21,10 +21,33 @@
|
|||||||
<input type="password" id="loginPass" placeholder="请输入密码" autocomplete="current-password">
|
<input type="password" id="loginPass" placeholder="请输入密码" autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" onclick="doLogin()">登 录</button>
|
<button class="btn btn-primary" onclick="doLogin()">登 录</button>
|
||||||
|
<button class="btn btn-secondary" onclick="showRegisterPage()">注册账号</button>
|
||||||
<div class="login-error" id="loginError"></div>
|
<div class="login-error" id="loginError"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 注册页 -->
|
||||||
|
<div id="registerPage" class="page">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>注册账号</h1>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input type="text" id="registerUser" placeholder="请输入用户名" autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" id="registerPass" placeholder="请输入密码" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>确认密码</label>
|
||||||
|
<input type="password" id="registerPassConfirm" placeholder="请再次输入密码" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="doRegister()">注 册</button>
|
||||||
|
<button class="btn btn-secondary" onclick="showLoginPage()">返回登录</button>
|
||||||
|
<div class="login-error" id="registerError"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 首页 -->
|
<!-- 首页 -->
|
||||||
<div id="homePage" class="page">
|
<div id="homePage" class="page">
|
||||||
<div class="home-header">
|
<div class="home-header">
|
||||||
@@ -284,9 +307,16 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back" onclick="bossDetailGoBack()">←</button>
|
<button class="back" onclick="bossDetailGoBack()">←</button>
|
||||||
<h2 id="bossDetailTitle">交易记录</h2>
|
<h2 id="bossDetailTitle">交易记录</h2>
|
||||||
|
<button class="scope-toggle" id="bossDetailAllBtn" onclick="toggleBossDetailAll()">全部</button>
|
||||||
|
</div>
|
||||||
|
<div class="daily-nav">
|
||||||
|
<button class="daily-nav-btn" onclick="switchBossDetailScope(-1)">← 上月</button>
|
||||||
|
<span class="daily-nav-date" id="bossDetailScope">2026年3月</span>
|
||||||
|
<button class="daily-nav-btn" onclick="switchBossDetailScope(1)">下月 →</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="daily-summary" id="bossDetailSummary"></div>
|
<div class="daily-summary" id="bossDetailSummary"></div>
|
||||||
<div id="bossDetailList" class="order-list"></div>
|
<div id="bossDetailList" class="order-list"></div>
|
||||||
|
<div id="bossDetailMore" class="order-loading" style="display:none;text-align:center;padding:12px;color:#667eea">加载更多</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 陪玩交易详情页 -->
|
<!-- 陪玩交易详情页 -->
|
||||||
@@ -294,9 +324,16 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back" onclick="partnerDetailGoBack()">←</button>
|
<button class="back" onclick="partnerDetailGoBack()">←</button>
|
||||||
<h2 id="partnerDetailTitle">交易记录</h2>
|
<h2 id="partnerDetailTitle">交易记录</h2>
|
||||||
|
<button class="scope-toggle" id="partnerDetailAllBtn" onclick="togglePartnerDetailAll()">全部</button>
|
||||||
|
</div>
|
||||||
|
<div class="daily-nav">
|
||||||
|
<button class="daily-nav-btn" onclick="switchPartnerDetailScope(-1)">← 上月</button>
|
||||||
|
<span class="daily-nav-date" id="partnerDetailScope">2026年3月</span>
|
||||||
|
<button class="daily-nav-btn" onclick="switchPartnerDetailScope(1)">下月 →</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="daily-summary" id="partnerDetailSummary"></div>
|
<div class="daily-summary" id="partnerDetailSummary"></div>
|
||||||
<div id="partnerDetailList" class="order-list"></div>
|
<div id="partnerDetailList" class="order-list"></div>
|
||||||
|
<div id="partnerDetailMore" class="order-loading" style="display:none;text-align:center;padding:12px;color:#667eea">加载更多</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 红包及其他收入列表页 -->
|
<!-- 红包及其他收入列表页 -->
|
||||||
@@ -443,12 +480,12 @@
|
|||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script src="js/app.js?v=20260224"></script>
|
<script src="js/app.js?v=20260224"></script>
|
||||||
<script src="js/auth.js"></script>
|
<script src="js/auth.js?v=20260428b"></script>
|
||||||
<script src="js/autocomplete.js"></script>
|
<script src="js/autocomplete.js"></script>
|
||||||
<script src="js/month-picker.js"></script>
|
<script src="js/month-picker.js"></script>
|
||||||
<script src="js/stats.js?v=2026022401"></script>
|
<script src="js/stats.js?v=20260326"></script>
|
||||||
<script src="js/ranking.js"></script>
|
<script src="js/ranking.js"></script>
|
||||||
<script src="js/order-list.js"></script>
|
<script src="js/order-list.js?v=20260326"></script>
|
||||||
<script src="js/edit-modal.js"></script>
|
<script src="js/edit-modal.js"></script>
|
||||||
<script src="js/record-form.js"></script>
|
<script src="js/record-form.js"></script>
|
||||||
<script src="js/import-export.js"></script>
|
<script src="js/import-export.js"></script>
|
||||||
|
|||||||
+110
-30
@@ -1,16 +1,100 @@
|
|||||||
|
// ============================================
|
||||||
|
// 应用状态管理
|
||||||
|
// ============================================
|
||||||
const API = '';
|
const API = '';
|
||||||
let token = localStorage.getItem('token');
|
|
||||||
let username = localStorage.getItem('username');
|
const AppState = {
|
||||||
let currentType = 'income';
|
// 认证状态
|
||||||
let currentCategory = '接单';
|
auth: {
|
||||||
let currentYear = new Date().getFullYear();
|
token: localStorage.getItem('token'),
|
||||||
let currentMonth = new Date().getMonth() + 1;
|
username: localStorage.getItem('username')
|
||||||
let currentNav = 'stats';
|
},
|
||||||
|
|
||||||
|
// 导航状态
|
||||||
|
nav: {
|
||||||
|
currentType: 'income',
|
||||||
|
currentCategory: '接单',
|
||||||
|
currentYear: new Date().getFullYear(),
|
||||||
|
currentMonth: new Date().getMonth() + 1,
|
||||||
|
currentNav: 'stats'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 状态更新方法
|
||||||
|
setAuth(token, username) {
|
||||||
|
this.auth.token = token;
|
||||||
|
this.auth.username = username;
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
localStorage.setItem('username', username);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAuth() {
|
||||||
|
this.auth.token = null;
|
||||||
|
this.auth.username = null;
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('username');
|
||||||
|
},
|
||||||
|
|
||||||
|
setCategory(type, category) {
|
||||||
|
this.nav.currentType = type;
|
||||||
|
this.nav.currentCategory = category;
|
||||||
|
},
|
||||||
|
|
||||||
|
setYearMonth(year, month) {
|
||||||
|
this.nav.currentYear = year;
|
||||||
|
this.nav.currentMonth = month;
|
||||||
|
},
|
||||||
|
|
||||||
|
setNav(nav) {
|
||||||
|
this.nav.currentNav = nav;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 兼容旧代码的全局变量
|
||||||
|
let token = AppState.auth.token;
|
||||||
|
let username = AppState.auth.username;
|
||||||
|
let currentType = AppState.nav.currentType;
|
||||||
|
let currentCategory = AppState.nav.currentCategory;
|
||||||
|
let currentYear = AppState.nav.currentYear;
|
||||||
|
let currentMonth = AppState.nav.currentMonth;
|
||||||
|
let currentNav = AppState.nav.currentNav;
|
||||||
|
|
||||||
function headers() {
|
function headers() {
|
||||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}[ch]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value) {
|
||||||
|
return escapeHtml(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsString(value) {
|
||||||
|
return escapeAttr(JSON.stringify(String(value ?? '')));
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordArg(record) {
|
||||||
|
return escapeAttr(JSON.stringify(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一的 API 请求函数,自动处理 401 跳转到登录页
|
||||||
|
async function apiFetch(url, options = {}) {
|
||||||
|
const res = await fetch(url, { ...options, headers: { ...headers(), ...(options.headers || {}) } });
|
||||||
|
if (res.status === 401) {
|
||||||
|
AppState.clearAuth();
|
||||||
|
showPage('loginPage');
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
function formatLocalTime(isoStr) {
|
function formatLocalTime(isoStr) {
|
||||||
if (!isoStr) return '';
|
if (!isoStr) return '';
|
||||||
const d = new Date(isoStr);
|
const d = new Date(isoStr);
|
||||||
@@ -30,7 +114,7 @@ function formatLocalDate(input) {
|
|||||||
|
|
||||||
async function loadTotalStats() {
|
async function loadTotalStats() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/total', { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/total');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const s = await res.json();
|
const s = await res.json();
|
||||||
document.getElementById('totalTakeCount').textContent = s.take_count;
|
document.getElementById('totalTakeCount').textContent = s.take_count;
|
||||||
@@ -48,10 +132,11 @@ function renderHorizontalChart(containerId, rows, clickHandler, fromPage) {
|
|||||||
const max = Math.max(...rows.map(r => r.count), 1);
|
const max = Math.max(...rows.map(r => r.count), 1);
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max((r.count / max) * 100, 2);
|
const pct = Math.max((r.count / max) * 100, 2);
|
||||||
const onclick = clickHandler ? ` onclick="${clickHandler}('${r.name.replace(/'/g, "\\'")}', '${fromPage}')"` : '';
|
const onclick = clickHandler ? ` onclick="${clickHandler}(${jsString(r.name)}, ${jsString(fromPage)})"` : '';
|
||||||
|
const name = escapeHtml(r.name);
|
||||||
return `<div class="hbar-row"${onclick}>
|
return `<div class="hbar-row"${onclick}>
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.name}">${r.name}</div>
|
<div class="hbar-label" title="${escapeAttr(r.name)}">${name}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.count}单</div>
|
<div class="hbar-value">${r.count}单</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -61,10 +146,9 @@ function renderHorizontalChart(containerId, rows, clickHandler, fromPage) {
|
|||||||
async function openTakeRanking() {
|
async function openTakeRanking() {
|
||||||
showPage('takeRankingPage');
|
showPage('takeRankingPage');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/take-ranking', { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/take-ranking');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
console.log('take-ranking data:', data);
|
|
||||||
const rows = data.rows || [];
|
const rows = data.rows || [];
|
||||||
|
|
||||||
// 显示统计卡片
|
// 显示统计卡片
|
||||||
@@ -91,7 +175,7 @@ async function openTakeRanking() {
|
|||||||
async function openDispatchRanking() {
|
async function openDispatchRanking() {
|
||||||
showPage('dispatchRankingPage');
|
showPage('dispatchRankingPage');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/dispatch-ranking', { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/dispatch-ranking');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const rows = data.rows || [];
|
const rows = data.rows || [];
|
||||||
@@ -128,9 +212,10 @@ async function openDispatchRanking() {
|
|||||||
const container = document.getElementById('dispatchRankingChart');
|
const container = document.getElementById('dispatchRankingChart');
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max(((r.dispatch_count || 0) / max) * 100, 2);
|
const pct = Math.max(((r.dispatch_count || 0) / max) * 100, 2);
|
||||||
return `<div class="hbar-row" onclick="openPartnerDetail('${r.name.replace(/'/g, "\\'")}', 'dispatchRankingPage')">
|
const name = escapeHtml(r.name);
|
||||||
|
return `<div class="hbar-row" onclick="openPartnerDetail(${jsString(r.name)}, 'dispatchRankingPage')">
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.name}">${r.name}</div>
|
<div class="hbar-label" title="${escapeAttr(r.name)}">${name}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.dispatch_count}单</div>
|
<div class="hbar-value">${r.dispatch_count}单</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -176,31 +261,26 @@ function showPage(pageId) {
|
|||||||
}
|
}
|
||||||
if (pageId === 'orderListPage') {
|
if (pageId === 'orderListPage') {
|
||||||
document.getElementById('orderListTitle').textContent = `${currentYear}年${currentMonth}月接单列表`;
|
document.getElementById('orderListTitle').textContent = `${currentYear}年${currentMonth}月接单列表`;
|
||||||
orderPage = 1; orderHasMore = true;
|
initList('orderList');
|
||||||
document.getElementById('orderList').innerHTML = '';
|
loadListData('orderList');
|
||||||
loadOrders();
|
|
||||||
}
|
}
|
||||||
if (pageId === 'otherIncomeListPage') {
|
if (pageId === 'otherIncomeListPage') {
|
||||||
document.getElementById('otherIncomeListTitle').textContent = `${currentYear}年${currentMonth}月红包及其他收入`;
|
document.getElementById('otherIncomeListTitle').textContent = `${currentYear}年${currentMonth}月红包及其他收入`;
|
||||||
otherIncomePage = 1; otherIncomeHasMore = true;
|
initList('otherIncomeList');
|
||||||
document.getElementById('otherIncomeList').innerHTML = '';
|
loadListData('otherIncomeList');
|
||||||
loadOtherIncome();
|
|
||||||
}
|
}
|
||||||
if (pageId === 'dispatchListPage') {
|
if (pageId === 'dispatchListPage') {
|
||||||
document.getElementById('dispatchListTitle').textContent = `${currentYear}年${currentMonth}月派单列表`;
|
document.getElementById('dispatchListTitle').textContent = `${currentYear}年${currentMonth}月派单列表`;
|
||||||
dispatchPage = 1; dispatchHasMore = true;
|
initList('dispatchList');
|
||||||
document.getElementById('dispatchList').innerHTML = '';
|
loadListData('dispatchList');
|
||||||
loadDispatches();
|
|
||||||
}
|
}
|
||||||
if (pageId === 'redEnvelopeListPage') {
|
if (pageId === 'redEnvelopeListPage') {
|
||||||
redEnvelopePage = 1; redEnvelopeHasMore = true;
|
initList('redEnvelopeList');
|
||||||
document.getElementById('redEnvelopeList').innerHTML = '';
|
loadListData('redEnvelopeList');
|
||||||
loadRedEnvelope();
|
|
||||||
}
|
}
|
||||||
if (pageId === 'milkTeaListPage') {
|
if (pageId === 'milkTeaListPage') {
|
||||||
milkTeaPage = 1; milkTeaHasMore = true;
|
initList('milkTeaList');
|
||||||
document.getElementById('milkTeaList').innerHTML = '';
|
loadListData('milkTeaList');
|
||||||
loadMilkTea();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,40 @@ async function doLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doRegister() {
|
||||||
|
const user = document.getElementById('registerUser').value.trim();
|
||||||
|
const pass = document.getElementById('registerPass').value.trim();
|
||||||
|
const passConfirm = document.getElementById('registerPassConfirm').value.trim();
|
||||||
|
if (!user || !pass) { document.getElementById('registerError').textContent = '请输入用户名和密码'; return; }
|
||||||
|
if (pass !== passConfirm) { document.getElementById('registerError').textContent = '两次密码输入不一致'; return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, password: pass })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) { document.getElementById('registerError').textContent = data.error; return; }
|
||||||
|
showToast('注册成功,请登录');
|
||||||
|
showLoginPage();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('registerError').textContent = '网络错误';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRegisterPage() {
|
||||||
|
document.getElementById('registerError').textContent = '';
|
||||||
|
document.getElementById('registerUser').value = '';
|
||||||
|
document.getElementById('registerPass').value = '';
|
||||||
|
document.getElementById('registerPassConfirm').value = '';
|
||||||
|
showPage('registerPage');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoginPage() {
|
||||||
|
document.getElementById('loginError').textContent = '';
|
||||||
|
showPage('loginPage');
|
||||||
|
}
|
||||||
|
|
||||||
function doLogout() {
|
function doLogout() {
|
||||||
token = null;
|
token = null;
|
||||||
username = null;
|
username = null;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const _acCache = {};
|
|||||||
async function acFetchNames(field) {
|
async function acFetchNames(field) {
|
||||||
if (_acCache[field] && Date.now() - _acCache[field].ts < 60000) return _acCache[field].data;
|
if (_acCache[field] && Date.now() - _acCache[field].ts < 60000) return _acCache[field].data;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/contacts?field=' + field, { headers: headers() });
|
const res = await apiFetch(API + '/api/contacts?field=' + field);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
_acCache[field] = { data, ts: Date.now() };
|
_acCache[field] = { data, ts: Date.now() };
|
||||||
@@ -45,14 +45,16 @@ function acSetup(inputId, field) {
|
|||||||
if (filtered.length === 0) { list.classList.remove('show'); return; }
|
if (filtered.length === 0) { list.classList.remove('show'); return; }
|
||||||
activeIdx = -1;
|
activeIdx = -1;
|
||||||
list.innerHTML = filtered.slice(0, 10).map((n, i) => {
|
list.innerHTML = filtered.slice(0, 10).map((n, i) => {
|
||||||
let display = n;
|
let display = escapeHtml(n);
|
||||||
if (q) {
|
if (q) {
|
||||||
const idx = n.toLowerCase().indexOf(q);
|
const idx = n.toLowerCase().indexOf(q);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
display = n.slice(0, idx) + '<span class="ac-highlight">' + n.slice(idx, idx + q.length) + '</span>' + n.slice(idx + q.length);
|
display = escapeHtml(n.slice(0, idx)) +
|
||||||
|
'<span class="ac-highlight">' + escapeHtml(n.slice(idx, idx + q.length)) + '</span>' +
|
||||||
|
escapeHtml(n.slice(idx + q.length));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `<div class="ac-item" data-idx="${i}" data-value="${n.replace(/"/g, '"')}">${display}</div>`;
|
return `<div class="ac-item" data-idx="${i}" data-value="${escapeAttr(n)}">${display}</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
list.classList.add('show');
|
list.classList.add('show');
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-28
@@ -12,46 +12,46 @@ function openEditModal(record) {
|
|||||||
if (cat === '接单' || cat === '派单') {
|
if (cat === '接单' || cat === '派单') {
|
||||||
document.getElementById('editModalTitle').textContent = cat === '派单' ? '编辑派单记录' : '编辑接单记录';
|
document.getElementById('editModalTitle').textContent = cat === '派单' ? '编辑派单记录' : '编辑接单记录';
|
||||||
let fields = `
|
let fields = `
|
||||||
<div class="form-group"><label>单数</label><input type="number" id="editQuantity" value="${record.quantity || ''}"></div>
|
<div class="form-group"><label>单数</label><input type="number" id="editQuantity" value="${escapeAttr(record.quantity ?? '')}"></div>
|
||||||
<div class="form-group"><label>单价(元)</label><input type="number" step="0.01" id="editPrice" value="${record.unit_price || ''}"></div>
|
<div class="form-group"><label>单价(元)</label><input type="number" step="0.01" id="editPrice" value="${escapeAttr(record.unit_price ?? '')}"></div>
|
||||||
`;
|
`;
|
||||||
if (cat === '派单') {
|
if (cat === '派单') {
|
||||||
const rebateVal = record.rebate || (record.quantity ? (record.amount / record.quantity) : '');
|
const rebateVal = record.rebate || (record.quantity ? (record.amount / record.quantity) : '');
|
||||||
fields += `<div class="form-group"><label>返点(元)</label><input type="number" step="0.01" id="editRebate" value="${rebateVal}"></div>`;
|
fields += `<div class="form-group"><label>返点(元)</label><input type="number" step="0.01" id="editRebate" value="${escapeAttr(rebateVal)}"></div>`;
|
||||||
}
|
}
|
||||||
fields += `
|
fields += `
|
||||||
<div class="form-group"><label>老板</label><input type="text" id="editBoss" value="${record.boss || ''}"></div>
|
<div class="form-group"><label>老板</label><input type="text" id="editBoss" value="${escapeAttr(record.boss || '')}"></div>
|
||||||
<div class="form-group"><label>${cat === '派单' ? '负责打单的陪玩' : '一起的陪玩'}</label><input type="text" id="editPartner" value="${record.partner || ''}"></div>
|
<div class="form-group"><label>${cat === '派单' ? '负责打单的陪玩' : '一起的陪玩'}</label><input type="text" id="editPartner" value="${escapeAttr(record.partner || '')}"></div>
|
||||||
<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${localTime}"></div>
|
<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${escapeAttr(localTime)}"></div>
|
||||||
`;
|
`;
|
||||||
body.innerHTML = fields;
|
body.innerHTML = fields;
|
||||||
} else if (cat === '红包收入' || cat === '奶茶') {
|
} else if (cat === '红包收入' || cat === '奶茶') {
|
||||||
document.getElementById('editModalTitle').textContent = '编辑' + cat + '记录';
|
document.getElementById('editModalTitle').textContent = '编辑' + cat + '记录';
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="form-group"><label>金额(元)</label><input type="number" step="0.01" id="editAmount" value="${record.amount || ''}"></div>
|
<div class="form-group"><label>金额(元)</label><input type="number" step="0.01" id="editAmount" value="${escapeAttr(record.amount ?? '')}"></div>
|
||||||
<div class="form-group"><label>来源</label><input type="text" id="editSource" value="${record.source || ''}"></div>
|
<div class="form-group"><label>来源</label><input type="text" id="editSource" value="${escapeAttr(record.source || '')}"></div>
|
||||||
<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${localTime}"></div>
|
<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${escapeAttr(localTime)}"></div>
|
||||||
`;
|
`;
|
||||||
} else if (cat === '红包支出' || cat === '比心' || cat === '其他支出') {
|
} else if (cat === '红包支出' || cat === '比心' || cat === '其他支出') {
|
||||||
document.getElementById('editModalTitle').textContent = '编辑' + cat + '记录';
|
document.getElementById('editModalTitle').textContent = '编辑' + cat + '记录';
|
||||||
let fields = `
|
let fields = `
|
||||||
<div class="form-group"><label>金额(元)</label><input type="number" step="0.01" id="editAmount" value="${record.amount || ''}"></div>
|
<div class="form-group"><label>金额(元)</label><input type="number" step="0.01" id="editAmount" value="${escapeAttr(record.amount ?? '')}"></div>
|
||||||
`;
|
`;
|
||||||
if (cat !== '比心') {
|
if (cat !== '比心') {
|
||||||
fields += `<div class="form-group"><label>目的地</label><input type="text" id="editDest" value="${record.destination || ''}"></div>`;
|
fields += `<div class="form-group"><label>目的地</label><input type="text" id="editDest" value="${escapeAttr(record.destination || '')}"></div>`;
|
||||||
} else {
|
} else {
|
||||||
fields += `<div class="form-group"><label>用途</label><input type="text" id="editDest" value="${record.destination || ''}"></div>`;
|
fields += `<div class="form-group"><label>用途</label><input type="text" id="editDest" value="${escapeAttr(record.destination || '')}"></div>`;
|
||||||
}
|
}
|
||||||
if (cat === '其他支出') {
|
if (cat === '其他支出') {
|
||||||
fields += `<div class="form-group"><label>备注</label><input type="text" id="editNote" value="${record.note || ''}"></div>`;
|
fields += `<div class="form-group"><label>备注</label><input type="text" id="editNote" value="${escapeAttr(record.note || '')}"></div>`;
|
||||||
}
|
}
|
||||||
fields += `<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${localTime}"></div>`;
|
fields += `<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${escapeAttr(localTime)}"></div>`;
|
||||||
body.innerHTML = fields;
|
body.innerHTML = fields;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('editModalTitle').textContent = '编辑记录';
|
document.getElementById('editModalTitle').textContent = '编辑记录';
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="form-group"><label>金额(元)</label><input type="number" step="0.01" id="editAmount" value="${record.amount || ''}"></div>
|
<div class="form-group"><label>金额(元)</label><input type="number" step="0.01" id="editAmount" value="${escapeAttr(record.amount ?? '')}"></div>
|
||||||
<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${localTime}"></div>
|
<div class="form-group"><label>时间</label><input type="datetime-local" id="editTime" value="${escapeAttr(localTime)}"></div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
// 绑定自动补全
|
// 绑定自动补全
|
||||||
@@ -74,17 +74,14 @@ function closeEditModal() {
|
|||||||
function refreshCurrentList() {
|
function refreshCurrentList() {
|
||||||
const cat = document.getElementById('editCategory').value;
|
const cat = document.getElementById('editCategory').value;
|
||||||
if (cat === '派单') {
|
if (cat === '派单') {
|
||||||
dispatchPage = 1; dispatchHasMore = true;
|
initList('dispatchList');
|
||||||
document.getElementById('dispatchList').innerHTML = '';
|
loadListData('dispatchList');
|
||||||
loadDispatches();
|
|
||||||
} else if (cat === '接单') {
|
} else if (cat === '接单') {
|
||||||
orderPage = 1; orderHasMore = true;
|
initList('orderList');
|
||||||
document.getElementById('orderList').innerHTML = '';
|
loadListData('orderList');
|
||||||
loadOrders();
|
|
||||||
} else {
|
} else {
|
||||||
otherIncomePage = 1; otherIncomeHasMore = true;
|
initList('otherIncomeList');
|
||||||
document.getElementById('otherIncomeList').innerHTML = '';
|
loadListData('otherIncomeList');
|
||||||
loadOtherIncome();
|
|
||||||
}
|
}
|
||||||
// 如果在日详情页也刷新
|
// 如果在日详情页也刷新
|
||||||
if (document.getElementById('dailyDetailPage').classList.contains('active') && currentDailyDate) {
|
if (document.getElementById('dailyDetailPage').classList.contains('active') && currentDailyDate) {
|
||||||
@@ -138,9 +135,8 @@ async function saveEditRecord() {
|
|||||||
if (time) body.created_at = new Date(time).toISOString();
|
if (time) body.created_at = new Date(time).toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/records/' + id, {
|
const res = await apiFetch(API + '/api/records/' + id, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: headers(),
|
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
if (!res.ok) { showToast('保存失败'); return; }
|
if (!res.ok) { showToast('保存失败'); return; }
|
||||||
@@ -155,7 +151,7 @@ async function deleteEditRecord() {
|
|||||||
const id = document.getElementById('editId').value;
|
const id = document.getElementById('editId').value;
|
||||||
if (!confirm('确定删除这条记录?')) return;
|
if (!confirm('确定删除这条记录?')) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/records/' + id, { method: 'DELETE', headers: headers() });
|
const res = await apiFetch(API + '/api/records/' + id, { method: 'DELETE' });
|
||||||
if (!res.ok) { showToast('删除失败'); return; }
|
if (!res.ok) { showToast('删除失败'); return; }
|
||||||
showToast('已删除');
|
showToast('已删除');
|
||||||
closeEditModal();
|
closeEditModal();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ async function doExport(format) {
|
|||||||
closeExportModal();
|
closeExportModal();
|
||||||
showToast('正在导出...');
|
showToast('正在导出...');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/export?format=' + format, { headers: headers() });
|
const res = await apiFetch(API + '/api/export?format=' + format);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
showToast(err.error || '导出失败');
|
showToast(err.error || '导出失败');
|
||||||
@@ -88,9 +88,9 @@ async function confirmImport() {
|
|||||||
if (!pendingImportContent) return;
|
if (!pendingImportContent) return;
|
||||||
showToast('正在导入...');
|
showToast('正在导入...');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/import', {
|
const res = await apiFetch(API + '/api/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Authorization': 'Bearer ' + token },
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
body: pendingImportContent
|
body: pendingImportContent
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|||||||
+181
-273
@@ -1,300 +1,208 @@
|
|||||||
let orderPage = 1;
|
// ============================================
|
||||||
let orderHasMore = true;
|
// 通用列表管理 - 无限滚动加载
|
||||||
let orderLoading = false;
|
// ============================================
|
||||||
|
|
||||||
async function loadOrders(append) {
|
const config = {
|
||||||
if (orderLoading || (!append && !orderHasMore)) return;
|
orderList: { endpoint: '/api/records/orders', emptyMsg: '暂无接单记录', renderItem: renderOrderItem },
|
||||||
orderLoading = true;
|
otherIncomeList: { endpoint: '/api/records/other-income', emptyMsg: '暂无红包及其他收入记录', renderItem: renderOtherIncomeItem },
|
||||||
document.getElementById('orderLoading').style.display = 'block';
|
dispatchList: { endpoint: '/api/records/dispatches', emptyMsg: '暂无派单记录', renderItem: renderDispatchItem },
|
||||||
try {
|
redEnvelopeList: { endpoint: '/api/records/red-envelope', emptyMsg: '暂无红包收入记录', renderItem: renderRedEnvelopeItem },
|
||||||
const res = await fetch(API + '/api/records/orders?page=' + orderPage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
milkTeaList: { endpoint: '/api/records/milk-tea', emptyMsg: '暂无奶茶收入记录', renderItem: renderMilkTeaItem }
|
||||||
if (!res.ok) return;
|
};
|
||||||
const data = await res.json();
|
|
||||||
const container = document.getElementById('orderList');
|
const PAGE_SIZE = 20;
|
||||||
if (!append) container.innerHTML = '';
|
|
||||||
if (data.records.length === 0) {
|
const listState = {};
|
||||||
if (orderPage === 1) container.innerHTML = '<div class="ranking-empty">暂无接单记录</div>';
|
|
||||||
orderHasMore = false;
|
function initList(listId) {
|
||||||
} else {
|
listState[listId] = {
|
||||||
const grouped = {};
|
loading: false,
|
||||||
data.records.forEach(r => {
|
records: [],
|
||||||
const date = formatLocalDate(r.created_at);
|
page: 1,
|
||||||
if (!grouped[date]) grouped[date] = [];
|
hasMore: true,
|
||||||
grouped[date].push(r);
|
totalCount: 0
|
||||||
});
|
};
|
||||||
let html = '';
|
|
||||||
for (const [date, records] of Object.entries(grouped)) {
|
const container = document.getElementById(listId);
|
||||||
html += `<div class="order-date-group" data-date="${date}">
|
if (container) container.innerHTML = '';
|
||||||
<div class="order-date-label">${date}</div>`;
|
|
||||||
records.forEach(r => {
|
|
||||||
const time = formatLocalTime(r.created_at);
|
|
||||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
|
||||||
<div class="order-item-top">
|
|
||||||
<span class="order-item-boss">${r.boss || '-'}</span>
|
|
||||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="order-item-bottom">
|
|
||||||
<span>${r.quantity || 0}单 × ${(r.unit_price || 0).toFixed(2)}</span>
|
|
||||||
<span>${time}${r.partner ? ' · ' + r.partner : ''}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
container.insertAdjacentHTML('beforeend', html);
|
|
||||||
orderHasMore = data.records.length >= 20;
|
|
||||||
orderPage++;
|
|
||||||
if (!append) {
|
|
||||||
const today = formatLocalDate(new Date());
|
|
||||||
setTimeout(() => {
|
|
||||||
const el = container.querySelector(`[data-date="${today}"]`);
|
|
||||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
orderLoading = false;
|
|
||||||
document.getElementById('orderLoading').style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动加载
|
async function loadListData(listId) {
|
||||||
window.addEventListener('scroll', () => {
|
const cfg = config[listId];
|
||||||
if (document.getElementById('orderListPage').classList.contains('active')) {
|
const state = listState[listId];
|
||||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
|
if (!cfg || !state) return;
|
||||||
if (orderHasMore && !orderLoading) loadOrders(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (document.getElementById('dispatchListPage').classList.contains('active')) {
|
|
||||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
|
|
||||||
if (dispatchHasMore && !dispatchLoading) loadDispatches(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (document.getElementById('otherIncomeListPage').classList.contains('active')) {
|
|
||||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
|
|
||||||
if (otherIncomeHasMore && !otherIncomeLoading) loadOtherIncome(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (document.getElementById('redEnvelopeListPage').classList.contains('active')) {
|
|
||||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
|
|
||||||
if (redEnvelopeHasMore && !redEnvelopeLoading) loadRedEnvelope(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (document.getElementById('milkTeaListPage').classList.contains('active')) {
|
|
||||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
|
|
||||||
if (milkTeaHasMore && !milkTeaLoading) loadMilkTea(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 红包及其他收入列表
|
if (state.loading || !state.hasMore) return;
|
||||||
let otherIncomePage = 1;
|
|
||||||
let otherIncomeHasMore = true;
|
state.loading = true;
|
||||||
let otherIncomeLoading = false;
|
showLoading(listId, true);
|
||||||
|
|
||||||
async function loadOtherIncome(append) {
|
|
||||||
if (otherIncomeLoading || (!append && !otherIncomeHasMore)) return;
|
|
||||||
otherIncomeLoading = true;
|
|
||||||
document.getElementById('otherIncomeLoading').style.display = 'block';
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/records/other-income?page=' + otherIncomePage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
const res = await apiFetch(
|
||||||
if (!res.ok) return;
|
API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('[loadListData] fetch failed:', res.status);
|
||||||
|
state.loading = false;
|
||||||
|
showLoading(listId, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const container = document.getElementById('otherIncomeList');
|
const newRecords = data.records || [];
|
||||||
if (!append) container.innerHTML = '';
|
const total = data.total || 0;
|
||||||
if (data.records.length === 0) {
|
|
||||||
if (otherIncomePage === 1) container.innerHTML = '<div class="ranking-empty">暂无红包及其他收入记录</div>';
|
state.records = state.records.concat(newRecords);
|
||||||
otherIncomeHasMore = false;
|
state.totalCount = total;
|
||||||
|
state.hasMore = state.records.length < total;
|
||||||
|
state.page++;
|
||||||
|
|
||||||
|
renderList(listId);
|
||||||
|
setupScrollObserver(listId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[loadListData] error:', e);
|
||||||
|
} finally {
|
||||||
|
state.loading = false;
|
||||||
|
showLoading(listId, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(listId, show) {
|
||||||
|
const loadingId = listId.replace('List', 'Loading');
|
||||||
|
const loadingEl = document.getElementById(loadingId);
|
||||||
|
if (loadingEl) {
|
||||||
|
loadingEl.style.display = show ? 'block' : 'none';
|
||||||
|
if (!show && listState[listId]) {
|
||||||
|
const state = listState[listId];
|
||||||
|
if (state.hasMore) {
|
||||||
|
loadingEl.textContent = '下拉加载更多...';
|
||||||
|
} else if (state.records.length > 0) {
|
||||||
|
loadingEl.textContent = '已加载全部';
|
||||||
} else {
|
} else {
|
||||||
|
loadingEl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(listId) {
|
||||||
|
const cfg = config[listId];
|
||||||
|
const state = listState[listId];
|
||||||
|
if (!cfg || !state) return;
|
||||||
|
|
||||||
|
const container = document.getElementById(listId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (state.records.length === 0) {
|
||||||
|
container.innerHTML = '<div class="ranking-empty">' + cfg.emptyMsg + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const grouped = {};
|
const grouped = {};
|
||||||
data.records.forEach(r => {
|
state.records.forEach(r => {
|
||||||
const date = formatLocalDate(r.created_at);
|
const date = formatLocalDate(r.created_at);
|
||||||
if (!grouped[date]) grouped[date] = [];
|
if (!grouped[date]) grouped[date] = [];
|
||||||
grouped[date].push(r);
|
grouped[date].push(r);
|
||||||
});
|
});
|
||||||
let html = '';
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
for (const [date, records] of Object.entries(grouped)) {
|
for (const [date, records] of Object.entries(grouped)) {
|
||||||
html += `<div class="order-date-group" data-date="${date}">
|
const group = document.createElement('div');
|
||||||
<div class="order-date-label">${date}</div>`;
|
group.className = 'order-date-group';
|
||||||
|
group.setAttribute('data-date', date);
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'order-date-label';
|
||||||
|
label.textContent = date;
|
||||||
|
group.appendChild(label);
|
||||||
|
|
||||||
records.forEach(r => {
|
records.forEach(r => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.innerHTML = cfg.renderItem(r);
|
||||||
|
group.appendChild(item.firstChild);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupScrollObserver(listId) {
|
||||||
|
const state = listState[listId];
|
||||||
|
if (!state || !state.hasMore) return;
|
||||||
|
|
||||||
|
const container = document.getElementById(listId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// 移除旧的 sentinel
|
||||||
|
const oldSentinel = container.querySelector('.scroll-sentinel');
|
||||||
|
if (oldSentinel) oldSentinel.remove();
|
||||||
|
|
||||||
|
// 创建新的 sentinel
|
||||||
|
const sentinel = document.createElement('div');
|
||||||
|
sentinel.className = 'scroll-sentinel';
|
||||||
|
sentinel.style.height = '1px';
|
||||||
|
container.appendChild(sentinel);
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && !state.loading && state.hasMore) {
|
||||||
|
loadListData(listId);
|
||||||
|
}
|
||||||
|
}, { rootMargin: '100px' });
|
||||||
|
|
||||||
|
observer.observe(sentinel);
|
||||||
|
|
||||||
|
// 存储 observer 以便清理
|
||||||
|
state.observer = observer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 兼容接口
|
||||||
|
// ============================================
|
||||||
|
async function loadOrders() { return loadListData('orderList'); }
|
||||||
|
async function loadOtherIncome() { return loadListData('otherIncomeList'); }
|
||||||
|
async function loadDispatches() { return loadListData('dispatchList'); }
|
||||||
|
async function loadRedEnvelope() { return loadListData('redEnvelopeList'); }
|
||||||
|
async function loadMilkTea() { return loadListData('milkTeaList'); }
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 渲染函数
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function renderOrderItem(r) {
|
||||||
|
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 time = formatLocalTime(r.created_at);
|
||||||
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
||||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
|
||||||
<div class="order-item-top">
|
'<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>' +
|
||||||
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span>
|
'<div class="order-item-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
|
||||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="order-item-bottom">
|
|
||||||
<span>${r.source || ''}</span>
|
|
||||||
<span>${time}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
container.insertAdjacentHTML('beforeend', html);
|
|
||||||
otherIncomeHasMore = data.records.length >= 20;
|
|
||||||
otherIncomePage++;
|
|
||||||
}
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
otherIncomeLoading = false;
|
|
||||||
document.getElementById('otherIncomeLoading').style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 派单列表
|
function renderDispatchItem(r) {
|
||||||
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);
|
const time = formatLocalTime(r.created_at);
|
||||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
const unitPrice = r.unit_price || (r.quantity ? r.amount / r.quantity : 0);
|
||||||
<div class="order-item-top">
|
return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
|
||||||
<span class="order-item-boss">${r.boss || '-'}</span>
|
'<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>' +
|
||||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
'<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>';
|
||||||
</div>
|
|
||||||
<div class="order-item-bottom">
|
|
||||||
<span>${r.quantity || 0}单 × ${(r.rebate || r.amount / r.quantity).toFixed(2)}(单价${(r.unit_price || 0).toFixed(2)})</span>
|
|
||||||
<span>${time}${r.partner ? ' · ' + r.partner : ''}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
container.insertAdjacentHTML('beforeend', html);
|
|
||||||
dispatchHasMore = data.records.length >= 20;
|
|
||||||
dispatchPage++;
|
|
||||||
}
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
dispatchLoading = false;
|
|
||||||
document.getElementById('dispatchLoading').style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 红包收入列表
|
function renderRedEnvelopeItem(r) {
|
||||||
let redEnvelopePage = 1;
|
|
||||||
let redEnvelopeHasMore = true;
|
|
||||||
let redEnvelopeLoading = false;
|
|
||||||
|
|
||||||
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);
|
const time = formatLocalTime(r.created_at);
|
||||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
|
||||||
<div class="order-item-top">
|
'<div class="order-item-top"><span class="order-item-boss">红包</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||||||
<span class="order-item-boss">红包</span>
|
'<div class="order-item-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
|
||||||
<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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 奶茶收入列表
|
function renderMilkTeaItem(r) {
|
||||||
let milkTeaPage = 1;
|
|
||||||
let milkTeaHasMore = true;
|
|
||||||
let milkTeaLoading = false;
|
|
||||||
|
|
||||||
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);
|
const time = formatLocalTime(r.created_at);
|
||||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
|
||||||
<div class="order-item-top">
|
'<div class="order-item-top"><span class="order-item-boss">奶茶</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||||||
<span class="order-item-boss">奶茶</span>
|
'<div class="order-item-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
|
||||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="order-item-bottom">
|
|
||||||
<span>${r.source || ''}</span>
|
|
||||||
<span>${time}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
container.insertAdjacentHTML('beforeend', html);
|
|
||||||
milkTeaHasMore = data.records.length >= 20;
|
|
||||||
milkTeaPage++;
|
|
||||||
}
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
milkTeaLoading = false;
|
|
||||||
document.getElementById('milkTeaLoading').style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-7
@@ -17,7 +17,7 @@ async function loadRankingDetail() {
|
|||||||
url += '&endDate=' + formatLocalDate(d);
|
url += '&endDate=' + formatLocalDate(d);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: headers() });
|
const res = await apiFetch(url);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const rows = await res.json();
|
const rows = await res.json();
|
||||||
const container = document.getElementById('rankingDetailList');
|
const container = document.getElementById('rankingDetailList');
|
||||||
@@ -29,9 +29,10 @@ async function loadRankingDetail() {
|
|||||||
const max = Math.max(...rows.map(r => r.dispatch_count), 1);
|
const max = Math.max(...rows.map(r => r.dispatch_count), 1);
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max((r.dispatch_count / max) * 100, 2);
|
const pct = Math.max((r.dispatch_count / max) * 100, 2);
|
||||||
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'rankingPage')">
|
const boss = escapeHtml(r.boss);
|
||||||
|
return `<div class="hbar-row" onclick="openBossDetail(${jsString(r.boss)}, 'rankingPage')">
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.boss}">${r.boss}</div>
|
<div class="hbar-label" title="${escapeAttr(r.boss)}">${boss}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.dispatch_count}单</div>
|
<div class="hbar-value">${r.dispatch_count}单</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -40,9 +41,10 @@ async function loadRankingDetail() {
|
|||||||
const max = Math.max(...rows.map(r => r.take_count), 1);
|
const max = Math.max(...rows.map(r => r.take_count), 1);
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max((r.take_count / max) * 100, 2);
|
const pct = Math.max((r.take_count / max) * 100, 2);
|
||||||
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'rankingPage')">
|
const boss = escapeHtml(r.boss);
|
||||||
|
return `<div class="hbar-row" onclick="openBossDetail(${jsString(r.boss)}, 'rankingPage')">
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.boss}">${r.boss}</div>
|
<div class="hbar-label" title="${escapeAttr(r.boss)}">${boss}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.take_count}单</div>
|
<div class="hbar-value">${r.take_count}单</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -51,9 +53,10 @@ async function loadRankingDetail() {
|
|||||||
const max = Math.max(...rows.map(r => r.total_income), 1);
|
const max = Math.max(...rows.map(r => r.total_income), 1);
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max((r.total_income / max) * 100, 2);
|
const pct = Math.max((r.total_income / max) * 100, 2);
|
||||||
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'rankingPage')">
|
const boss = escapeHtml(r.boss);
|
||||||
|
return `<div class="hbar-row" onclick="openBossDetail(${jsString(r.boss)}, 'rankingPage')">
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.boss}">${r.boss}</div>
|
<div class="hbar-label" title="${escapeAttr(r.boss)}">${boss}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.total_income.toFixed(2)}</div>
|
<div class="hbar-value">${r.total_income.toFixed(2)}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -123,11 +123,12 @@ async function submitRecord() {
|
|||||||
const partner = document.getElementById('fPartner')?.value.trim() || '';
|
const partner = document.getElementById('fPartner')?.value.trim() || '';
|
||||||
if (currentCategory === '派单') {
|
if (currentCategory === '派单') {
|
||||||
const rebate = parseFloat(document.getElementById('fRebate').value);
|
const rebate = parseFloat(document.getElementById('fRebate').value);
|
||||||
if (!quantity || !unit_price || !rebate || !boss) { showToast('请填写完整信息'); return; }
|
if (!quantity || !unit_price || !rebate || !boss || !partner) { showToast('请填写完整信息(包括负责打单的陪玩)'); return; }
|
||||||
body.quantity = quantity;
|
body.quantity = quantity;
|
||||||
body.unit_price = unit_price;
|
body.unit_price = unit_price;
|
||||||
body.rebate = rebate;
|
body.rebate = rebate;
|
||||||
body.amount = quantity * rebate;
|
body.amount = quantity * rebate;
|
||||||
|
body.partner = partner;
|
||||||
} else {
|
} else {
|
||||||
if (!quantity || !unit_price || !boss) { showToast('请填写完整信息'); return; }
|
if (!quantity || !unit_price || !boss) { showToast('请填写完整信息'); return; }
|
||||||
body.quantity = quantity;
|
body.quantity = quantity;
|
||||||
@@ -135,7 +136,6 @@ async function submitRecord() {
|
|||||||
body.amount = quantity * unit_price;
|
body.amount = quantity * unit_price;
|
||||||
}
|
}
|
||||||
body.boss = boss;
|
body.boss = boss;
|
||||||
body.partner = partner;
|
|
||||||
} else if (currentCategory === '红包收入' || currentCategory === '奶茶') {
|
} else if (currentCategory === '红包收入' || currentCategory === '奶茶') {
|
||||||
const amount = parseFloat(document.getElementById('fAmount').value);
|
const amount = parseFloat(document.getElementById('fAmount').value);
|
||||||
const source = document.getElementById('fSource').value.trim();
|
const source = document.getElementById('fSource').value.trim();
|
||||||
@@ -159,9 +159,8 @@ async function submitRecord() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/records', {
|
const res = await apiFetch(API + '/api/records', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers(),
|
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
if (!res.ok) { showToast('保存失败'); return; }
|
if (!res.ok) { showToast('保存失败'); return; }
|
||||||
|
|||||||
+403
-103
@@ -1,8 +1,15 @@
|
|||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
// 并行加载所有数据
|
||||||
if (!res.ok) return;
|
const [monthlyRes, bossRankRes, partnerRankRes, weeklyRes] = await Promise.all([
|
||||||
const s = await res.json();
|
apiFetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth),
|
||||||
|
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('takeCount').textContent = s.take_order_count;
|
||||||
document.getElementById('dispatchCount').textContent = s.dispatch_order_count;
|
document.getElementById('dispatchCount').textContent = s.dispatch_order_count;
|
||||||
document.getElementById('takeIncome').textContent = '' + s.take_order_income.toFixed(2);
|
document.getElementById('takeIncome').textContent = '' + s.take_order_income.toFixed(2);
|
||||||
@@ -14,30 +21,81 @@ async function loadStats() {
|
|||||||
document.getElementById('totalIncome').textContent = '' + s.total_income.toFixed(2);
|
document.getElementById('totalIncome').textContent = '' + s.total_income.toFixed(2);
|
||||||
document.getElementById('totalExpense').textContent = '' + s.total_expense.toFixed(2);
|
document.getElementById('totalExpense').textContent = '' + s.total_expense.toFixed(2);
|
||||||
document.getElementById('netIncome').textContent = '' + (s.total_income - s.total_expense).toFixed(2);
|
document.getElementById('netIncome').textContent = '' + (s.total_income - s.total_expense).toFixed(2);
|
||||||
loadBossRankingPreview();
|
}
|
||||||
loadPartnerRankingPreview();
|
|
||||||
loadWeeklyChart();
|
if (bossRankRes.ok) {
|
||||||
|
const data = await bossRankRes.json();
|
||||||
|
renderBossRankingPreview(data || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partnerRankRes.ok) {
|
||||||
|
const data = await partnerRankRes.json();
|
||||||
|
renderPartnerRankingPreview(data || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weeklyRes.ok) {
|
||||||
|
const days = await weeklyRes.json();
|
||||||
|
renderWeeklyChart(days);
|
||||||
|
}
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordLineText(r, fallbackField) {
|
||||||
|
if (r.quantity) {
|
||||||
|
if (r.category === '派单') {
|
||||||
|
const rebate = (r.rebate || r.amount / r.quantity).toFixed(2);
|
||||||
|
return r.quantity + '单 × ' + rebate + '(单价' + (r.unit_price || 0).toFixed(2) + ')';
|
||||||
|
}
|
||||||
|
return r.quantity + '单 × ' + (r.unit_price || 0).toFixed(2);
|
||||||
|
}
|
||||||
|
return r[fallbackField] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecordItem(r, opts = {}) {
|
||||||
|
const time = opts.time || formatLocalTime(r.created_at);
|
||||||
|
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
||||||
|
const suffix = opts.suffix || '';
|
||||||
|
const line = opts.line !== undefined ? opts.line : recordLineText(r, 'note');
|
||||||
|
const amountPrefix = opts.amountPrefix || '';
|
||||||
|
return `<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="${recordArg(r)}">
|
||||||
|
<div class="order-item-top">
|
||||||
|
<span class="order-item-boss">${escapeHtml(displayCategory + suffix)}</span>
|
||||||
|
<span class="order-item-amount">${amountPrefix}${(r.amount || 0).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="order-item-bottom">
|
||||||
|
<span>${escapeHtml(line)}</span>
|
||||||
|
<span>${escapeHtml(time + (opts.partnerTime && r.partner ? ' · ' + r.partner : ''))}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadWeeklyChart() {
|
async function loadWeeklyChart() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/daily-income', { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/daily-income');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const days = await res.json();
|
const days = await res.json();
|
||||||
|
renderWeeklyChart(days);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWeeklyChart(days) {
|
||||||
const container = document.getElementById('weeklyChartBars');
|
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 max = Math.max(...days.map(d => d.income), 1);
|
||||||
|
const barMaxHeight = 110; // 柱状图最大可用像素高度
|
||||||
container.innerHTML = days.map(d => {
|
container.innerHTML = days.map(d => {
|
||||||
const pct = Math.max((d.income / max) * 100, 3);
|
const height = Math.max((d.income / max) * barMaxHeight, 4);
|
||||||
const label = d.date.slice(5).replace('-', '/');
|
const label = d.date.slice(5).replace('-', '/');
|
||||||
const amount = d.income > 0 ? '' + d.income.toFixed(0) : '0';
|
const amount = d.income > 0 ? '' + d.income.toFixed(0) : '0';
|
||||||
return `<div class="weekly-bar-col" onclick="openDailyDetail('${d.date}')">
|
return `<div class="weekly-bar-col" onclick="openDailyDetail(${jsString(d.date)})">
|
||||||
<span class="weekly-bar-amount">${amount}</span>
|
<span class="weekly-bar-amount">${amount}</span>
|
||||||
<div class="weekly-bar" style="height:${pct}%"></div>
|
<div class="weekly-bar" style="height:${height}px"></div>
|
||||||
<span class="weekly-bar-date">${label}</span>
|
<span class="weekly-bar-date">${label}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentDailyDate = '';
|
let currentDailyDate = '';
|
||||||
@@ -52,7 +110,7 @@ async function openDailyDetail(date) {
|
|||||||
showPage('dailyDetailPage');
|
showPage('dailyDetailPage');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/daily-detail?date=' + date, { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/daily-detail?date=' + date);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
document.getElementById('dailySummary').innerHTML = `
|
document.getElementById('dailySummary').innerHTML = `
|
||||||
@@ -66,17 +124,12 @@ async function openDailyDetail(date) {
|
|||||||
}
|
}
|
||||||
container.innerHTML = data.records.map(r => {
|
container.innerHTML = data.records.map(r => {
|
||||||
const time = formatLocalTime(r.created_at);
|
const time = formatLocalTime(r.created_at);
|
||||||
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
return renderRecordItem(r, {
|
||||||
return `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
time,
|
||||||
<div class="order-item-top">
|
suffix: r.boss ? ' · ' + r.boss : '',
|
||||||
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span>
|
line: recordLineText(r, 'source'),
|
||||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
partnerTime: true
|
||||||
</div>
|
});
|
||||||
<div class="order-item-bottom">
|
|
||||||
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + ')' : (r.unit_price || 0).toFixed(2)) : (r.source || '')}</span>
|
|
||||||
<span>${time}${r.partner ? ' · ' + r.partner : ''}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
@@ -115,7 +168,7 @@ async function loadYearlyNetIncome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/yearly-net-income', { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/yearly-net-income');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
@@ -185,7 +238,7 @@ async function loadMonthlyDetail(type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/monthly-records?type=' + type + '&year=' + monthlyDetailYear + '&month=' + monthlyDetailMonth, { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/monthly-records?type=' + type + '&year=' + monthlyDetailYear + '&month=' + monthlyDetailMonth + '&limit=1000');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const color = type === 'income' ? '#27ae60' : '#e74c3c';
|
const color = type === 'income' ? '#27ae60' : '#e74c3c';
|
||||||
@@ -209,17 +262,13 @@ async function loadMonthlyDetail(type) {
|
|||||||
html += `<div class="order-date-group"><div class="order-date-label">${date}</div>`;
|
html += `<div class="order-date-group"><div class="order-date-label">${date}</div>`;
|
||||||
records.forEach(r => {
|
records.forEach(r => {
|
||||||
const time = formatLocalTime(r.created_at);
|
const time = formatLocalTime(r.created_at);
|
||||||
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
html += renderRecordItem(r, {
|
||||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
time,
|
||||||
<div class="order-item-top">
|
suffix: r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : '',
|
||||||
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : ''}</span>
|
line: recordLineText(r, 'note'),
|
||||||
<span class="order-item-amount">${type === 'expense' ? '-' : ''}${(r.amount || 0).toFixed(2)}</span>
|
amountPrefix: type === 'expense' ? '-' : '',
|
||||||
</div>
|
partnerTime: true
|
||||||
<div class="order-item-bottom">
|
});
|
||||||
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + ')' : (r.unit_price || 0).toFixed(2)) : (r.note || '')}</span>
|
|
||||||
<span>${time}${r.partner ? ' · ' + r.partner : ''}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
});
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
@@ -229,50 +278,146 @@ async function loadMonthlyDetail(type) {
|
|||||||
|
|
||||||
async function loadBossRankingPreview() {
|
async function loadBossRankingPreview() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const rows = await res.json();
|
const rows = await res.json();
|
||||||
const container = document.getElementById('rankingPreviewList');
|
renderBossRankingPreview(rows);
|
||||||
if (rows.length === 0) {
|
} 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>';
|
container.innerHTML = '<div class="ranking-empty">暂无数据</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const max = Math.max(...rows.map(r => r.total_income), 1);
|
const max = Math.max(...rows.map(r => getValue(r)), 1);
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max((r.total_income / max) * 100, 2);
|
const pct = Math.max((getValue(r) / max) * 100, 2);
|
||||||
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'homePage')">
|
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-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.boss}">${r.boss}</div>
|
<div class="hbar-label" title="${escapeAttr(rawLabel)}">${label}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.total_income.toFixed(2)}</div>
|
<div class="hbar-value">${value}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch (e) { console.error(e); }
|
}
|
||||||
|
|
||||||
|
function renderBossRankingPreview(rows) {
|
||||||
|
renderHBarRanking(rows, {
|
||||||
|
containerId: 'rankingPreviewList',
|
||||||
|
getValue: r => r.total_income,
|
||||||
|
getLabel: r => r.boss,
|
||||||
|
onClick: 'openBossDetail'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentBossName = '';
|
let currentBossName = '';
|
||||||
let bossDetailFrom = 'homePage';
|
let bossDetailFrom = 'homePage';
|
||||||
|
let bossDetailYear = 0;
|
||||||
|
let bossDetailMonth = 0;
|
||||||
|
let bossDetailScopeAll = false;
|
||||||
|
let bossDetailPage = 1;
|
||||||
|
let bossDetailLimit = 20;
|
||||||
|
let bossDetailTotalCount = 0;
|
||||||
|
let bossDetailAllRecords = [];
|
||||||
|
let bossDetailLoading = false;
|
||||||
|
let bossDetailScrollHandler = null;
|
||||||
|
|
||||||
function openBossDetail(boss, from) {
|
function openBossDetail(boss, from) {
|
||||||
currentBossName = boss;
|
currentBossName = boss;
|
||||||
bossDetailFrom = from || 'homePage';
|
bossDetailFrom = from || 'homePage';
|
||||||
|
bossDetailYear = currentYear;
|
||||||
|
bossDetailMonth = currentMonth;
|
||||||
|
// 从排行榜进入时默认显示全部
|
||||||
|
bossDetailScopeAll = (from === 'rankingPage' || from === 'takeRankingPage');
|
||||||
|
bossDetailPage = 1;
|
||||||
|
bossDetailLimit = bossDetailScopeAll ? 500 : 20;
|
||||||
|
bossDetailTotalCount = 0;
|
||||||
|
bossDetailAllRecords = [];
|
||||||
|
updateBossDetailNav();
|
||||||
loadBossDetail();
|
loadBossDetail();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bossDetailGoBack() {
|
function bossDetailGoBack() {
|
||||||
|
if (bossDetailScrollHandler) {
|
||||||
|
window.removeEventListener('scroll', bossDetailScrollHandler);
|
||||||
|
bossDetailScrollHandler = null;
|
||||||
|
}
|
||||||
showPage(bossDetailFrom);
|
showPage(bossDetailFrom);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBossDetail() {
|
function updateBossDetailNav() {
|
||||||
|
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('bossDetailTitle').textContent = currentBossName + ' 交易记录';
|
||||||
document.getElementById('bossDetailSummary').innerHTML = '<div class="ranking-empty">加载中...</div>';
|
document.getElementById('bossDetailSummary').innerHTML = '<div class="ranking-empty">加载中...</div>';
|
||||||
document.getElementById('bossDetailList').innerHTML = '';
|
document.getElementById('bossDetailList').innerHTML = '';
|
||||||
showPage('bossDetailPage');
|
showPage('bossDetailPage');
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = API + '/api/stats/boss-records?boss=' + encodeURIComponent(currentBossName) + '&page=' + bossDetailPage + '&limit=' + bossDetailLimit;
|
||||||
|
if (!bossDetailScopeAll) {
|
||||||
|
url += '&year=' + bossDetailYear + '&month=' + bossDetailMonth;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/boss-records?boss=' + encodeURIComponent(currentBossName), { headers: headers() });
|
const res = await apiFetch(url);
|
||||||
if (!res.ok) return;
|
if (!res.ok) { bossDetailLoading = false; return; }
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!append) {
|
||||||
document.getElementById('bossDetailSummary').innerHTML = `
|
document.getElementById('bossDetailSummary').innerHTML = `
|
||||||
<div style="display:flex;justify-content:space-around;flex-wrap:wrap;gap:8px">
|
<div style="display:flex;justify-content:space-around;flex-wrap:wrap;gap:8px">
|
||||||
<div style="text-align:center">
|
<div style="text-align:center">
|
||||||
@@ -283,15 +428,35 @@ async function loadBossDetail() {
|
|||||||
<div class="daily-summary-amount" style="font-size:24px;color:#27ae60">${data.my_income.toFixed(2)}</div>
|
<div class="daily-summary-amount" style="font-size:24px;color:#27ae60">${data.my_income.toFixed(2)}</div>
|
||||||
<div class="daily-summary-label">我的总收入</div>
|
<div class="daily-summary-label">我的总收入</div>
|
||||||
</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>
|
||||||
`;
|
`;
|
||||||
|
bossDetailAllRecords = data.records || [];
|
||||||
|
setupBossDetailScroll();
|
||||||
|
} else {
|
||||||
|
bossDetailAllRecords = bossDetailAllRecords.concat(data.records || []);
|
||||||
|
}
|
||||||
|
bossDetailTotalCount = data.totalCount || 0;
|
||||||
|
|
||||||
const container = document.getElementById('bossDetailList');
|
const container = document.getElementById('bossDetailList');
|
||||||
if (data.records.length === 0) {
|
const moreBtn = document.getElementById('bossDetailMore');
|
||||||
|
|
||||||
|
if (bossDetailAllRecords.length === 0) {
|
||||||
container.innerHTML = '<div class="ranking-empty">暂无交易记录</div>';
|
container.innerHTML = '<div class="ranking-empty">暂无交易记录</div>';
|
||||||
|
if (moreBtn) moreBtn.style.display = 'none';
|
||||||
|
bossDetailLoading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouped = {};
|
const grouped = {};
|
||||||
data.records.forEach(r => {
|
bossDetailAllRecords.forEach(r => {
|
||||||
const d = new Date(r.created_at);
|
const d = new Date(r.created_at);
|
||||||
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
|
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
|
||||||
if (!grouped[key]) grouped[key] = { records: [], total: 0 };
|
if (!grouped[key]) grouped[key] = { records: [], total: 0 };
|
||||||
@@ -303,47 +468,72 @@ async function loadBossDetail() {
|
|||||||
html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`;
|
html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`;
|
||||||
group.records.forEach(r => {
|
group.records.forEach(r => {
|
||||||
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
|
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
|
||||||
const displayCategory = r.category === '红包收入' ? '红包' : r.category;
|
html += renderRecordItem(r, {
|
||||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
time,
|
||||||
<div class="order-item-top">
|
suffix: r.partner ? ' · ' + r.partner : '',
|
||||||
<span class="order-item-boss">${displayCategory}${r.partner ? ' · ' + r.partner : ''}</span>
|
line: recordLineText(r, 'note')
|
||||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
});
|
||||||
</div>
|
|
||||||
<div class="order-item-bottom">
|
|
||||||
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + ')' : (r.unit_price || 0).toFixed(2)) : (r.note || '')}</span>
|
|
||||||
<span>${time}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
});
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
|
// Show/hide load more indicator
|
||||||
|
if (moreBtn) {
|
||||||
|
if (bossDetailAllRecords.length < bossDetailTotalCount) {
|
||||||
|
moreBtn.style.display = 'block';
|
||||||
|
moreBtn.textContent = '加载中... (' + bossDetailAllRecords.length + '/' + bossDetailTotalCount + ')';
|
||||||
|
} else {
|
||||||
|
moreBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bossDetailLoading = false;
|
||||||
|
} catch (e) {
|
||||||
|
bossDetailLoading = false;
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupBossDetailScroll() {
|
||||||
|
if (bossDetailScrollHandler) {
|
||||||
|
window.removeEventListener('scroll', bossDetailScrollHandler);
|
||||||
|
}
|
||||||
|
bossDetailScrollHandler = function() {
|
||||||
|
if (bossDetailLoading) return;
|
||||||
|
if (bossDetailScopeAll && bossDetailAllRecords.length >= bossDetailTotalCount) {
|
||||||
|
window.removeEventListener('scroll', bossDetailScrollHandler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const docHeight = document.documentElement.scrollHeight;
|
||||||
|
if (scrollY + windowHeight >= docHeight - 200) {
|
||||||
|
bossDetailPage++;
|
||||||
|
loadBossDetail(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', bossDetailScrollHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPartnerRankingPreview() {
|
async function loadPartnerRankingPreview() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const rows = await res.json();
|
const rows = await res.json();
|
||||||
const container = document.getElementById('partnerRankingPreviewList');
|
renderPartnerRankingPreview(rows);
|
||||||
if (rows.length === 0) {
|
|
||||||
container.innerHTML = '<div class="ranking-empty">暂无数据</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const max = Math.max(...rows.map(r => r.dispatch_count), 1);
|
|
||||||
container.innerHTML = rows.map((r, i) => {
|
|
||||||
const pct = Math.max((r.dispatch_count / max) * 100, 2);
|
|
||||||
return `<div class="hbar-row" onclick="openPartnerDetail('${r.partner.replace(/'/g, "\\'")}', 'homePage')">
|
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
|
||||||
<div class="hbar-label" title="${r.partner}">${r.partner}</div>
|
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
|
||||||
<div class="hbar-value">${r.dispatch_count}单</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPartnerRankingPreview(rows) {
|
||||||
|
renderHBarRanking(rows, {
|
||||||
|
containerId: 'partnerRankingPreviewList',
|
||||||
|
getValue: r => r.dispatch_count,
|
||||||
|
getLabel: r => r.partner,
|
||||||
|
onClick: 'openPartnerDetail',
|
||||||
|
formatValue: v => v + '单'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function openPartnerRankingPage() {
|
function openPartnerRankingPage() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const y = now.getFullYear();
|
const y = now.getFullYear();
|
||||||
@@ -366,20 +556,15 @@ async function loadPartnerRankingDetail() {
|
|||||||
params.push('endDate=' + formatLocalDate(d));
|
params.push('endDate=' + formatLocalDate(d));
|
||||||
}
|
}
|
||||||
if (params.length) url += '?' + params.join('&');
|
if (params.length) url += '?' + params.join('&');
|
||||||
console.log('partner-ranking url:', url);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: headers() });
|
const res = await apiFetch(url);
|
||||||
console.log('partner-ranking res.ok:', res.ok);
|
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
console.log('partner-ranking data:', data);
|
|
||||||
const rows = data.rows || [];
|
const rows = data.rows || [];
|
||||||
console.log('rows:', rows);
|
|
||||||
const summary = data.summary || { total_partner_income: 0, total_rebate_income: 0, total_milkTea_income: 0 };
|
const summary = data.summary || { total_partner_income: 0, total_rebate_income: 0, total_milkTea_income: 0 };
|
||||||
|
|
||||||
// 计算派单总数(从列表)
|
// 计算派单总数(从列表)
|
||||||
const totalDispatch = rows.reduce((sum, r) => sum + (r.dispatch_count || 0), 0);
|
const totalDispatch = rows.reduce((sum, r) => sum + (r.dispatch_count || 0), 0);
|
||||||
console.log('totalDispatch:', totalDispatch);
|
|
||||||
|
|
||||||
// 更新统计卡片
|
// 更新统计卡片
|
||||||
document.getElementById('partnerTotalDispatchCount').textContent = totalDispatch;
|
document.getElementById('partnerTotalDispatchCount').textContent = totalDispatch;
|
||||||
@@ -395,9 +580,10 @@ async function loadPartnerRankingDetail() {
|
|||||||
const max = Math.max(...rows.map(r => r.dispatch_count), 1);
|
const max = Math.max(...rows.map(r => r.dispatch_count), 1);
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max((r.dispatch_count / max) * 100, 2);
|
const pct = Math.max((r.dispatch_count / max) * 100, 2);
|
||||||
return `<div class="hbar-row" onclick="openPartnerDetail('${r.partner.replace(/'/g, "\\'")}', 'partnerRankingPage')">
|
const partner = escapeHtml(r.partner);
|
||||||
|
return `<div class="hbar-row" onclick="openPartnerDetail(${jsString(r.partner)}, 'partnerRankingPage')">
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.partner}">${r.partner}</div>
|
<div class="hbar-label" title="${escapeAttr(r.partner)}">${partner}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.dispatch_count}单</div>
|
<div class="hbar-value">${r.dispatch_count}单</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -407,27 +593,101 @@ async function loadPartnerRankingDetail() {
|
|||||||
|
|
||||||
let currentPartnerName = '';
|
let currentPartnerName = '';
|
||||||
let partnerDetailFrom = 'homePage';
|
let partnerDetailFrom = 'homePage';
|
||||||
|
let partnerDetailYear = 0;
|
||||||
|
let partnerDetailMonth = 0;
|
||||||
|
let partnerDetailScopeAll = false;
|
||||||
|
let partnerDetailPage = 1;
|
||||||
|
let partnerDetailLimit = 20;
|
||||||
|
let partnerDetailTotalCount = 0;
|
||||||
|
let partnerDetailAllRecords = [];
|
||||||
|
let partnerDetailLoading = false;
|
||||||
|
let partnerDetailScrollHandler = null;
|
||||||
|
|
||||||
function openPartnerDetail(partner, from) {
|
function openPartnerDetail(partner, from) {
|
||||||
currentPartnerName = partner;
|
currentPartnerName = partner;
|
||||||
partnerDetailFrom = from || 'homePage';
|
partnerDetailFrom = from || 'homePage';
|
||||||
|
partnerDetailYear = currentYear;
|
||||||
|
partnerDetailMonth = currentMonth;
|
||||||
|
// 从排行榜进入时默认显示全部
|
||||||
|
partnerDetailScopeAll = (from === 'dispatchRankingPage' || from === 'partnerRankingPage');
|
||||||
|
partnerDetailPage = 1;
|
||||||
|
partnerDetailLimit = partnerDetailScopeAll ? 500 : 20;
|
||||||
|
partnerDetailTotalCount = 0;
|
||||||
|
partnerDetailAllRecords = [];
|
||||||
|
updatePartnerDetailNav();
|
||||||
loadPartnerDetail();
|
loadPartnerDetail();
|
||||||
}
|
}
|
||||||
|
|
||||||
function partnerDetailGoBack() {
|
function partnerDetailGoBack() {
|
||||||
|
if (partnerDetailScrollHandler) {
|
||||||
|
window.removeEventListener('scroll', partnerDetailScrollHandler);
|
||||||
|
partnerDetailScrollHandler = null;
|
||||||
|
}
|
||||||
showPage(partnerDetailFrom);
|
showPage(partnerDetailFrom);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPartnerDetail() {
|
function updatePartnerDetailNav() {
|
||||||
|
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('partnerDetailTitle').textContent = currentPartnerName + ' 交易记录';
|
||||||
document.getElementById('partnerDetailSummary').innerHTML = '<div class="ranking-empty">加载中...</div>';
|
document.getElementById('partnerDetailSummary').innerHTML = '<div class="ranking-empty">加载中...</div>';
|
||||||
document.getElementById('partnerDetailList').innerHTML = '';
|
document.getElementById('partnerDetailList').innerHTML = '';
|
||||||
showPage('partnerDetailPage');
|
showPage('partnerDetailPage');
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = API + '/api/stats/partner-records?partner=' + encodeURIComponent(currentPartnerName) + '&page=' + partnerDetailPage + '&limit=' + partnerDetailLimit;
|
||||||
|
if (!partnerDetailScopeAll) {
|
||||||
|
url += '&year=' + partnerDetailYear + '&month=' + partnerDetailMonth;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/partner-records?partner=' + encodeURIComponent(currentPartnerName), { headers: headers() });
|
const res = await apiFetch(url);
|
||||||
if (!res.ok) return;
|
if (!res.ok) { partnerDetailLoading = false; return; }
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!append) {
|
||||||
document.getElementById('partnerDetailSummary').innerHTML = `
|
document.getElementById('partnerDetailSummary').innerHTML = `
|
||||||
<div style="display:flex;justify-content:space-around;flex-wrap:wrap;gap:8px">
|
<div style="display:flex;justify-content:space-around;flex-wrap:wrap;gap:8px">
|
||||||
<div style="text-align:center">
|
<div style="text-align:center">
|
||||||
@@ -448,13 +708,25 @@ async function loadPartnerDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
partnerDetailAllRecords = data.records || [];
|
||||||
|
setupPartnerDetailScroll();
|
||||||
|
} else {
|
||||||
|
partnerDetailAllRecords = partnerDetailAllRecords.concat(data.records || []);
|
||||||
|
}
|
||||||
|
partnerDetailTotalCount = data.totalCount || 0;
|
||||||
|
|
||||||
const container = document.getElementById('partnerDetailList');
|
const container = document.getElementById('partnerDetailList');
|
||||||
if (data.records.length === 0) {
|
const moreBtn = document.getElementById('partnerDetailMore');
|
||||||
|
|
||||||
|
if (partnerDetailAllRecords.length === 0) {
|
||||||
container.innerHTML = '<div class="ranking-empty">暂无交易记录</div>';
|
container.innerHTML = '<div class="ranking-empty">暂无交易记录</div>';
|
||||||
|
if (moreBtn) moreBtn.style.display = 'none';
|
||||||
|
partnerDetailLoading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouped = {};
|
const grouped = {};
|
||||||
data.records.forEach(r => {
|
partnerDetailAllRecords.forEach(r => {
|
||||||
const d = new Date(r.created_at);
|
const d = new Date(r.created_at);
|
||||||
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
|
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
|
||||||
if (!grouped[key]) grouped[key] = { records: [], total: 0 };
|
if (!grouped[key]) grouped[key] = { records: [], total: 0 };
|
||||||
@@ -466,20 +738,48 @@ async function loadPartnerDetail() {
|
|||||||
html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`;
|
html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`;
|
||||||
group.records.forEach(r => {
|
group.records.forEach(r => {
|
||||||
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
|
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
|
||||||
const displayCategory = r.category === '红包收入' ? '红包' : r.category;
|
html += renderRecordItem(r, {
|
||||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
time,
|
||||||
<div class="order-item-top">
|
suffix: r.boss ? ' · ' + r.boss : '',
|
||||||
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span>
|
line: recordLineText(r, 'source')
|
||||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
});
|
||||||
</div>
|
|
||||||
<div class="order-item-bottom">
|
|
||||||
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + ')' : (r.unit_price || 0).toFixed(2)) : (r.source || '')}</span>
|
|
||||||
<span>${time}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
});
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
|
if (moreBtn) {
|
||||||
|
if (partnerDetailAllRecords.length < partnerDetailTotalCount) {
|
||||||
|
moreBtn.style.display = 'block';
|
||||||
|
moreBtn.textContent = '加载中... (' + partnerDetailAllRecords.length + '/' + partnerDetailTotalCount + ')';
|
||||||
|
} else {
|
||||||
|
moreBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
partnerDetailLoading = false;
|
||||||
|
} catch (e) {
|
||||||
|
partnerDetailLoading = false;
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupPartnerDetailScroll() {
|
||||||
|
if (partnerDetailScrollHandler) {
|
||||||
|
window.removeEventListener('scroll', partnerDetailScrollHandler);
|
||||||
|
}
|
||||||
|
partnerDetailScrollHandler = function() {
|
||||||
|
if (partnerDetailLoading) return;
|
||||||
|
if (partnerDetailScopeAll && partnerDetailAllRecords.length >= partnerDetailTotalCount) {
|
||||||
|
window.removeEventListener('scroll', partnerDetailScrollHandler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const docHeight = document.documentElement.scrollHeight;
|
||||||
|
if (scrollY + windowHeight >= docHeight - 200) {
|
||||||
|
partnerDetailPage++;
|
||||||
|
loadPartnerDetail(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', partnerDetailScrollHandler);
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-5
@@ -1,7 +1,11 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const compression = require('compression');
|
||||||
|
|
||||||
|
const config = require('./config');
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
const { clearUserCache } = require('./utils/cache');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
const { router: authRouter, auth } = require('./routes/auth');
|
const { router: authRouter, auth } = require('./routes/auth');
|
||||||
const statsRouter = require('./routes/stats');
|
const statsRouter = require('./routes/stats');
|
||||||
@@ -10,11 +14,42 @@ const ioRouter = require('./routes/io');
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors());
|
app.set('trust proxy', config.trustProxy);
|
||||||
|
|
||||||
|
// 请求日志中间件
|
||||||
|
app.use(logger.requestLogger);
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin(origin, callback) {
|
||||||
|
if (!origin || config.corsOrigins.includes(origin)) {
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
return callback(null, false);
|
||||||
|
}
|
||||||
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
app.use(compression());
|
||||||
|
app.use(express.static(path.join(__dirname, '..', 'public'), {
|
||||||
|
maxAge: '1h',
|
||||||
|
etag: true
|
||||||
|
}));
|
||||||
|
|
||||||
// 联系人自动补全
|
// 联系人自动补全
|
||||||
|
app.use('/api', (req, res, next) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 全局缓存清除中间件:在写操作完成后清除用户缓存
|
||||||
|
app.use('/api', (req, res, next) => {
|
||||||
|
res.once('finish', () => {
|
||||||
|
if (['POST', 'DELETE', 'PUT', 'PATCH'].includes(req.method) && req.user?.id) {
|
||||||
|
clearUserCache(req.user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/contacts', auth, (req, res) => {
|
app.get('/api/contacts', auth, (req, res) => {
|
||||||
const field = req.query.field;
|
const field = req.query.field;
|
||||||
const allowed = ['boss', 'partner', 'source', 'destination'];
|
const allowed = ['boss', 'partner', 'source', 'destination'];
|
||||||
@@ -34,7 +69,34 @@ app.use('/api/stats', auth, statsRouter);
|
|||||||
app.use('/api/records', auth, recordsRouter);
|
app.use('/api/records', auth, recordsRouter);
|
||||||
app.use('/api', auth, ioRouter);
|
app.use('/api', auth, ioRouter);
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
// 404 处理
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.use((req, res) => {
|
||||||
console.log(`服务器运行在 http://0.0.0.0:${PORT}`);
|
res.status(404).json({ error: '接口不存在' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 全局错误处理中间件
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
// 记录错误日志
|
||||||
|
logger.error('Unhandled error:', err.message, err.stack);
|
||||||
|
|
||||||
|
// 处理特定错误类型
|
||||||
|
if (err.name === 'UnauthorizedError') {
|
||||||
|
return res.status(401).json({ error: '未授权访问' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.name === 'SyntaxError' && err.status === 400 && 'body' in err) {
|
||||||
|
return res.status(400).json({ error: 'JSON 格式错误' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认错误响应
|
||||||
|
const status = err.status || err.statusCode || 500;
|
||||||
|
const message = err.expose ? err.message : '服务器内部错误';
|
||||||
|
|
||||||
|
res.status(status).json({ error: message });
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = config.port;
|
||||||
|
const HOST = config.host;
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
logger.info(`服务器运行在 http://${HOST}:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']
|
||||||
|
};
|
||||||
+61
-15
@@ -1,14 +1,18 @@
|
|||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
|
||||||
const db = new Database(path.join(__dirname, '..', 'data.db'));
|
const db = new Database(path.join(__dirname, '..', 'data.db'));
|
||||||
|
|
||||||
|
// 启用 WAL 模式和外键约束
|
||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
// 创建表
|
/**
|
||||||
db.exec(`
|
* 创建表结构
|
||||||
|
*/
|
||||||
|
function createTables() {
|
||||||
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
@@ -32,12 +36,29 @@ db.exec(`
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
);
|
);
|
||||||
`);
|
|
||||||
|
|
||||||
// 迁移:去掉 category 的 CHECK 约束
|
CREATE INDEX IF NOT EXISTS idx_records_user_id ON records(user_id);
|
||||||
try {
|
CREATE INDEX IF NOT EXISTS idx_records_category ON records(category);
|
||||||
|
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();
|
const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get();
|
||||||
if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) {
|
if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) {
|
||||||
|
logger.info('执行迁移:移除 category CHECK 约束');
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE records_new (
|
CREATE TABLE records_new (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -59,26 +80,51 @@ try {
|
|||||||
DROP TABLE records;
|
DROP TABLE records;
|
||||||
ALTER TABLE records_new RENAME TO records;
|
ALTER TABLE records_new RENAME TO records;
|
||||||
`);
|
`);
|
||||||
|
logger.info('迁移完成:category CHECK 约束已移除');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('迁移跳过 (category):', e.message);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.log('Migration skipped:', e.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 迁移:添加 rebate 列(派单返点)
|
/**
|
||||||
try {
|
* 迁移:添加 rebate 列
|
||||||
|
*/
|
||||||
|
function migrateAddRebateColumn() {
|
||||||
|
try {
|
||||||
const cols = db.prepare("PRAGMA table_info(records)").all();
|
const cols = db.prepare("PRAGMA table_info(records)").all();
|
||||||
if (!cols.find(c => c.name === 'rebate')) {
|
if (!cols.find(c => c.name === 'rebate')) {
|
||||||
|
logger.info('执行迁移:添加 rebate 列');
|
||||||
db.exec("ALTER TABLE records ADD COLUMN rebate REAL");
|
db.exec("ALTER TABLE records ADD COLUMN rebate REAL");
|
||||||
|
logger.info('迁移完成:rebate 列已添加');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
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) {
|
*/
|
||||||
|
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);
|
const hash = bcrypt.hashSync('lizhilun', 10);
|
||||||
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash);
|
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash);
|
||||||
|
logger.info('开发默认用户 lizhilun 已创建');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 执行初始化
|
||||||
|
createTables();
|
||||||
|
migrateRemoveCategoryCheck();
|
||||||
|
migrateAddRebateColumn();
|
||||||
|
initDefaultUser();
|
||||||
|
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
|
|||||||
+92
-3
@@ -2,16 +2,54 @@ const express = require('express');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
|
const config = require('../config');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const SECRET = 'gamer-order-secret-2024';
|
|
||||||
|
const loginAttempts = new Map();
|
||||||
|
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const LOGIN_MAX_FAILURES = 5;
|
||||||
|
|
||||||
|
function normalizeLoginKey(req, username) {
|
||||||
|
return `${req.ip || req.socket.remoteAddress || 'unknown'}:${String(username || '').trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoginAttempt(key) {
|
||||||
|
const now = Date.now();
|
||||||
|
const attempt = loginAttempts.get(key);
|
||||||
|
if (!attempt || attempt.resetAt <= now) {
|
||||||
|
const fresh = { failures: 0, resetAt: now + LOGIN_WINDOW_MS };
|
||||||
|
loginAttempts.set(key, fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearExpiredLoginAttempts() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, attempt] of loginAttempts) {
|
||||||
|
if (attempt.resetAt <= now) loginAttempts.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailedLogin(key) {
|
||||||
|
const attempt = getLoginAttempt(key);
|
||||||
|
attempt.failures += 1;
|
||||||
|
if (loginAttempts.size > 1000) clearExpiredLoginAttempts();
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLoginAttempts(key) {
|
||||||
|
loginAttempts.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
// JWT 中间件
|
// JWT 中间件
|
||||||
function auth(req, res, next) {
|
function auth(req, res, next) {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
if (!token) return res.status(401).json({ error: '未登录' });
|
if (!token) return res.status(401).json({ error: '未登录' });
|
||||||
try {
|
try {
|
||||||
req.user = jwt.verify(token, SECRET);
|
req.user = jwt.verify(token, config.jwtSecret);
|
||||||
next();
|
next();
|
||||||
} catch {
|
} catch {
|
||||||
res.status(401).json({ error: '登录已过期' });
|
res.status(401).json({ error: '登录已过期' });
|
||||||
@@ -21,12 +59,63 @@ function auth(req, res, next) {
|
|||||||
// 登录
|
// 登录
|
||||||
router.post('/login', (req, res) => {
|
router.post('/login', (req, res) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: '用户名和密码不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginKey = normalizeLoginKey(req, username);
|
||||||
|
const attempt = getLoginAttempt(loginKey);
|
||||||
|
if (attempt.failures >= LOGIN_MAX_FAILURES) {
|
||||||
|
const retryAfter = Math.ceil((attempt.resetAt - Date.now()) / 1000);
|
||||||
|
res.setHeader('Retry-After', String(Math.max(retryAfter, 1)));
|
||||||
|
return res.status(429).json({ error: '登录失败次数过多,请稍后再试' });
|
||||||
|
}
|
||||||
|
|
||||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||||
if (!user || !bcrypt.compareSync(password, user.password)) {
|
if (!user || !bcrypt.compareSync(password, user.password)) {
|
||||||
|
recordFailedLogin(loginKey);
|
||||||
|
logger.warn(`登录失败: ${username}`);
|
||||||
return res.status(401).json({ error: '用户名或密码错误' });
|
return res.status(401).json({ error: '用户名或密码错误' });
|
||||||
}
|
}
|
||||||
const token = jwt.sign({ id: user.id, username: user.username }, SECRET, { expiresIn: '7d' });
|
|
||||||
|
resetLoginAttempts(loginKey);
|
||||||
|
const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
|
||||||
|
logger.info(`用户登录: ${username}`);
|
||||||
res.json({ token, username: user.username });
|
res.json({ token, username: user.username });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
router.post('/register', (req, res) => {
|
||||||
|
if (!config.enableRegistration) {
|
||||||
|
return res.status(403).json({ error: '注册已关闭' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: '用户名和密码不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < 2 || username.length > 20) {
|
||||||
|
return res.status(400).json({ error: '用户名长度需在2-20个字符之间' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return res.status(400).json({ error: '密码长度不能少于6个字符' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||||
|
if (existing) {
|
||||||
|
return res.status(409).json({ error: '用户名已存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = bcrypt.hashSync(password, 10);
|
||||||
|
const result = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run(username, hash);
|
||||||
|
logger.info(`新用户注册: ${username}`);
|
||||||
|
|
||||||
|
const token = jwt.sign({ id: result.lastInsertRowid, username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
|
||||||
|
res.json({ token, username });
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = { router, auth };
|
module.exports = { router, auth };
|
||||||
|
|||||||
+129
-44
@@ -1,14 +1,49 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const { parseCSVLine } = require('../utils/helpers');
|
const config = require('../config');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const {
|
||||||
|
parseCSVLine,
|
||||||
|
AppError,
|
||||||
|
validateNumber,
|
||||||
|
validateInList,
|
||||||
|
validateIsoDate,
|
||||||
|
optionalString
|
||||||
|
} = require('../utils/helpers');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
function emptyToNull(value) {
|
||||||
|
return value === undefined || value === null || value === '' ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableDbValue(value) {
|
||||||
|
return value === undefined || value === null || value === '' ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvTextFields = new Set(['type', 'category', 'boss', 'partner', 'source', 'destination', 'note', 'created_at']);
|
||||||
|
|
||||||
|
function escapeCsvFormula(value) {
|
||||||
|
return /^[\t\r=+\-@]/.test(value) || /^[ \t]+[=+\-@]/.test(value)
|
||||||
|
? `'${value}`
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCsvCell(value, col) {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
const str = csvTextFields.has(col) ? escapeCsvFormula(String(value)) : String(value);
|
||||||
|
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||||
|
return '"' + str.replace(/"/g, '""') + '"';
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
// 导出数据
|
// 导出数据
|
||||||
router.get('/export', (req, res) => {
|
router.get('/export', (req, res, next) => {
|
||||||
|
try {
|
||||||
const format = req.query.format;
|
const format = req.query.format;
|
||||||
if (!format || !['json', 'csv'].includes(format)) {
|
if (!format || !['json', 'csv'].includes(format)) {
|
||||||
return res.status(400).json({ error: '请指定格式: json 或 csv' });
|
throw new AppError('请指定格式: json 或 csv', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = db.prepare(
|
const records = db.prepare(
|
||||||
@@ -26,6 +61,7 @@ router.get('/export', (req, res) => {
|
|||||||
recordCount: records.length,
|
recordCount: records.length,
|
||||||
records
|
records
|
||||||
};
|
};
|
||||||
|
logger.info(`用户 ${req.user.username} 导出 JSON 数据: ${records.length} 条`);
|
||||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
|
||||||
return res.send(JSON.stringify(exportData, null, 2));
|
return res.send(JSON.stringify(exportData, null, 2));
|
||||||
@@ -35,27 +71,23 @@ router.get('/export', (req, res) => {
|
|||||||
const BOM = '\uFEFF';
|
const BOM = '\uFEFF';
|
||||||
const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at'];
|
const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at'];
|
||||||
const csvRows = records.map(r => {
|
const csvRows = records.map(r => {
|
||||||
return cols.map(col => {
|
return cols.map(col => encodeCsvCell(r[col], col)).join(',');
|
||||||
const val = r[col];
|
|
||||||
if (val === null || val === undefined) return '';
|
|
||||||
const str = String(val);
|
|
||||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
|
||||||
return '"' + str.replace(/"/g, '""') + '"';
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
}).join(',');
|
|
||||||
});
|
});
|
||||||
|
logger.info(`用户 ${req.user.username} 导出 CSV 数据: ${records.length} 条`);
|
||||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
|
||||||
res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n'));
|
res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n'));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 导入数据
|
// 导入数据
|
||||||
router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res) => {
|
router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const raw = req.body;
|
const raw = req.body;
|
||||||
if (!raw || typeof raw !== 'string') {
|
if (!raw || typeof raw !== 'string') {
|
||||||
return res.status(400).json({ error: '未收到文件内容' });
|
throw new AppError('未收到文件内容', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
let records = [];
|
let records = [];
|
||||||
@@ -66,45 +98,79 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res)
|
|||||||
if (trimmed.startsWith('{')) {
|
if (trimmed.startsWith('{')) {
|
||||||
const data = JSON.parse(trimmed);
|
const data = JSON.parse(trimmed);
|
||||||
if (!data.exportVersion || !Array.isArray(data.records)) {
|
if (!data.exportVersion || !Array.isArray(data.records)) {
|
||||||
return res.status(400).json({ error: '无效的JSON导出文件格式' });
|
throw new AppError('无效的JSON导出文件格式', 400);
|
||||||
}
|
}
|
||||||
records = data.records;
|
records = data.records;
|
||||||
} else {
|
} else {
|
||||||
const lines = trimmed.split('\n').map(l => l.replace(/\r$/, ''));
|
const lines = trimmed.split('\n').map(l => l.replace(/\r$/, ''));
|
||||||
if (lines.length < 2) {
|
if (lines.length < 2) {
|
||||||
return res.status(400).json({ error: 'CSV文件为空或格式不正确' });
|
throw new AppError('CSV文件为空或格式不正确', 400);
|
||||||
}
|
}
|
||||||
if (lines[0] !== allFields.join(',')) {
|
if (lines[0] !== allFields.join(',')) {
|
||||||
return res.status(400).json({ error: 'CSV列头不匹配,请使用本系统导出的文件' });
|
throw new AppError('CSV列头不匹配,请使用本系统导出的文件', 400);
|
||||||
}
|
}
|
||||||
const csvHeaders = lines[0].split(',');
|
const csvHeaders = lines[0].split(',');
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
if (!lines[i].trim()) continue;
|
if (!lines[i].trim()) continue;
|
||||||
const values = parseCSVLine(lines[i]);
|
const values = parseCSVLine(lines[i]);
|
||||||
const record = {};
|
const record = {};
|
||||||
csvHeaders.forEach((h, idx) => { record[h] = values[idx] || null; });
|
csvHeaders.forEach((h, idx) => { record[h] = emptyToNull(values[idx]); });
|
||||||
if (record.amount) record.amount = parseFloat(record.amount);
|
if (record.amount !== null) record.amount = parseFloat(record.amount);
|
||||||
if (record.quantity) record.quantity = parseInt(record.quantity) || null;
|
if (record.quantity !== null) record.quantity = parseInt(record.quantity, 10);
|
||||||
if (record.unit_price) record.unit_price = parseFloat(record.unit_price) || null;
|
if (record.unit_price !== null) record.unit_price = parseFloat(record.unit_price);
|
||||||
if (record.rebate) record.rebate = parseFloat(record.rebate) || null;
|
if (record.rebate !== null) record.rebate = parseFloat(record.rebate);
|
||||||
records.push(record);
|
records.push(record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const r of records) {
|
if (records.length > config.import.maxRecords) {
|
||||||
for (const f of requiredFields) {
|
throw new AppError(`单次最多导入 ${config.import.maxRecords} 条记录`, 400);
|
||||||
if (!r[f] && r[f] !== 0) {
|
|
||||||
return res.status(400).json({ error: `记录缺少必填字段: ${f}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!['income', 'expense'].includes(r.type)) {
|
|
||||||
return res.status(400).json({ error: `无效的类型: ${r.type}` });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingCheck = db.prepare(
|
const allCategories = [...config.categories.income, ...config.categories.expense];
|
||||||
'SELECT id FROM records WHERE user_id = ? AND created_at = ? AND category = ? AND amount = ?'
|
const normalizedRecords = records.map((r, index) => {
|
||||||
);
|
for (const f of requiredFields) {
|
||||||
|
if (!r[f] && r[f] !== 0) {
|
||||||
|
throw new AppError(`第 ${index + 1} 条记录缺少必填字段: ${f}`, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateInList(r.type, 'type', ['income', 'expense']);
|
||||||
|
validateInList(r.category, 'category', allCategories);
|
||||||
|
const amount = r.category === '派单'
|
||||||
|
? validateNumber(r.amount, '金额')
|
||||||
|
: validateNumber(r.amount, '金额', 0);
|
||||||
|
return {
|
||||||
|
type: r.type,
|
||||||
|
category: r.category,
|
||||||
|
amount,
|
||||||
|
quantity: r.quantity === undefined || r.quantity === null || r.quantity === '' ? null : validateNumber(r.quantity, '数量', 0),
|
||||||
|
unit_price: r.unit_price === undefined || r.unit_price === null || r.unit_price === '' ? null : validateNumber(r.unit_price, '单价', 0),
|
||||||
|
rebate: r.rebate === undefined || r.rebate === null || r.rebate === '' ? null : validateNumber(r.rebate, '返点'),
|
||||||
|
boss: optionalString(r.boss, '老板', 100),
|
||||||
|
partner: optionalString(r.partner, '陪玩', 100),
|
||||||
|
source: optionalString(r.source, '来源', 100),
|
||||||
|
destination: optionalString(r.destination, '去向', 100),
|
||||||
|
note: optionalString(r.note, '备注', 500),
|
||||||
|
created_at: validateIsoDate(r.created_at, '创建时间')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingCheck = db.prepare(`
|
||||||
|
SELECT id FROM records
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND type = ?
|
||||||
|
AND category = ?
|
||||||
|
AND amount = ?
|
||||||
|
AND COALESCE(quantity, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(unit_price, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(rebate, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(boss, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(partner, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(source, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(destination, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(note, '') = COALESCE(?, '')
|
||||||
|
AND created_at = ?
|
||||||
|
`);
|
||||||
const insertStmt = db.prepare(`
|
const insertStmt = db.prepare(`
|
||||||
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -113,23 +179,42 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res)
|
|||||||
let imported = 0, skipped = 0;
|
let imported = 0, skipped = 0;
|
||||||
const insertMany = db.transaction((recs) => {
|
const insertMany = db.transaction((recs) => {
|
||||||
for (const r of recs) {
|
for (const r of recs) {
|
||||||
if (existingCheck.get(req.user.id, r.created_at, r.category, r.amount)) {
|
const values = [
|
||||||
|
req.user.id,
|
||||||
|
r.type,
|
||||||
|
r.category,
|
||||||
|
r.amount,
|
||||||
|
r.quantity,
|
||||||
|
r.unit_price,
|
||||||
|
r.rebate,
|
||||||
|
r.boss,
|
||||||
|
r.partner,
|
||||||
|
r.source,
|
||||||
|
r.destination,
|
||||||
|
r.note,
|
||||||
|
r.created_at
|
||||||
|
];
|
||||||
|
if (existingCheck.get(...values)) {
|
||||||
skipped++; continue;
|
skipped++; continue;
|
||||||
}
|
}
|
||||||
insertStmt.run(req.user.id, r.type, r.category, r.amount,
|
insertStmt.run(req.user.id, r.type, r.category, r.amount,
|
||||||
r.quantity || null, r.unit_price || null, r.rebate || null,
|
nullableDbValue(r.quantity), nullableDbValue(r.unit_price), nullableDbValue(r.rebate),
|
||||||
r.boss || null, r.partner || null,
|
nullableDbValue(r.boss), nullableDbValue(r.partner),
|
||||||
r.source || null, r.destination || null,
|
nullableDbValue(r.source), nullableDbValue(r.destination),
|
||||||
r.note || null, r.created_at);
|
nullableDbValue(r.note), r.created_at);
|
||||||
imported++;
|
imported++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
insertMany(records);
|
insertMany(normalizedRecords);
|
||||||
|
|
||||||
res.json({ imported, skipped, total: records.length });
|
logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped} 条`);
|
||||||
} catch (e) {
|
res.json({ imported, skipped, total: normalizedRecords.length });
|
||||||
console.error('Import error:', e);
|
} catch (err) {
|
||||||
res.status(400).json({ error: '文件解析失败: ' + e.message });
|
if (err instanceof SyntaxError) {
|
||||||
|
next(new AppError('文件解析失败: ' + err.message, 400));
|
||||||
|
} else {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+178
-74
@@ -1,109 +1,162 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const { getMonthRange } = require('../utils/helpers');
|
const config = require('../config');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const {
|
||||||
|
getMonthRange,
|
||||||
|
AppError,
|
||||||
|
validateRequired,
|
||||||
|
validateNumber,
|
||||||
|
validateInList,
|
||||||
|
validatePage,
|
||||||
|
validateLimit,
|
||||||
|
validateIsoDate,
|
||||||
|
optionalString
|
||||||
|
} = require('../utils/helpers');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// 新增记录
|
// 新增记录
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res, next) => {
|
||||||
|
try {
|
||||||
const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body;
|
const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body;
|
||||||
|
|
||||||
|
// 输入验证
|
||||||
|
validateRequired(req.body, ['type', 'category', 'amount']);
|
||||||
|
validateInList(type, 'type', ['income', 'expense']);
|
||||||
|
|
||||||
|
const allCategories = [...config.categories.income, ...config.categories.expense];
|
||||||
|
validateInList(category, 'category', allCategories);
|
||||||
|
|
||||||
|
// 派单的返点可为负数,因此金额也允许为负数
|
||||||
|
const validatedAmount = category === '派单'
|
||||||
|
? validateNumber(amount, '金额')
|
||||||
|
: validateNumber(amount, '金额', 0);
|
||||||
|
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
|
||||||
|
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
|
||||||
|
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
|
||||||
|
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
const result = stmt.run(req.user.id, type, category, amount, quantity || null, unit_price || null, rebate || null, boss || null, partner || null, source || null, destination || null, note || null, created_at || new Date().toISOString());
|
const result = stmt.run(
|
||||||
|
req.user.id,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
validatedAmount,
|
||||||
|
validatedQuantity,
|
||||||
|
validatedUnitPrice,
|
||||||
|
validatedRebate,
|
||||||
|
optionalString(boss, '老板', 100),
|
||||||
|
optionalString(partner, '陪玩', 100),
|
||||||
|
optionalString(source, '来源', 100),
|
||||||
|
optionalString(destination, '去向', 100),
|
||||||
|
optionalString(note, '备注', 500),
|
||||||
|
validatedCreatedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`用户 ${req.user.username} 新增记录: ${category} ${validatedAmount}`);
|
||||||
res.json({ id: result.lastInsertRowid });
|
res.json({ id: result.lastInsertRowid });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 通用分页查询函数
|
||||||
|
function queryPagedRecords(userId, year, month, options) {
|
||||||
|
const page = validatePage(options.page, 1);
|
||||||
|
const limit = validateLimit(options.limit, config.pagination.defaultLimit, config.pagination.maxLimit);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const { startDate, endDate } = getMonthRange(year, month);
|
||||||
|
|
||||||
|
let whereClause = `user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`;
|
||||||
|
const params = [userId, startDate, endDate];
|
||||||
|
|
||||||
|
if (options.category) {
|
||||||
|
whereClause += ` AND category = ?`;
|
||||||
|
params.push(options.category);
|
||||||
|
}
|
||||||
|
if (options.type) {
|
||||||
|
whereClause += ` AND type = ?`;
|
||||||
|
params.push(options.type);
|
||||||
|
}
|
||||||
|
if (options.excludeCategories && options.excludeCategories.length > 0) {
|
||||||
|
whereClause += ` AND category NOT IN (${options.excludeCategories.map(() => '?').join(',')})`;
|
||||||
|
params.push(...options.excludeCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = db.prepare(
|
||||||
|
`SELECT COUNT(*) as count FROM records WHERE ${whereClause}`
|
||||||
|
).get(...params).count;
|
||||||
|
|
||||||
|
const records = db.prepare(
|
||||||
|
`SELECT * FROM records WHERE ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||||
|
).all(...params, limit, offset);
|
||||||
|
|
||||||
|
return { records, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
// 接单列表
|
// 接单列表
|
||||||
router.get('/orders', (req, res) => {
|
router.get('/orders', (req, res) => {
|
||||||
const { page = 1, limit = 20 } = req.query;
|
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
|
||||||
const offset = (Number(page) - 1) * Number(limit);
|
category: '接单',
|
||||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
page: req.query.page,
|
||||||
|
limit: req.query.limit
|
||||||
const total = db.prepare(
|
});
|
||||||
`SELECT COUNT(*) as count FROM records WHERE user_id = ? AND category = '接单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`
|
res.json(result);
|
||||||
).get(req.user.id, startDate, endDate).count;
|
|
||||||
|
|
||||||
const records = db.prepare(
|
|
||||||
`SELECT * FROM records WHERE user_id = ? AND category = '接单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
|
||||||
).all(req.user.id, startDate, endDate, Number(limit), offset);
|
|
||||||
|
|
||||||
res.json({ records, total, page: Number(page), limit: Number(limit) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 红包及其他收入列表
|
// 红包及其他收入列表
|
||||||
router.get('/other-income', (req, res) => {
|
router.get('/other-income', (req, res) => {
|
||||||
const { page = 1, limit = 20 } = req.query;
|
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
|
||||||
const offset = (Number(page) - 1) * Number(limit);
|
type: 'income',
|
||||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
excludeCategories: ['接单', '派单'],
|
||||||
|
page: req.query.page,
|
||||||
const total = db.prepare(
|
limit: req.query.limit
|
||||||
`SELECT COUNT(*) as count FROM records WHERE user_id = ? AND type = 'income' AND category != '接单' AND category != '派单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`
|
});
|
||||||
).get(req.user.id, startDate, endDate).count;
|
res.json(result);
|
||||||
|
|
||||||
const records = db.prepare(
|
|
||||||
`SELECT * FROM records WHERE user_id = ? AND type = 'income' AND category != '接单' AND category != '派单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
|
||||||
).all(req.user.id, startDate, endDate, Number(limit), offset);
|
|
||||||
|
|
||||||
res.json({ records, total, page: Number(page), limit: Number(limit) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 红包收入列表
|
// 红包收入列表
|
||||||
router.get('/red-envelope', (req, res) => {
|
router.get('/red-envelope', (req, res) => {
|
||||||
const { page = 1, limit = 20 } = req.query;
|
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
|
||||||
const offset = (Number(page) - 1) * Number(limit);
|
type: 'income',
|
||||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
category: '红包收入',
|
||||||
|
page: req.query.page,
|
||||||
const total = db.prepare(
|
limit: req.query.limit
|
||||||
`SELECT COUNT(*) as count FROM records WHERE user_id = ? AND type = 'income' AND category = '红包收入' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`
|
});
|
||||||
).get(req.user.id, startDate, endDate).count;
|
res.json(result);
|
||||||
|
|
||||||
const records = db.prepare(
|
|
||||||
`SELECT * FROM records WHERE user_id = ? AND type = 'income' AND category = '红包收入' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
|
||||||
).all(req.user.id, startDate, endDate, Number(limit), offset);
|
|
||||||
|
|
||||||
res.json({ records, total, page: Number(page), limit: Number(limit) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 奶茶收入列表
|
// 奶茶收入列表
|
||||||
router.get('/milk-tea', (req, res) => {
|
router.get('/milk-tea', (req, res) => {
|
||||||
const { page = 1, limit = 20 } = req.query;
|
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
|
||||||
const offset = (Number(page) - 1) * Number(limit);
|
type: 'income',
|
||||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
category: '奶茶',
|
||||||
|
page: req.query.page,
|
||||||
const total = db.prepare(
|
limit: req.query.limit
|
||||||
`SELECT COUNT(*) as count FROM records WHERE user_id = ? AND type = 'income' AND category = '奶茶' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`
|
});
|
||||||
).get(req.user.id, startDate, endDate).count;
|
res.json(result);
|
||||||
|
|
||||||
const records = db.prepare(
|
|
||||||
`SELECT * FROM records WHERE user_id = ? AND type = 'income' AND category = '奶茶' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
|
||||||
).all(req.user.id, startDate, endDate, Number(limit), offset);
|
|
||||||
|
|
||||||
res.json({ records, total, page: Number(page), limit: Number(limit) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 派单列表
|
// 派单列表
|
||||||
router.get('/dispatches', (req, res) => {
|
router.get('/dispatches', (req, res) => {
|
||||||
const { page = 1, limit = 20 } = req.query;
|
const result = queryPagedRecords(req.user.id, req.query.year, req.query.month, {
|
||||||
const offset = (Number(page) - 1) * Number(limit);
|
category: '派单',
|
||||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
page: req.query.page,
|
||||||
|
limit: req.query.limit
|
||||||
const total = db.prepare(
|
});
|
||||||
`SELECT COUNT(*) as count FROM records WHERE user_id = ? AND category = '派单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`
|
res.json(result);
|
||||||
).get(req.user.id, startDate, endDate).count;
|
|
||||||
|
|
||||||
const records = db.prepare(
|
|
||||||
`SELECT * FROM records WHERE user_id = ? AND category = '派单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
|
||||||
).all(req.user.id, startDate, endDate, Number(limit), offset);
|
|
||||||
|
|
||||||
res.json({ records, total, page: Number(page), limit: Number(limit) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取记录列表
|
// 获取记录列表
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const { type, page = 1, limit = 20 } = req.query;
|
const { type, page = 1, limit = 20 } = req.query;
|
||||||
const offset = (page - 1) * limit;
|
const validatedPage = validatePage(page, 1);
|
||||||
|
const validatedLimit = validateLimit(limit, config.pagination.defaultLimit, config.pagination.maxLimit);
|
||||||
|
const offset = (validatedPage - 1) * validatedLimit;
|
||||||
let sql = 'SELECT * FROM records WHERE user_id = ?';
|
let sql = 'SELECT * FROM records WHERE user_id = ?';
|
||||||
const params = [req.user.id];
|
const params = [req.user.id];
|
||||||
if (type) {
|
if (type) {
|
||||||
@@ -111,24 +164,75 @@ router.get('/', (req, res) => {
|
|||||||
params.push(type);
|
params.push(type);
|
||||||
}
|
}
|
||||||
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
||||||
params.push(Number(limit), Number(offset));
|
params.push(validatedLimit, offset);
|
||||||
res.json(db.prepare(sql).all(...params));
|
res.json(db.prepare(sql).all(...params));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新记录
|
// 更新记录
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res, next) => {
|
||||||
|
try {
|
||||||
const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body;
|
const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body;
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
|
||||||
|
if (!id || id <= 0) {
|
||||||
|
throw new AppError('无效的记录ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取原有记录以判断类别(派单允许负数金额)
|
||||||
|
const existing = db.prepare('SELECT category FROM records WHERE id = ? AND user_id = ?').get(id, req.user.id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new AppError('记录不存在', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedAmount = existing.category === '派单'
|
||||||
|
? validateNumber(amount, '金额')
|
||||||
|
: validateNumber(amount, '金额', 0);
|
||||||
|
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
|
||||||
|
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
|
||||||
|
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
|
||||||
|
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ?
|
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ?
|
||||||
WHERE id = ? AND user_id = ?
|
WHERE id = ? AND user_id = ?
|
||||||
`).run(quantity || null, unit_price || null, rebate || null, amount, boss || null, partner || null, source || null, destination || null, note || null, created_at || null, req.params.id, req.user.id);
|
`).run(
|
||||||
|
validatedQuantity,
|
||||||
|
validatedUnitPrice,
|
||||||
|
validatedRebate,
|
||||||
|
validatedAmount,
|
||||||
|
optionalString(boss, '老板', 100),
|
||||||
|
optionalString(partner, '陪玩', 100),
|
||||||
|
optionalString(source, '来源', 100),
|
||||||
|
optionalString(destination, '去向', 100),
|
||||||
|
optionalString(note, '备注', 500),
|
||||||
|
validatedCreatedAt,
|
||||||
|
id,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(`用户 ${req.user.username} 更新记录: ${id}`);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 删除记录
|
// 删除记录
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res, next) => {
|
||||||
db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
|
||||||
|
if (!id || id <= 0) {
|
||||||
|
throw new AppError('无效的记录ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?').run(id, req.user.id);
|
||||||
|
|
||||||
|
logger.info(`用户 ${req.user.username} 删除记录: ${id}`);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+200
-126
@@ -1,9 +1,14 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const { getMonthRange } = require('../utils/helpers');
|
const config = require('../config');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { getMonthRange, AppError, validateRequired, validateNumber, validateInList } = require('../utils/helpers');
|
||||||
|
const { getCached } = require('../utils/cache');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 缓存模块已移至 utils/cache.js
|
||||||
|
|
||||||
// 格式化本地日期为 YYYY-MM-DD
|
// 格式化本地日期为 YYYY-MM-DD
|
||||||
function formatLocalDate(date) {
|
function formatLocalDate(date) {
|
||||||
const y = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
@@ -13,8 +18,11 @@ function formatLocalDate(date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 本月统计
|
// 本月统计
|
||||||
router.get('/monthly', (req, res) => {
|
router.get('/monthly', (req, res, next) => {
|
||||||
|
try {
|
||||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||||
|
const cacheKey = `monthly:${req.user.id}:${startDate}:${endDate}`;
|
||||||
|
getCached(cacheKey, () => {
|
||||||
const stats = db.prepare(`
|
const stats = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_order_count,
|
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_order_count,
|
||||||
@@ -30,44 +38,45 @@ router.get('/monthly', (req, res) => {
|
|||||||
FROM records
|
FROM records
|
||||||
WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||||
`).get(req.user.id, startDate, endDate);
|
`).get(req.user.id, startDate, endDate);
|
||||||
res.json(stats);
|
return stats;
|
||||||
|
}).then(stats => res.json(stats)).catch(next);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 月收入/支出记录列表
|
// 月收入/支出记录列表
|
||||||
router.get('/monthly-records', (req, res) => {
|
router.get('/monthly-records', (req, res, next) => {
|
||||||
const { type } = req.query;
|
try {
|
||||||
if (!type || !['income', 'expense'].includes(type)) return res.status(400).json({ error: '缺少类型参数' });
|
const { type, page = 1, limit = 20 } = req.query;
|
||||||
|
if (!type || !['income', 'expense'].includes(type)) {
|
||||||
|
throw new AppError('缺少类型参数或类型无效', 400);
|
||||||
|
}
|
||||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||||
|
const validatedPage = Math.max(1, Number(page) || 1);
|
||||||
|
const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
|
||||||
|
const offset = (validatedPage - 1) * validatedLimit;
|
||||||
|
|
||||||
const summary = db.prepare(`
|
const stats = db.prepare(`
|
||||||
SELECT COALESCE(SUM(amount), 0) as total
|
SELECT COALESCE(SUM(amount), 0) as total, COUNT(*) as count
|
||||||
FROM records WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
FROM records WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||||
`).get(req.user.id, type, startDate, endDate);
|
`).get(req.user.id, type, startDate, endDate);
|
||||||
|
|
||||||
const records = db.prepare(`
|
const records = db.prepare(`
|
||||||
SELECT * FROM records
|
SELECT * FROM records
|
||||||
WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||||
`).all(req.user.id, type, startDate, endDate);
|
`).all(req.user.id, type, startDate, endDate, validatedLimit, offset);
|
||||||
|
|
||||||
res.json({ total: summary.total, records });
|
res.json({ total: stats.total, records, page: validatedPage, limit: validatedLimit, totalCount: stats.count });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 年度净收入按年统计
|
// 年度净收入按年统计
|
||||||
router.get('/yearly-net-income', (req, res) => {
|
router.get('/yearly-net-income', (req, res) => {
|
||||||
// 获取所有年份数据
|
const combined = db.prepare(`
|
||||||
const yearlyStats = db.prepare(`
|
|
||||||
SELECT
|
|
||||||
strftime('%Y', created_at) as year,
|
|
||||||
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
|
|
||||||
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
|
|
||||||
FROM records
|
|
||||||
WHERE user_id = ?
|
|
||||||
GROUP BY year ORDER BY year DESC
|
|
||||||
`).all(req.user.id);
|
|
||||||
|
|
||||||
// 获取所有年份的每月数据
|
|
||||||
const monthlyByYear = db.prepare(`
|
|
||||||
SELECT
|
SELECT
|
||||||
strftime('%Y', created_at) as year,
|
strftime('%Y', created_at) as year,
|
||||||
strftime('%m', created_at) as month,
|
strftime('%m', created_at) as month,
|
||||||
@@ -75,33 +84,51 @@ router.get('/yearly-net-income', (req, res) => {
|
|||||||
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
|
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
|
||||||
FROM records
|
FROM records
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
GROUP BY year, month ORDER BY year DESC, month
|
GROUP BY year, month
|
||||||
`).all(req.user.id);
|
UNION ALL
|
||||||
|
|
||||||
// 按年份分组每月数据
|
|
||||||
const monthlyData = {};
|
|
||||||
monthlyByYear.forEach(m => {
|
|
||||||
if (!monthlyData[m.year]) monthlyData[m.year] = [];
|
|
||||||
monthlyData[m.year].push({
|
|
||||||
month: m.month,
|
|
||||||
total_income: m.total_income,
|
|
||||||
total_expense: m.total_expense
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const grandTotal = db.prepare(`
|
|
||||||
SELECT
|
SELECT
|
||||||
|
strftime('%Y', created_at) as year,
|
||||||
|
'00' as month,
|
||||||
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
|
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
|
||||||
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
|
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
|
||||||
FROM records WHERE user_id = ?
|
FROM records
|
||||||
`).get(req.user.id);
|
WHERE user_id = ?
|
||||||
|
GROUP BY year
|
||||||
|
`).all(req.user.id, req.user.id);
|
||||||
|
|
||||||
|
const yearlyStats = [];
|
||||||
|
const monthlyData = {};
|
||||||
|
let grandTotal = { total_income: 0, total_expense: 0 };
|
||||||
|
|
||||||
|
combined.forEach(row => {
|
||||||
|
// grandTotal 只累加年度汇总数据(month='00'),避免重复计算月明细
|
||||||
|
if (row.month === '00') {
|
||||||
|
grandTotal.total_income += row.total_income;
|
||||||
|
grandTotal.total_expense += row.total_expense;
|
||||||
|
yearlyStats.push({
|
||||||
|
year: row.year,
|
||||||
|
total_income: row.total_income,
|
||||||
|
total_expense: row.total_expense
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!monthlyData[row.year]) monthlyData[row.year] = [];
|
||||||
|
monthlyData[row.year].push({
|
||||||
|
month: row.month,
|
||||||
|
total_income: row.total_income,
|
||||||
|
total_expense: row.total_expense
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
yearlyStats.sort((a, b) => b.year - a.year);
|
||||||
res.json({ yearlyStats, monthlyData, grandTotal });
|
res.json({ yearlyStats, monthlyData, grandTotal });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 陪玩派单榜预览
|
// 陪玩派单榜预览
|
||||||
router.get('/partner-ranking/preview', (req, res) => {
|
router.get('/partner-ranking/preview', (req, res) => {
|
||||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||||
|
const cacheKey = `partner-ranking-preview:${req.user.id}:${startDate}:${endDate}`;
|
||||||
|
getCached(cacheKey, () => {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income
|
SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income
|
||||||
FROM records
|
FROM records
|
||||||
@@ -109,7 +136,8 @@ router.get('/partner-ranking/preview', (req, res) => {
|
|||||||
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||||
GROUP BY partner ORDER BY dispatch_count DESC LIMIT 10
|
GROUP BY partner ORDER BY dispatch_count DESC LIMIT 10
|
||||||
`).all(req.user.id, startDate, endDate);
|
`).all(req.user.id, startDate, endDate);
|
||||||
res.json(rows);
|
return rows;
|
||||||
|
}).then(rows => res.json(rows));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 陪玩派单榜详情
|
// 陪玩派单榜详情
|
||||||
@@ -139,24 +167,23 @@ router.get('/partner-ranking', (req, res) => {
|
|||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0);
|
row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0);
|
||||||
});
|
});
|
||||||
const summary = db.prepare(`
|
const summaryStats = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(quantity), 0) as total_dispatch_count,
|
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as total_dispatch_count,
|
||||||
COALESCE(SUM(quantity * COALESCE(unit_price, 0)), 0) as total_dispatch_income,
|
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as total_dispatch_income,
|
||||||
COALESCE(SUM(amount), 0) as total_rebate_income,
|
COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as total_rebate_income,
|
||||||
COALESCE(SUM(quantity * COALESCE(unit_price, 0)), 0) - COALESCE(SUM(amount), 0) as total_partner_income
|
COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as total_milkTea_income
|
||||||
FROM records
|
FROM records
|
||||||
WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != ''
|
WHERE user_id = ? AND type = 'income' AND category IN ('派单', '奶茶')
|
||||||
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||||
`).get(req.user.id, start, end);
|
`).get(req.user.id, start, end);
|
||||||
// 获取奶茶收入
|
const summary = {
|
||||||
const milkTeaStats = db.prepare(`
|
total_dispatch_count: summaryStats.total_dispatch_count,
|
||||||
SELECT COALESCE(SUM(amount), 0) as total_milkTea_income
|
total_dispatch_income: summaryStats.total_dispatch_income,
|
||||||
FROM records
|
total_rebate_income: summaryStats.total_rebate_income,
|
||||||
WHERE user_id = ? AND type = 'income' AND category = '奶茶'
|
total_partner_income: summaryStats.total_dispatch_income - summaryStats.total_rebate_income,
|
||||||
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
total_milkTea_income: summaryStats.total_milkTea_income
|
||||||
`).get(req.user.id, start, end);
|
};
|
||||||
summary.total_milkTea_income = milkTeaStats.total_milkTea_income;
|
|
||||||
res.json({ rows, summary });
|
res.json({ rows, summary });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,22 +194,18 @@ router.get('/take-ranking', (req, res) => {
|
|||||||
FROM records WHERE user_id = ? AND category = '接单' AND boss IS NOT NULL AND boss != ''
|
FROM records WHERE user_id = ? AND category = '接单' AND boss IS NOT NULL AND boss != ''
|
||||||
GROUP BY boss ORDER BY count DESC
|
GROUP BY boss ORDER BY count DESC
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
// 获取接单总收入和红包收入
|
|
||||||
const stats = db.prepare(`
|
const stats = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(quantity), 0) as total_take_count,
|
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as total_take_count,
|
||||||
COALESCE(SUM(amount), 0) as total_take_income
|
COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as total_take_income,
|
||||||
FROM records WHERE user_id = ? AND category = '接单'
|
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as total_redEnvelope_income
|
||||||
`).get(req.user.id);
|
FROM records WHERE user_id = ? AND type = 'income' AND category IN ('接单', '红包收入')
|
||||||
const redEnvelopeStats = db.prepare(`
|
|
||||||
SELECT COALESCE(SUM(amount), 0) as total_redEnvelope_income
|
|
||||||
FROM records WHERE user_id = ? AND type = 'income' AND category = '红包收入'
|
|
||||||
`).get(req.user.id);
|
`).get(req.user.id);
|
||||||
const result = {
|
const result = {
|
||||||
rows: rows,
|
rows: rows,
|
||||||
total_take_count: stats.total_take_count,
|
total_take_count: stats.total_take_count,
|
||||||
total_take_income: stats.total_take_income,
|
total_take_income: stats.total_take_income,
|
||||||
total_redEnvelope_income: redEnvelopeStats.total_redEnvelope_income
|
total_redEnvelope_income: stats.total_redEnvelope_income
|
||||||
};
|
};
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
@@ -202,19 +225,16 @@ router.get('/dispatch-ranking', (req, res) => {
|
|||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0);
|
row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0);
|
||||||
});
|
});
|
||||||
// 获取派单总数和奶茶收入
|
const stats = db.prepare(`
|
||||||
const dispatchTotal = db.prepare(`
|
SELECT
|
||||||
SELECT COALESCE(SUM(quantity), 0) as total_dispatch_count
|
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as total_dispatch_count,
|
||||||
FROM records WHERE user_id = ? AND category = '派单'
|
COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as total_milkTea_income
|
||||||
`).get(req.user.id);
|
FROM records WHERE user_id = ? AND type = 'income' AND category IN ('派单', '奶茶')
|
||||||
const milkTeaStats = db.prepare(`
|
|
||||||
SELECT COALESCE(SUM(amount), 0) as total_milkTea_income
|
|
||||||
FROM records WHERE user_id = ? AND type = 'income' AND category = '奶茶'
|
|
||||||
`).get(req.user.id);
|
`).get(req.user.id);
|
||||||
const result = {
|
const result = {
|
||||||
rows: rows,
|
rows: rows,
|
||||||
total_dispatch_count: dispatchTotal.total_dispatch_count,
|
total_dispatch_count: stats.total_dispatch_count,
|
||||||
total_milkTea_income: milkTeaStats.total_milkTea_income
|
total_milkTea_income: stats.total_milkTea_income
|
||||||
};
|
};
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
@@ -234,72 +254,112 @@ router.get('/total', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 老板交易记录
|
// 老板交易记录
|
||||||
router.get('/boss-records', (req, res) => {
|
router.get('/boss-records', (req, res, next) => {
|
||||||
|
try {
|
||||||
const boss = req.query.boss;
|
const boss = req.query.boss;
|
||||||
if (!boss) return res.status(400).json({ error: '缺少老板参数' });
|
const { page = 1, limit = 20, year, month } = req.query;
|
||||||
|
if (!boss) throw new AppError('缺少老板参数', 400);
|
||||||
|
|
||||||
// 我的收入:只统计 type='income' 的记录(接单、派单返点、红包收入等)
|
const validatedPage = Math.max(1, Number(page) || 1);
|
||||||
// 使用 COALESCE(boss, source) 兼容旧数据(之前的红包收入只有 source 字段)
|
const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
|
||||||
const myIncomeSummary = db.prepare(`
|
const offset = (validatedPage - 1) * validatedLimit;
|
||||||
SELECT COALESCE(SUM(amount), 0) as total
|
|
||||||
FROM records WHERE user_id = ? AND COALESCE(boss, source) = ? AND type = 'income'
|
|
||||||
`).get(req.user.id, boss);
|
|
||||||
|
|
||||||
// 老板支出:派单支出 + 接单支出 + 红包收入(老板发的红包也算老板支出)
|
// 日期过滤条件
|
||||||
const expenseSummary = db.prepare(`
|
let dateFilter = '';
|
||||||
|
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 summary = db.prepare(`
|
||||||
SELECT
|
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 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 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 amount ELSE 0 END), 0) as redEnvelope_expense,
|
||||||
FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?
|
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
|
||||||
`).get(req.user.id, boss);
|
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
|
||||||
|
COUNT(*) as count
|
||||||
const boss_total_expense = expenseSummary.dispatch_expense + expenseSummary.order_expense + expenseSummary.redEnvelope_expense;
|
FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter}
|
||||||
|
`).get(req.user.id, boss, ...dateParams);
|
||||||
|
|
||||||
const records = db.prepare(`
|
const records = db.prepare(`
|
||||||
SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ? ORDER BY created_at DESC
|
SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||||
`).all(req.user.id, boss);
|
`).all(req.user.id, boss, ...dateParams, validatedLimit, offset);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
my_income: myIncomeSummary.total,
|
my_income: summary.my_income,
|
||||||
boss_total_expense: boss_total_expense,
|
boss_total_expense: summary.dispatch_expense + summary.order_expense + summary.redEnvelope_expense,
|
||||||
records
|
take_count: summary.take_count,
|
||||||
|
dispatch_count: summary.dispatch_count,
|
||||||
|
records,
|
||||||
|
page: validatedPage,
|
||||||
|
limit: validatedLimit,
|
||||||
|
totalCount: summary.count
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 陪玩交易记录
|
// 陪玩交易记录
|
||||||
router.get('/partner-records', (req, res) => {
|
router.get('/partner-records', (req, res, next) => {
|
||||||
|
try {
|
||||||
const partner = req.query.partner;
|
const partner = req.query.partner;
|
||||||
if (!partner) return res.status(400).json({ error: '缺少陪玩参数' });
|
const { page = 1, limit = 20, year, month } = req.query;
|
||||||
|
if (!partner) throw new AppError('缺少陪玩参数', 400);
|
||||||
|
|
||||||
const dispatchStats = db.prepare(`
|
const validatedPage = Math.max(1, Number(page) || 1);
|
||||||
|
const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
|
||||||
|
const offset = (validatedPage - 1) * validatedLimit;
|
||||||
|
|
||||||
|
// 日期过滤条件
|
||||||
|
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 summary = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(quantity), 0) as dispatch_count,
|
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 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(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(amount), 0) as rebate_income
|
COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as rebate_income,
|
||||||
FROM records WHERE user_id = ? AND category = '派单' AND partner = ?
|
COALESCE(SUM(CASE WHEN type = 'income' AND category != '派单' AND category != '接单' AND source = ? THEN amount ELSE 0 END), 0) as other_income,
|
||||||
`).get(req.user.id, partner);
|
COUNT(*) as count
|
||||||
|
FROM records WHERE user_id = ? AND (
|
||||||
const otherIncome = db.prepare(`
|
(category = '派单' AND partner = ?) OR
|
||||||
SELECT COALESCE(SUM(amount), 0) as total
|
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
|
||||||
FROM records WHERE user_id = ? AND type = 'income' AND category != '派单' AND category != '接单' AND source = ?
|
)${dateFilter}
|
||||||
`).get(req.user.id, partner);
|
`).get(partner, req.user.id, partner, partner, ...dateParams);
|
||||||
|
|
||||||
const records = db.prepare(`
|
const records = db.prepare(`
|
||||||
SELECT * FROM records
|
SELECT * FROM records
|
||||||
WHERE user_id = ? AND (
|
WHERE user_id = ? AND (
|
||||||
(category = '派单' AND partner = ?) OR
|
(category = '派单' AND partner = ?) OR
|
||||||
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
|
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
|
||||||
) ORDER BY created_at DESC
|
)${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||||
`).all(req.user.id, partner, partner);
|
`).all(req.user.id, partner, partner, ...dateParams, validatedLimit, offset);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
dispatch_count: dispatchStats.dispatch_count,
|
dispatch_count: summary.dispatch_count,
|
||||||
spread_income: dispatchStats.spread_income,
|
spread_income: summary.spread_income,
|
||||||
rebate_income: dispatchStats.rebate_income,
|
rebate_income: summary.rebate_income,
|
||||||
other_income: otherIncome.total,
|
other_income: summary.other_income,
|
||||||
records
|
records,
|
||||||
|
page: validatedPage,
|
||||||
|
limit: validatedLimit,
|
||||||
|
totalCount: summary.count
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 最近7天每日收入
|
// 最近7天每日收入
|
||||||
@@ -316,41 +376,54 @@ router.get('/daily-income', (req, res) => {
|
|||||||
endDay.setDate(endDay.getDate() + 1);
|
endDay.setDate(endDay.getDate() + 1);
|
||||||
const endDate = formatLocalDate(endDay);
|
const endDate = formatLocalDate(endDay);
|
||||||
|
|
||||||
|
const cacheKey = `daily-income:${req.user.id}:${startDate}`;
|
||||||
|
getCached(cacheKey, () => {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income
|
SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income
|
||||||
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||||
GROUP BY DATE(created_at, 'localtime')
|
GROUP BY DATE(created_at, 'localtime')
|
||||||
`).all(req.user.id, startDate, endDate);
|
`).all(req.user.id, startDate, endDate);
|
||||||
|
|
||||||
const map = {};
|
const map = {};
|
||||||
rows.forEach(r => { map[r.date] = r.income; });
|
rows.forEach(r => { map[r.date] = r.income; });
|
||||||
res.json(days.map(d => ({ date: d, income: map[d] || 0 })));
|
return days.map(d => ({ date: d, income: map[d] || 0 }));
|
||||||
|
}).then(data => res.json(data));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 某天收入记录详情
|
// 某天收入记录详情
|
||||||
router.get('/daily-detail', (req, res) => {
|
router.get('/daily-detail', (req, res, next) => {
|
||||||
const { date } = req.query;
|
try {
|
||||||
if (!date) return res.status(400).json({ error: '缺少日期参数' });
|
const { date, page = 1, limit = 20 } = req.query;
|
||||||
|
if (!date) throw new AppError('缺少日期参数', 400);
|
||||||
|
|
||||||
|
const validatedPage = Math.max(1, Number(page) || 1);
|
||||||
|
const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
|
||||||
|
const offset = (validatedPage - 1) * validatedLimit;
|
||||||
|
|
||||||
const nextDay = new Date(date);
|
const nextDay = new Date(date);
|
||||||
nextDay.setDate(nextDay.getDate() + 1);
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
const endDate = formatLocalDate(nextDay);
|
const endDate = formatLocalDate(nextDay);
|
||||||
|
|
||||||
const summary = db.prepare(`
|
const stats = db.prepare(`
|
||||||
SELECT COALESCE(SUM(amount), 0) as total_income
|
SELECT COALESCE(SUM(amount), 0) as total_income, COUNT(*) as count
|
||||||
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||||
`).get(req.user.id, date, endDate);
|
`).get(req.user.id, date, endDate);
|
||||||
|
|
||||||
const records = db.prepare(`
|
const records = db.prepare(`
|
||||||
SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||||
`).all(req.user.id, date, endDate);
|
`).all(req.user.id, date, endDate, validatedLimit, offset);
|
||||||
|
|
||||||
res.json({ total_income: summary.total_income, records });
|
res.json({ total_income: stats.total_income, records, page: validatedPage, limit: validatedLimit, totalCount: stats.count });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 老板收入排行 - 首页预览
|
// 老板收入排行 - 首页预览
|
||||||
router.get('/boss-ranking/preview', (req, res) => {
|
router.get('/boss-ranking/preview', (req, res) => {
|
||||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||||
|
const cacheKey = `boss-ranking-preview:${req.user.id}:${startDate}:${endDate}`;
|
||||||
|
getCached(cacheKey, () => {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income,
|
SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income,
|
||||||
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
|
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
|
||||||
@@ -361,7 +434,8 @@ router.get('/boss-ranking/preview', (req, res) => {
|
|||||||
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||||
GROUP BY COALESCE(boss, source) ORDER BY total_income DESC LIMIT 10
|
GROUP BY COALESCE(boss, source) ORDER BY total_income DESC LIMIT 10
|
||||||
`).all(req.user.id, startDate, endDate);
|
`).all(req.user.id, startDate, endDate);
|
||||||
res.json(rows);
|
return rows;
|
||||||
|
}).then(rows => res.json(rows));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 老板收入排行 - 详情页(支持筛选)
|
// 老板收入排行 - 详情页(支持筛选)
|
||||||
|
|||||||
@@ -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) {
|
function getMonthRange(qYear, qMonth) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const y = Number(qYear) || now.getFullYear();
|
const y = Number(qYear) || now.getFullYear();
|
||||||
@@ -10,7 +23,9 @@ function getMonthRange(qYear, qMonth) {
|
|||||||
return { startDate, endDate };
|
return { startDate, endDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSV行解析(处理引号内的逗号)
|
/**
|
||||||
|
* CSV行解析(处理引号内的逗号)
|
||||||
|
*/
|
||||||
function parseCSVLine(line) {
|
function parseCSVLine(line) {
|
||||||
const result = [];
|
const result = [];
|
||||||
let current = '';
|
let current = '';
|
||||||
@@ -31,4 +46,80 @@ function parseCSVLine(line) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getMonthRange, parseCSVLine };
|
/**
|
||||||
|
* 输入验证函数
|
||||||
|
*/
|
||||||
|
function validateRequired(obj, fields) {
|
||||||
|
const missing = fields.filter(f => obj[f] === undefined || obj[f] === null || obj[f] === '');
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new AppError(`缺少必填字段: ${missing.join(', ')}`, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNumber(value, field, min, max) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) {
|
||||||
|
throw new AppError(`${field} 必须是数字`, 400);
|
||||||
|
}
|
||||||
|
if (min !== undefined && num < min) {
|
||||||
|
throw new AppError(`${field} 不能小于 ${min}`, 400);
|
||||||
|
}
|
||||||
|
if (max !== undefined && num > max) {
|
||||||
|
throw new AppError(`${field} 不能大于 ${max}`, 400);
|
||||||
|
}
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateString(value, field, maxLength) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(`${field} 必须是字符串`, 400);
|
||||||
|
}
|
||||||
|
if (maxLength && value.length > maxLength) {
|
||||||
|
throw new AppError(`${field} 长度不能超过 ${maxLength}`, 400);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value, field, maxLength) {
|
||||||
|
if (value === undefined || value === null || value === '') return null;
|
||||||
|
return validateString(String(value), field, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInList(value, field, list) {
|
||||||
|
if (!list.includes(value)) {
|
||||||
|
throw new AppError(`${field} 值无效`, 400);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePage(value, defaultValue = 1) {
|
||||||
|
return Math.max(1, Number.parseInt(value, 10) || defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLimit(value, defaultValue, maxLimit) {
|
||||||
|
const limit = Number.parseInt(value, 10) || defaultValue;
|
||||||
|
return Math.min(Math.max(1, limit), maxLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateIsoDate(value, field) {
|
||||||
|
if (value === undefined || value === null || value === '') return new Date().toISOString();
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
throw new AppError(`${field} 格式无效`, 400);
|
||||||
|
}
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
AppError,
|
||||||
|
getMonthRange,
|
||||||
|
parseCSVLine,
|
||||||
|
validateRequired,
|
||||||
|
validateNumber,
|
||||||
|
validateString,
|
||||||
|
optionalString,
|
||||||
|
validateInList,
|
||||||
|
validatePage,
|
||||||
|
validateLimit,
|
||||||
|
validateIsoDate
|
||||||
|
};
|
||||||
|
|||||||
@@ -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