commit a0d7bfdc3d7a846446785eaedcfc012a9f6f0108 Author: lizhilun Date: Thu Mar 12 11:23:10 2026 +0800 Initial commit: gamer project Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38544d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +*.log +data.db +data.db-shm +data.db-wal +.env +.DS_Store +*.swp +*.swo diff --git a/backup.sh b/backup.sh new file mode 100755 index 0000000..833c45a --- /dev/null +++ b/backup.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# 数据备份脚本 +# 备份 /opt/gamer 的数据到 /opt/backup/data/YYYYMMDD/gamer + +# 设置变量 +SOURCE_DIR="/opt/gamer" +BACKUP_ROOT="/opt/backup/data" +DATE=$(date +%Y%m%d) +BACKUP_DIR="${BACKUP_ROOT}/${DATE}/gamer" + +# 创建备份目录 +mkdir -p "${BACKUP_DIR}" + +# 备份数据库 +echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始备份数据库..." +cp "${SOURCE_DIR}/data.db" "${BACKUP_DIR}/data.db" +if [ $? -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] 数据库备份完成" +else + echo "[$(date '+%Y-%m-%d %H:%M:%S')] 数据库备份失败" + exit 1 +fi + +# 备份 public 目录 +echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始备份 public 目录..." +cp -r "${SOURCE_DIR}/public" "${BACKUP_DIR}/public" +if [ $? -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] public 目录备份完成" +else + echo "[$(date '+%Y-%m-%d %H:%M:%S')] public 目录备份失败" + exit 1 +fi + +# 记录备份日志 +echo "[$(date '+%Y-%m-%d %H:%M:%S')] 备份完成: ${BACKUP_DIR}" + +# 清理7天前的旧备份(可选) +find "${BACKUP_ROOT}" -type d -name "20*" -mtime +7 -exec rm -rf {} \; 2>/dev/null +echo "[$(date '+%Y-%m-%d %H:%M:%S')] 旧备份清理完成" diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..2be79f5 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -e + +echo "=== 游戏陪玩订单系统 - Ubuntu 24.04 部署脚本 ===" + +# 1. 安装 Node.js 20.x +echo ">>> 安装 Node.js..." +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt-get install -y nodejs + +# 2. 安装 PM2 +echo ">>> 安装 PM2..." +sudo npm install -g pm2 + +# 3. 安装 Nginx +echo ">>> 安装 Nginx..." +sudo apt-get install -y nginx + +# 4. 安装项目依赖 +echo ">>> 安装项目依赖..." +cd /opt/gamer +npm install --production + +# 5. 配置 Nginx +echo ">>> 配置 Nginx..." +sudo cp nginx.conf /etc/nginx/sites-available/gamer +sudo ln -sf /etc/nginx/sites-available/gamer /etc/nginx/sites-enabled/gamer +sudo rm -f /etc/nginx/sites-enabled/default +sudo nginx -t +sudo systemctl restart nginx + +# 6. 启动应用 +echo ">>> 启动应用..." +pm2 start ecosystem.config.js +pm2 save +pm2 startup systemd -u $USER --hp $HOME | tail -1 | sudo bash + +echo "=== 部署完成!===" +echo "请访问 http://your_server_ip 使用系统" diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..47fc6ad --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,13 @@ +module.exports = { + apps: [{ + name: 'gamer', + script: 'server/app.js', + env: { + NODE_ENV: 'production', + PORT: 3000 + }, + instances: 1, + autorestart: true, + max_memory_restart: '200M' + }] +}; diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..9d71d6d --- /dev/null +++ b/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + server_name your_domain_or_ip; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5fa6c14 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1396 @@ +{ + "name": "gamer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gamer", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.6.2", + "cors": "^2.8.6", + "express": "^5.2.1", + "jsonwebtoken": "^9.0.3" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ac00d3b --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "gamer", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "node server/app.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.6.2", + "cors": "^2.8.6", + "express": "^5.2.1", + "jsonwebtoken": "^9.0.3" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..b258b9a --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,760 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f5; + max-width: 480px; + margin: 0 auto; + min-height: 100vh; + position: relative; +} + +/* 登录页 */ +.page { display: none; } +.page.active { display: flex; flex-direction: column; } + +#loginPage { + min-height: 100vh; + justify-content: center; + align-items: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 20px; +} +.login-box { + background: #fff; + border-radius: 16px; + padding: 40px 30px; + width: 100%; + max-width: 360px; + box-shadow: 0 10px 40px rgba(0,0,0,0.2); +} +.login-box h1 { + text-align: center; + color: #333; + margin-bottom: 30px; + font-size: 22px; +} +.form-group { + margin-bottom: 20px; +} +.form-group label { + display: block; + color: #666; + margin-bottom: 6px; + font-size: 14px; +} +.form-group input { + width: 100%; + padding: 12px 16px; + border: 1px solid #ddd; + border-radius: 10px; + font-size: 16px; + outline: none; + transition: border-color 0.2s; +} +.form-group input:focus { + border-color: #667eea; +} +.btn { + width: 100%; + padding: 14px; + border: none; + border-radius: 10px; + font-size: 16px; + cursor: pointer; + transition: opacity 0.2s; +} +.btn:active { opacity: 0.8; } +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; +} +.login-error { + color: #e74c3c; + text-align: center; + margin-top: 12px; + font-size: 14px; + min-height: 20px; +} + +/* 首页 */ +#homePage { + min-height: 100vh; + background: #f5f5f5; +} +.home-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; + padding: 16px 20px; + display: flex; + justify-content: center; + align-items: center; +} +.month-selector { + display: flex; align-items: center; gap: 4px; + font-size: 16px; cursor: pointer; font-weight: 600; +} +.month-selector svg { width: 16px; height: 16px; } +.tab-content { padding: 16px 20px 100px; } +.tab-panel { display: none; } +.tab-panel.active { display: block; } + +/* 统计卡片 */ +.stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-top: 8px; +} +.sub-income { + font-size: 15px; + color: #27ae60; + margin-top: 4px; + font-weight: 600; +} +.stat-card { + background: #fff; + border-radius: 12px; + padding: 16px; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); +} +.stat-card .label { + font-size: 13px; + color: #999; + margin-bottom: 8px; +} +.stat-card .value { + font-size: 22px; + font-weight: 700; + color: #333; +} +.stat-card .value.income { color: #27ae60; } +.stat-card .value.expense { color: #e74c3c; } +.stat-card.full { grid-column: 1 / -1; } +.stat-summary { + display: flex; + justify-content: space-between; + background: #fff; + border-radius: 12px; + padding: 16px; + margin-top: 12px; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); +} +.stat-summary .item { text-align: center; flex: 1; } +.stat-summary .item .label { font-size: 13px; color: #999; margin-bottom: 4px; } +.stat-summary .item .val { font-size: 20px; font-weight: 700; } +.stat-summary .item .val.green { color: #27ae60; } +.stat-summary .item .val.red { color: #e74c3c; } +.stat-summary .item .val.orange { color: #f39c12; } +.stat-summary .item .val.purple { color: #9b59b6; } + +/* 最近7天收入柱状图 */ +.weekly-chart { + background: #fff; + border-radius: 12px; + padding: 16px; + margin-top: 12px; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); +} +.weekly-chart-title { + font-size: 15px; + font-weight: 600; + color: #333; + margin-bottom: 16px; +} +.weekly-chart-bars { + display: flex; + align-items: flex-end; + justify-content: space-between; + height: 150px; + gap: 8px; +} +.weekly-bar-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + justify-content: flex-end; + cursor: pointer; +} +.weekly-bar-col:active { opacity: 0.7; } +.weekly-bar-amount { + font-size: 10px; + color: #667eea; + font-weight: 600; + margin-bottom: 4px; + white-space: nowrap; +} +.weekly-bar { + width: 100%; + max-width: 32px; + border-radius: 6px 6px 0 0; + background: linear-gradient(180deg, #667eea 0%, #764ba2 100%); + min-height: 4px; + transition: height 0.3s ease; +} +.weekly-bar-date { + font-size: 11px; + color: #999; + margin-top: 6px; +} + +/* 日收入详情页 */ +#dailyDetailPage { min-height: 100vh; background: #f5f5f5; } +.daily-nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 20px 0; +} +.daily-nav-btn { + background: none; border: none; color: #667eea; + font-size: 14px; cursor: pointer; padding: 6px 0; +} +.daily-nav-btn:active { opacity: 0.6; } +.daily-nav-date { + font-size: 15px; font-weight: 600; color: #333; +} +.daily-summary { + background: #fff; + border-radius: 12px; + padding: 24px 16px; + margin: 16px 20px; + text-align: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); +} +.daily-summary-amount { + font-size: 32px; + font-weight: 700; + color: #27ae60; +} +.daily-summary-label { + font-size: 13px; + color: #999; + margin-top: 4px; +} + +/* 我的页面 */ +.profile-card { + background: #fff; + border-radius: 12px; + padding: 24px; + text-align: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); +} +.profile-card .avatar { + width: 64px; + height: 64px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 28px; + margin: 0 auto 12px; +} +.profile-card .name { font-size: 18px; font-weight: 600; color: #333; } +.refresh-btn { + margin-top: 20px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + color: #fff; + border-radius: 12px; + padding: 12px; + width: 100%; + font-size: 15px; + cursor: pointer; +} +.refresh-btn:active { opacity: 0.8; } +.logout-btn { + margin-top: 20px; + background: #fff; + border: 1px solid #e74c3c; + color: #e74c3c; + border-radius: 12px; + padding: 12px; + width: 100%; + font-size: 15px; + cursor: pointer; +} +.logout-btn:active { background: #ffeaea; } + +/* 底部导航 */ +.bottom-bar { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 480px; + background: #fff; + padding: 6px 20px 18px; + box-shadow: 0 -2px 10px rgba(0,0,0,0.05); + display: flex; + justify-content: space-around; + align-items: flex-end; + z-index: 10; +} +.nav-item { + display: flex; flex-direction: column; align-items: center; + cursor: pointer; padding: 4px 12px; color: #999; font-size: 11px; gap: 2px; + transition: color 0.2s; +} +.nav-item svg { width: 24px; height: 24px; } +.nav-item.active { color: #667eea; } +.record-btn { + width: 50px; + height: 50px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + color: #fff; + font-size: 28px; + cursor: pointer; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s; + margin-top: -15px; +} +.record-btn:active { transform: scale(0.9); } + +/* 记录页 */ +#recordPage { + min-height: 100vh; + background: #f5f5f5; +} +.page-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; + padding: 16px 20px; + display: flex; + align-items: center; + gap: 12px; +} +.page-header .back { + font-size: 22px; + cursor: pointer; + background: none; + border: none; + color: #fff; + padding: 4px; +} +.page-header h2 { font-size: 18px; font-weight: 600; } + +.type-switch { + display: flex; + margin: 16px 20px; + background: #fff; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); +} +.type-switch .type-btn { + flex: 1; + padding: 12px; + text-align: center; + font-size: 15px; + cursor: pointer; + border: none; + background: #fff; + color: #999; + transition: all 0.2s; +} +.type-switch .type-btn.active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; +} + +.category-list { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + padding: 0 20px; + margin-bottom: 16px; +} +.category-item { + background: #fff; + border: 2px solid transparent; + border-radius: 10px; + padding: 12px 8px; + text-align: center; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + color: #666; +} +.category-item.active { + border-color: #667eea; + color: #667eea; + background: #f0f2ff; +} + +.record-form { + padding: 0 20px 30px; + overflow: hidden; +} +.record-form .form-group { margin-bottom: 14px; } +.record-form .form-group input[type="datetime-local"] { + -webkit-appearance: none; + appearance: none; +} +.record-form .form-group label { + display: block; + font-size: 14px; + color: #666; + margin-bottom: 6px; +} +.record-form .form-group input { + width: 100%; + padding: 12px 14px; + border: 1px solid #e0e0e0; + border-radius: 10px; + font-size: 15px; + outline: none; + background: #fff; + box-sizing: border-box; + max-width: 100%; +} +.record-form .form-group input:focus { border-color: #667eea; } +.computed-amount { + background: #f0f2ff; + border-radius: 10px; + padding: 14px; + text-align: center; + margin-bottom: 16px; + font-size: 14px; + color: #666; +} +.computed-amount .total { + font-size: 28px; + font-weight: 700; + color: #667eea; + margin-top: 4px; +} +.submit-btn { + width: 100%; + padding: 14px; + border: none; + border-radius: 10px; + font-size: 16px; + cursor: pointer; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; +} +.submit-btn:active { opacity: 0.8; } + +/* Toast */ +.toast { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0,0,0,0.75); + color: #fff; + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + z-index: 999; + display: none; +} +.toast.show { display: block; } + +/* 排行预览 */ +.ranking-preview { + background: #fff; + border-radius: 12px; + padding: 16px; + margin-top: 12px; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); +} +.ranking-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} +.ranking-title { font-size: 15px; font-weight: 600; color: #333; } +.ranking-detail-btn { font-size: 13px; color: #667eea; cursor: pointer; } +.ranking-list, .ranking-detail-list { display: flex; flex-direction: column; } +.ranking-item { + display: flex; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid #f0f0f0; + cursor: pointer; +} +.ranking-item:last-child { border-bottom: none; } +.ranking-index { + width: 24px; height: 24px; border-radius: 50%; + background: #eee; color: #999; font-size: 12px; font-weight: 600; + display: flex; align-items: center; justify-content: center; + margin-right: 12px; flex-shrink: 0; +} +.ranking-index.top { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; +} +.ranking-boss { flex: 1; font-size: 14px; color: #333; } +.ranking-amount { font-size: 15px; font-weight: 600; color: #27ae60; } +.ranking-empty { text-align: center; color: #999; padding: 20px 0; font-size: 14px; } + +/* 排行详情页 */ +#rankingPage { min-height: 100vh; background: #f5f5f5; } +.ranking-filters { padding: 16px 20px; } +.filter-row { display: flex; gap: 10px; margin-bottom: 12px; } +.filter-row .form-group { flex: 1; min-width: 0; margin-bottom: 0; text-align: center; } +.filter-row .form-group label { text-align: center; } +.filter-row .form-group input { + width: 100%; padding: 10px 4px; border: 1px solid #e0e0e0; + border-radius: 10px; font-size: 12px; outline: none; background: #fff; + text-align: center; box-sizing: border-box; + -webkit-appearance: none; appearance: none; +} +.filter-row .form-group input:focus { border-color: #667eea; } +.sort-switch { + display: flex; background: #fff; border-radius: 10px; + overflow: hidden; border: 1px solid #e0e0e0; flex: 1; +} +.sort-btn { + flex: 1; padding: 10px 8px; text-align: center; font-size: 13px; + cursor: pointer; border: none; background: #fff; color: #999; transition: all 0.2s; +} +.sort-btn.active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; +} +.ranking-detail-list { padding: 0 20px 30px; } +.ranking-detail-item { + background: #fff; border-radius: 12px; padding: 14px 16px; + margin-bottom: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); +} +.ranking-detail-top { display: flex; align-items: center; } +.ranking-detail-stats { + display: flex; justify-content: space-between; + margin-top: 8px; padding-left: 36px; font-size: 12px; color: #999; +} + +/* 接单列表页 */ +#orderListPage { min-height: 100vh; background: #f5f5f5; } +.order-list { padding: 12px 20px 30px; } +.order-date-group { margin-bottom: 16px; } +.order-date-label { + font-size: 13px; color: #999; font-weight: 600; + margin-bottom: 8px; padding-left: 4px; +} +.order-item { + background: #fff; border-radius: 12px; padding: 14px 16px; + margin-bottom: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); + cursor: pointer; transition: background 0.15s; +} +.order-item:active { background: #f8f8f8; } +.order-item-top { display: flex; justify-content: space-between; align-items: center; } +.order-item-boss { font-size: 15px; font-weight: 600; color: #333; } +.order-item-amount { font-size: 15px; font-weight: 600; color: #27ae60; } +.order-item-bottom { display: flex; justify-content: space-between; margin-top: 6px; font-size: 12px; color: #999; } +.order-loading { text-align: center; padding: 16px; color: #999; font-size: 14px; } + +/* 编辑弹窗 */ +.modal-overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); z-index: 100; + display: none; justify-content: center; align-items: center; +} +.modal-overlay.show { display: flex; } +.modal-box { + background: #fff; border-radius: 16px; padding: 24px; + width: 90%; max-width: 400px; max-height: 80vh; overflow-y: auto; +} +.modal-box h3 { font-size: 18px; margin-bottom: 16px; color: #333; } +.modal-box .form-group { margin-bottom: 14px; } +.modal-box .form-group label { display: block; font-size: 14px; color: #666; margin-bottom: 6px; } +.modal-box .form-group input { + width: 100%; padding: 10px 12px; border: 1px solid #e0e0e0; + border-radius: 10px; font-size: 14px; outline: none; background: #fff; + box-sizing: border-box; +} +.modal-box .form-group input[type="datetime-local"] { + -webkit-appearance: none; + appearance: none; +} +.modal-box .form-group input:focus { border-color: #667eea; } +.modal-actions { display: flex; gap: 12px; margin-top: 16px; } +.modal-actions button { flex: 1; padding: 12px; border-radius: 10px; font-size: 15px; cursor: pointer; border: none; } +.modal-actions .btn-cancel { background: #f0f0f0; color: #666; } +.modal-actions .btn-save { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; +} +.modal-actions .btn-delete { background: #e74c3c; color: #fff; } + +/* 可点击的统计卡片 */ +.stat-card.clickable { cursor: pointer; transition: transform 0.15s; } +.stat-card.clickable:active { transform: scale(0.97); } +.stat-summary .item.clickable { cursor: pointer; } +.stat-summary .item.clickable:active { opacity: 0.7; } +.mine-stats { + display: flex; + gap: 12px; + margin: 16px 0; + width: 100%; +} +.mine-stat-item { + flex: 1; + background: #f8f9fa; + border-radius: 10px; + padding: 12px 8px; + text-align: center; +} +.mine-stat-value { + font-size: 18px; + font-weight: 700; + color: #333; +} +.mine-stat-value.green { color: #27ae60; } +.mine-stat-label { + font-size: 12px; + color: #999; + margin-top: 4px; +} +.mine-stat-item { cursor: pointer; } +.mine-stat-item:active { opacity: 0.7; } +.horizontal-chart { + padding: 16px; +} +.hbar-row { + display: flex; + align-items: center; + margin-bottom: 10px; + background: #fff; + border-radius: 12px; + padding: 12px 14px; + box-shadow: 0 1px 4px rgba(0,0,0,0.06); +} +.hbar-index { + width: 24px; + height: 24px; + border-radius: 50%; + background: #e0e0e0; + color: #666; + font-size: 12px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-right: 10px; +} +.hbar-index.top { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; } +.hbar-label { + width: 60px; + font-size: 14px; + color: #333; + font-weight: 600; + text-align: left; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.hbar-track { + flex: 1; + height: 22px; + background: #f0f0f0; + border-radius: 11px; + overflow: hidden; + position: relative; + margin: 0 10px; +} +.hbar-fill { + height: 100%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 11px; + min-width: 4px; + transition: width 0.4s ease; +} +.hbar-value { + font-size: 14px; + font-weight: 700; + color: #667eea; + text-align: right; + flex-shrink: 0; + min-width: 45px; +} + +/* 月份选择器弹窗 */ +.month-picker-overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); z-index: 100; + display: none; justify-content: center; align-items: center; +} +.month-picker-overlay.show { display: flex; } +.month-picker { + background: #fff; border-radius: 16px; padding: 24px; + width: 85%; max-width: 360px; +} +.month-picker-header { + display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; +} +.month-picker-header button { + background: none; border: none; font-size: 20px; cursor: pointer; color: #667eea; padding: 4px 8px; +} +.month-picker-header span { font-size: 18px; font-weight: 600; color: #333; } +.month-picker-grid { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; +} +.month-picker-grid button { + padding: 12px 0; border-radius: 10px; border: 1px solid #e0e0e0; + background: #fff; font-size: 14px; color: #333; cursor: pointer; transition: all 0.15s; +} +.month-picker-grid button.active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; border-color: transparent; +} +.month-picker-grid button:active { transform: scale(0.95); } +.month-picker-actions { display: flex; gap: 12px; margin-top: 16px; } +.month-picker-actions button { + flex: 1; padding: 12px; border-radius: 10px; font-size: 15px; cursor: pointer; border: none; +} +.month-picker-actions .btn-cancel { background: #f0f0f0; color: #666; } +.month-picker-actions .btn-confirm { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; +} + +/* 自动补全 */ +.ac-wrap { position: relative; } +.ac-list { + position: absolute; left: 0; right: 0; top: 100%; + background: #fff; border: 1px solid #e0e0e0; border-top: none; + border-radius: 0 0 10px 10px; max-height: 180px; overflow-y: auto; + z-index: 50; display: none; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); +} +.ac-list.show { display: block; } +.ac-item { + padding: 10px 14px; font-size: 14px; color: #333; + cursor: pointer; border-bottom: 1px solid #f5f5f5; +} +.ac-item:last-child { border-bottom: none; } +.ac-item:active, .ac-item.active { background: #f0f2ff; color: #667eea; } +.ac-item .ac-highlight { color: #667eea; font-weight: 600; } + +/* 下拉刷新 */ +.pull-indicator { + display: flex; align-items: center; justify-content: center; gap: 6px; + height: 0; overflow: hidden; color: #999; font-size: 13px; + transition: height 0.2s; +} +.pull-indicator.visible { height: 40px; } +.pull-indicator.refreshing .pull-arrow { + animation: spin 0.8s linear infinite; +} +.pull-arrow { + width: 18px; height: 18px; transition: transform 0.2s; +} +.pull-arrow.flipped { transform: rotate(180deg); } +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5ee2b31 --- /dev/null +++ b/public/index.html @@ -0,0 +1,456 @@ + + + + + + 接单系统 + + + + + +
+ +
+ + +
+
+
+ 2026年2月 + +
+
+
+ +
+
+ + 下拉刷新 +
+
+
+
接单数
+
0
+
0
+
+
+
派单数
+
0
+
0
+
+
+
红包
+
0
+
0
+
+
+
奶茶
+
0
+
0
+
+
+
+
+
总收入
+
0
+
+
+
总支出
+
0
+
+
+
净收入
+
0
+
+
+ +
+
最近7天收入
+
+
+ +
+
+ 老板贡献榜(本月) + 详情 → +
+
+
暂无数据
+
+
+ +
+
+ 陪玩派单榜(本月) + 详情 → +
+
+
暂无数据
+
+
+
+
+
+
+
用户
+
+
+
0
+
总接单数
+
+
+
0
+
总派单数
+
+
+
0
+
总净收入
+
+
+ + + +
+
+
+
+ + + +
+
+ + +
+ +
+ + +
+
+
接单
+
派单
+
红包
+
奶茶
+
+ + +
+ +
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + + +
+
+ +
+
+
+ + +
+ +
+ +
+ + +
+ +
+ +
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+
0
+
派单总数
+
+
+
0.00
+
陪玩总收入
+
+
+
0.00
+
返点总收入
+
+
+
0.00
+
奶茶收入
+
+
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+ + + +
+
+
+
+ + +
+ +
+ + + +
+
+
+
+ + +
+ +
+
+
+ + +
+ +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + + +
+
+
+
+ + + + + +
+
+
+ + 2026 + +
+
+
+ + +
+
+
+ + + + + + + +
+ + + + + + + + + + + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..315e21f --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,309 @@ +const API = ''; +let token = localStorage.getItem('token'); +let username = localStorage.getItem('username'); +let currentType = 'income'; +let currentCategory = '接单'; +let currentYear = new Date().getFullYear(); +let currentMonth = new Date().getMonth() + 1; +let currentNav = 'stats'; + +function headers() { + return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; +} + +function formatLocalTime(isoStr) { + if (!isoStr) return ''; + const d = new Date(isoStr); + const h = String(d.getHours()).padStart(2, '0'); + const m = String(d.getMinutes()).padStart(2, '0'); + return h + ':' + m; +} + +function formatLocalDate(input) { + if (!input) return '未知日期'; + const d = input instanceof Date ? input : new Date(input); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return y + '-' + m + '-' + day; +} + +async function loadTotalStats() { + try { + const res = await fetch(API + '/api/stats/total', { headers: headers() }); + if (!res.ok) return; + const s = await res.json(); + document.getElementById('totalTakeCount').textContent = s.take_count; + document.getElementById('totalDispatchCount').textContent = s.dispatch_count; + document.getElementById('totalNetIncome').textContent = '' + s.net_income.toFixed(2); + } catch (e) { console.error(e); } +} + +function renderHorizontalChart(containerId, rows, clickHandler, fromPage) { + const container = document.getElementById(containerId); + if (rows.length === 0) { + container.innerHTML = '
暂无数据
'; + return; + } + const max = Math.max(...rows.map(r => r.count), 1); + container.innerHTML = rows.map((r, i) => { + const pct = Math.max((r.count / max) * 100, 2); + const onclick = clickHandler ? ` onclick="${clickHandler}('${r.name.replace(/'/g, "\\'")}', '${fromPage}')"` : ''; + return `
+
${i + 1}
+
${r.name}
+
+
${r.count}单
+
`; + }).join(''); +} + +async function openTakeRanking() { + showPage('takeRankingPage'); + try { + const res = await fetch(API + '/api/stats/take-ranking', { headers: headers() }); + if (!res.ok) return; + const data = await res.json(); + console.log('take-ranking data:', data); + const rows = data.rows || []; + + // 显示统计卡片 + const summaryContainer = document.getElementById('takeRankingSummary'); + summaryContainer.innerHTML = ` +
+
接单总数
+
${data.total_take_count || 0}
+
+
+
接单总收入
+
${(data.total_take_income || 0).toFixed(2)}
+
+
+
红包收入
+
${(data.total_redEnvelope_income || 0).toFixed(2)}
+
+ `; + + renderHorizontalChart('takeRankingChart', rows, 'openBossDetail', 'takeRankingPage'); + } catch (e) { console.error(e); } +} + +async function openDispatchRanking() { + showPage('dispatchRankingPage'); + try { + const res = await fetch(API + '/api/stats/dispatch-ranking', { headers: headers() }); + if (!res.ok) return; + const data = await res.json(); + const rows = data.rows || []; + + // 计算总计 + const totalDispatch = data.total_dispatch_count !== undefined ? data.total_dispatch_count : (rows.reduce((sum, r) => sum + (r.dispatch_count || 0), 0)); + const totalPartnerIncome = rows.reduce((sum, r) => sum + (r.partner_income || 0), 0); + const totalRebateIncome = rows.reduce((sum, r) => sum + (r.rebate_income || 0), 0); + const totalMilkTeaIncome = data.total_milkTea_income || 0; + + // 显示4个卡片 + const summaryContainer = document.getElementById('dispatchRankingSummary'); + summaryContainer.innerHTML = ` +
+
派单总数
+
${totalDispatch}
+
+
+
陪玩总收入
+
${totalPartnerIncome.toFixed(2)}
+
+
+
返点总收入
+
${totalRebateIncome.toFixed(2)}
+
+
+
奶茶收入
+
${totalMilkTeaIncome.toFixed(2)}
+
+ `; + + // 显示排行列表 + const max = Math.max(...rows.map(r => r.dispatch_count || 0), 1); + const container = document.getElementById('dispatchRankingChart'); + container.innerHTML = rows.map((r, i) => { + const pct = Math.max(((r.dispatch_count || 0) / max) * 100, 2); + return `
+
${i + 1}
+
${r.name}
+
+
${r.dispatch_count}单
+
`; + }).join(''); + } catch (e) { console.error(e); } +} + +function showToast(msg) { + const t = document.getElementById('toast'); + t.textContent = msg; + t.classList.add('show'); + setTimeout(() => t.classList.remove('show'), 2000); +} + +function showPage(pageId) { + document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); + document.getElementById(pageId).classList.add('active'); + window.scrollTo(0, 0); + if (pageId === 'homePage') { + updateMonthDisplay(); + loadStats(); + document.getElementById('profileName').textContent = username; + document.getElementById('avatarText').textContent = username.charAt(0).toUpperCase(); + switchNav(currentNav); + } + if (pageId === 'recordPage') { + currentType = 'income'; + currentCategory = '接单'; + document.querySelectorAll('.type-btn').forEach((b, i) => b.classList.toggle('active', i === 0)); + document.getElementById('incomeCats').style.display = 'grid'; + document.getElementById('expenseCats').style.display = 'none'; + selectCategory(document.querySelector('#incomeCats .category-item'), '接单'); + } + if (pageId === 'rankingPage') { + const startDate = `${currentYear}-${String(currentMonth).padStart(2, '0')}-01`; + const now = new Date(); + const endDay = (currentYear === now.getFullYear() && currentMonth === now.getMonth() + 1) ? formatLocalDate(now) : `${currentYear}-${String(currentMonth).padStart(2, '0')}-28`; + document.getElementById('rankStartDate').value = startDate; + document.getElementById('rankEndDate').value = endDay; + currentSort = 'total_income'; + document.querySelectorAll('.sort-btn').forEach((b, i) => b.classList.toggle('active', i === 0)); + loadRankingDetail(); + } + if (pageId === 'orderListPage') { + document.getElementById('orderListTitle').textContent = `${currentYear}年${currentMonth}月接单列表`; + orderPage = 1; orderHasMore = true; + document.getElementById('orderList').innerHTML = ''; + loadOrders(); + } + if (pageId === 'otherIncomeListPage') { + document.getElementById('otherIncomeListTitle').textContent = `${currentYear}年${currentMonth}月红包及其他收入`; + otherIncomePage = 1; otherIncomeHasMore = true; + document.getElementById('otherIncomeList').innerHTML = ''; + loadOtherIncome(); + } + if (pageId === 'dispatchListPage') { + document.getElementById('dispatchListTitle').textContent = `${currentYear}年${currentMonth}月派单列表`; + dispatchPage = 1; dispatchHasMore = true; + document.getElementById('dispatchList').innerHTML = ''; + loadDispatches(); + } + if (pageId === 'redEnvelopeListPage') { + redEnvelopePage = 1; redEnvelopeHasMore = true; + document.getElementById('redEnvelopeList').innerHTML = ''; + loadRedEnvelope(); + } + if (pageId === 'milkTeaListPage') { + milkTeaPage = 1; milkTeaHasMore = true; + document.getElementById('milkTeaList').innerHTML = ''; + loadMilkTea(); + } +} + +function switchNav(nav) { + currentNav = nav; + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); + document.getElementById('navStats').classList.toggle('active', nav === 'stats'); + document.getElementById('navMine').classList.toggle('active', nav === 'mine'); + if (nav === 'stats') { + document.getElementById('statsPanel').classList.add('active'); + } else { + document.getElementById('minePanel').classList.add('active'); + loadTotalStats(); + } +} + +function updateMonthDisplay() { + document.getElementById('monthDisplay').textContent = `${currentYear}年${currentMonth}月`; +} + +// 头像连续点击5次显示隐藏按钮 +let avatarTapCount = 0; +let avatarTapTimer = null; +function onAvatarTap() { + avatarTapCount++; + clearTimeout(avatarTapTimer); + avatarTapTimer = setTimeout(() => { avatarTapCount = 0; }, 1500); + if (avatarTapCount >= 5) { + avatarTapCount = 0; + const el = document.getElementById('secretBtns'); + const visible = el.style.display !== 'none'; + el.style.display = visible ? 'none' : 'block'; + showToast(visible ? '已隐藏' : '已解锁'); + } +} + +// 下拉刷新 +(function() { + let startY = 0, pulling = false, triggered = false; + const threshold = 60; + + function getPanel() { return document.getElementById('statsPanel'); } + function getIndicator() { return document.getElementById('pullIndicator'); } + + document.addEventListener('touchstart', function(e) { + const panel = getPanel(); + if (!panel || !panel.classList.contains('active')) return; + if (currentNav !== 'stats') return; + if (window.scrollY > 0) return; + startY = e.touches[0].clientY; + pulling = true; + triggered = false; + }, { passive: true }); + + document.addEventListener('touchmove', function(e) { + if (!pulling) return; + const panel = getPanel(); + if (!panel || !panel.classList.contains('active')) { pulling = false; return; } + if (window.scrollY > 0) { pulling = false; return; } + const dy = e.touches[0].clientY - startY; + if (dy < 0) return; + const indicator = getIndicator(); + const arrow = document.getElementById('pullArrow'); + const text = document.getElementById('pullText'); + if (dy > 10) { + indicator.classList.add('visible'); + if (dy >= threshold) { + arrow.classList.add('flipped'); + text.textContent = '松手刷新'; + triggered = true; + } else { + arrow.classList.remove('flipped'); + text.textContent = '下拉刷新'; + triggered = false; + } + } + }, { passive: true }); + + document.addEventListener('touchend', function() { + if (!pulling) return; + pulling = false; + const indicator = getIndicator(); + const arrow = document.getElementById('pullArrow'); + const text = document.getElementById('pullText'); + if (triggered) { + text.textContent = '刷新中...'; + arrow.classList.remove('flipped'); + indicator.classList.add('refreshing'); + loadStats().then(() => { + setTimeout(() => { + indicator.classList.remove('visible', 'refreshing'); + showToast('已刷新'); + }, 500); + }); + } else { + indicator.classList.remove('visible'); + } + }, { passive: true }); +})(); + +document.addEventListener('DOMContentLoaded', function() { + if (token && username) { + showPage('homePage'); + } + renderForm(); +}); diff --git a/public/js/auth.js b/public/js/auth.js new file mode 100644 index 0000000..8c412f9 --- /dev/null +++ b/public/js/auth.js @@ -0,0 +1,29 @@ +async function doLogin() { + const user = document.getElementById('loginUser').value.trim(); + const pass = document.getElementById('loginPass').value.trim(); + if (!user || !pass) { document.getElementById('loginError').textContent = '请输入用户名和密码'; return; } + try { + const res = await fetch(API + '/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: user, password: pass }) + }); + const data = await res.json(); + if (!res.ok) { document.getElementById('loginError').textContent = data.error; return; } + token = data.token; + username = data.username; + localStorage.setItem('token', token); + localStorage.setItem('username', username); + showPage('homePage'); + } catch (e) { + document.getElementById('loginError').textContent = '网络错误'; + } +} + +function doLogout() { + token = null; + username = null; + localStorage.removeItem('token'); + localStorage.removeItem('username'); + showPage('loginPage'); +} diff --git a/public/js/autocomplete.js b/public/js/autocomplete.js new file mode 100644 index 0000000..53a767b --- /dev/null +++ b/public/js/autocomplete.js @@ -0,0 +1,103 @@ +// 联系人自动补全模块 +const _acCache = {}; + +async function acFetchNames(field) { + if (_acCache[field] && Date.now() - _acCache[field].ts < 60000) return _acCache[field].data; + try { + const res = await fetch(API + '/api/contacts?field=' + field, { headers: headers() }); + if (!res.ok) return []; + const data = await res.json(); + _acCache[field] = { data, ts: Date.now() }; + return data; + } catch { return []; } +} + +function acInvalidateCache(field) { + if (field) delete _acCache[field]; + else Object.keys(_acCache).forEach(k => delete _acCache[k]); +} + +function acSetup(inputId, field) { + const input = document.getElementById(inputId); + if (!input || input._acBound) return; + input._acBound = true; + input.setAttribute('autocomplete', 'off'); + + const wrap = document.createElement('div'); + wrap.className = 'ac-wrap'; + input.parentNode.insertBefore(wrap, input); + wrap.appendChild(input); + + const list = document.createElement('div'); + list.className = 'ac-list'; + wrap.appendChild(list); + + let names = []; + let activeIdx = -1; + + async function loadNames() { + names = await acFetchNames(field); + } + + function render(query) { + const q = query.trim().toLowerCase(); + const filtered = q ? names.filter(n => n.toLowerCase().includes(q)) : names; + if (filtered.length === 0) { list.classList.remove('show'); return; } + activeIdx = -1; + list.innerHTML = filtered.slice(0, 10).map((n, i) => { + let display = n; + if (q) { + const idx = n.toLowerCase().indexOf(q); + if (idx >= 0) { + display = n.slice(0, idx) + '' + n.slice(idx, idx + q.length) + '' + n.slice(idx + q.length); + } + } + return `
${display}
`; + }).join(''); + list.classList.add('show'); + } + + function pick(value) { + input.value = value; + list.classList.remove('show'); + input.dispatchEvent(new Event('input', { bubbles: true })); + } + + input.addEventListener('focus', async () => { + await loadNames(); + render(input.value); + }); + + input.addEventListener('input', () => { render(input.value); }); + + input.addEventListener('keydown', (e) => { + const items = list.querySelectorAll('.ac-item'); + if (!list.classList.contains('show') || items.length === 0) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + activeIdx = Math.min(activeIdx + 1, items.length - 1); + items.forEach((el, i) => el.classList.toggle('active', i === activeIdx)); + items[activeIdx].scrollIntoView({ block: 'nearest' }); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + activeIdx = Math.max(activeIdx - 1, 0); + items.forEach((el, i) => el.classList.toggle('active', i === activeIdx)); + items[activeIdx].scrollIntoView({ block: 'nearest' }); + } else if (e.key === 'Enter' && activeIdx >= 0) { + e.preventDefault(); + pick(items[activeIdx].dataset.value); + } else if (e.key === 'Escape') { + list.classList.remove('show'); + } + }); + + list.addEventListener('mousedown', (e) => { + e.preventDefault(); // 防止 input 失焦 + const item = e.target.closest('.ac-item'); + if (item) pick(item.dataset.value); + }); + + document.addEventListener('click', (e) => { + if (!wrap.contains(e.target)) list.classList.remove('show'); + }); +} diff --git a/public/js/edit-modal.js b/public/js/edit-modal.js new file mode 100644 index 0000000..8dd28d0 --- /dev/null +++ b/public/js/edit-modal.js @@ -0,0 +1,164 @@ +function openEditModal(record) { + document.getElementById('editId').value = record.id; + document.getElementById('editCategory').value = record.category || ''; + const cat = record.category; + const body = document.getElementById('editFormBody'); + let localTime = ''; + if (record.created_at) { + const dt = new Date(record.created_at); + localTime = new Date(dt.getTime() - dt.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + } + + if (cat === '接单' || cat === '派单') { + document.getElementById('editModalTitle').textContent = cat === '派单' ? '编辑派单记录' : '编辑接单记录'; + let fields = ` +
+
+ `; + if (cat === '派单') { + const rebateVal = record.rebate || (record.quantity ? (record.amount / record.quantity) : ''); + fields += `
`; + } + fields += ` +
+
+
+ `; + body.innerHTML = fields; + } else if (cat === '红包收入' || cat === '奶茶') { + document.getElementById('editModalTitle').textContent = '编辑' + cat + '记录'; + body.innerHTML = ` +
+
+
+ `; + } else if (cat === '红包支出' || cat === '比心' || cat === '其他支出') { + document.getElementById('editModalTitle').textContent = '编辑' + cat + '记录'; + let fields = ` +
+ `; + if (cat !== '比心') { + fields += `
`; + } else { + fields += `
`; + } + if (cat === '其他支出') { + fields += `
`; + } + fields += `
`; + body.innerHTML = fields; + } else { + document.getElementById('editModalTitle').textContent = '编辑记录'; + body.innerHTML = ` +
+
+ `; + } + // 绑定自动补全 + if (cat === '接单' || cat === '派单') { + acSetup('editBoss', 'boss'); + acSetup('editPartner', 'partner'); + } else if (cat === '红包收入' || cat === '奶茶') { + acSetup('editSource', 'source'); + } else if (cat === '红包支出' || cat === '比心' || cat === '其他支出') { + acSetup('editDest', 'destination'); + } + + document.getElementById('editModal').classList.add('show'); +} + +function closeEditModal() { + document.getElementById('editModal').classList.remove('show'); +} + +function refreshCurrentList() { + const cat = document.getElementById('editCategory').value; + if (cat === '派单') { + dispatchPage = 1; dispatchHasMore = true; + document.getElementById('dispatchList').innerHTML = ''; + loadDispatches(); + } else if (cat === '接单') { + orderPage = 1; orderHasMore = true; + document.getElementById('orderList').innerHTML = ''; + loadOrders(); + } else { + otherIncomePage = 1; otherIncomeHasMore = true; + document.getElementById('otherIncomeList').innerHTML = ''; + loadOtherIncome(); + } + // 如果在日详情页也刷新 + if (document.getElementById('dailyDetailPage').classList.contains('active') && currentDailyDate) { + openDailyDetail(currentDailyDate); + } +} + +async function saveEditRecord() { + const id = document.getElementById('editId').value; + const cat = document.getElementById('editCategory').value; + const time = document.getElementById('editTime')?.value; + let body = {}; + + if (cat === '接单' || cat === '派单') { + const quantity = parseInt(document.getElementById('editQuantity').value) || 0; + const unit_price = parseFloat(document.getElementById('editPrice').value) || 0; + const boss = document.getElementById('editBoss').value.trim(); + const partner = document.getElementById('editPartner').value.trim(); + if (cat === '派单') { + const rebate = parseFloat(document.getElementById('editRebate').value) || 0; + if (!quantity || !unit_price || !rebate || !boss) { showToast('请填写完整信息'); return; } + body = { quantity, unit_price, rebate, amount: quantity * rebate, boss, partner }; + } else { + if (!quantity || !unit_price || !boss) { showToast('请填写完整信息'); return; } + body = { quantity, unit_price, amount: quantity * unit_price, boss, partner }; + } + } else if (cat === '红包收入' || cat === '奶茶') { + const amount = parseFloat(document.getElementById('editAmount').value); + const source = document.getElementById('editSource').value.trim(); + if (!amount || !source) { showToast('请填写完整信息'); return; } + body = { amount, source }; + } else if (cat === '红包支出' || cat === '比心' || cat === '其他支出') { + const amount = parseFloat(document.getElementById('editAmount').value); + if (!amount) { showToast('请填写完整信息'); return; } + body = { amount }; + if (cat === '比心') { + const dest = document.getElementById('editDest').value.trim(); + body.destination = dest || '比心'; + } else { + const dest = document.getElementById('editDest').value.trim(); + if (!dest) { showToast('请填写完整信息'); return; } + body.destination = dest; + } + body.note = document.getElementById('editNote')?.value.trim() || ''; + } else { + const amount = parseFloat(document.getElementById('editAmount').value); + if (!amount) { showToast('请填写完整信息'); return; } + body = { amount }; + } + + if (time) body.created_at = new Date(time).toISOString(); + + try { + const res = await fetch(API + '/api/records/' + id, { + method: 'PUT', + headers: headers(), + body: JSON.stringify(body) + }); + if (!res.ok) { showToast('保存失败'); return; } + showToast('保存成功'); + acInvalidateCache(); + closeEditModal(); + refreshCurrentList(); + } catch (e) { showToast('网络错误'); } +} + +async function deleteEditRecord() { + const id = document.getElementById('editId').value; + if (!confirm('确定删除这条记录?')) return; + try { + const res = await fetch(API + '/api/records/' + id, { method: 'DELETE', headers: headers() }); + if (!res.ok) { showToast('删除失败'); return; } + showToast('已删除'); + closeEditModal(); + refreshCurrentList(); + } catch (e) { showToast('网络错误'); } +} diff --git a/public/js/import-export.js b/public/js/import-export.js new file mode 100644 index 0000000..0f80370 --- /dev/null +++ b/public/js/import-export.js @@ -0,0 +1,108 @@ +// 导出功能 +function showExportDialog() { + document.getElementById('exportModal').classList.add('show'); +} + +function closeExportModal() { + document.getElementById('exportModal').classList.remove('show'); +} + +async function doExport(format) { + closeExportModal(); + showToast('正在导出...'); + try { + const res = await fetch(API + '/api/export?format=' + format, { headers: headers() }); + if (!res.ok) { + const err = await res.json(); + showToast(err.error || '导出失败'); + return; + } + const disposition = res.headers.get('Content-Disposition'); + let filename = 'gamer-export.' + format; + if (disposition) { + const match = disposition.match(/filename="?([^"]+)"?/); + if (match) filename = match[1]; + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast('导出成功'); + } catch (e) { + showToast('导出失败: 网络错误'); + } +} + +// 导入功能 +let pendingImportContent = null; + +function triggerImport() { + document.getElementById('importFileInput').value = ''; + document.getElementById('importFileInput').click(); +} + +function handleImportFile(event) { + const file = event.target.files[0]; + if (!file) return; + const ext = file.name.split('.').pop().toLowerCase(); + if (ext !== 'json' && ext !== 'csv') { + showToast('请选择 JSON 或 CSV 文件'); + return; + } + const reader = new FileReader(); + reader.onload = function(e) { + const content = e.target.result; + pendingImportContent = content; + let count = 0; + try { + const trimmed = content.replace(/^\uFEFF/, '').trim(); + if (trimmed.startsWith('{')) { + const data = JSON.parse(trimmed); + count = data.recordCount || (data.records ? data.records.length : 0); + } else { + count = Math.max(0, trimmed.split('\n').filter(l => l.trim()).length - 1); + } + } catch (err) { + showToast('文件格式无法识别'); + return; + } + document.getElementById('importConfirmMsg').textContent = + '文件: ' + file.name + '\n检测到 ' + count + ' 条记录,确认导入?\n(重复记录将自动跳过)'; + document.getElementById('importConfirmModal').classList.add('show'); + }; + reader.readAsText(file, 'UTF-8'); +} + +function closeImportConfirm() { + document.getElementById('importConfirmModal').classList.remove('show'); + pendingImportContent = null; +} + +async function confirmImport() { + document.getElementById('importConfirmModal').classList.remove('show'); + if (!pendingImportContent) return; + showToast('正在导入...'); + try { + const res = await fetch(API + '/api/import', { + method: 'POST', + headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Authorization': 'Bearer ' + token }, + body: pendingImportContent + }); + const data = await res.json(); + if (!res.ok) { + showToast(data.error || '导入失败'); + return; + } + showToast('导入完成: ' + data.imported + '条新增, ' + data.skipped + '条跳过'); + pendingImportContent = null; + loadStats(); + loadTotalStats(); + } catch (e) { + showToast('导入失败: 网络错误'); + } +} diff --git a/public/js/month-picker.js b/public/js/month-picker.js new file mode 100644 index 0000000..f1aaadf --- /dev/null +++ b/public/js/month-picker.js @@ -0,0 +1,43 @@ +let pickerYear = currentYear; +let pickerMonth = currentMonth; + +function openMonthPicker() { + pickerYear = currentYear; + pickerMonth = currentMonth; + renderMonthPicker(); + document.getElementById('monthPickerOverlay').classList.add('show'); +} + +function closeMonthPicker() { + document.getElementById('monthPickerOverlay').classList.remove('show'); +} + +function changePickerYear(delta) { + pickerYear += delta; + renderMonthPicker(); +} + +function renderMonthPicker() { + document.getElementById('pickerYearDisplay').textContent = pickerYear; + const grid = document.getElementById('pickerMonthGrid'); + grid.innerHTML = ''; + for (let m = 1; m <= 12; m++) { + const btn = document.createElement('button'); + btn.textContent = m + '月'; + if (m === pickerMonth) btn.classList.add('active'); + btn.onclick = () => { + pickerMonth = m; + grid.querySelectorAll('button').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }; + grid.appendChild(btn); + } +} + +function confirmMonth() { + currentYear = pickerYear; + currentMonth = pickerMonth; + updateMonthDisplay(); + closeMonthPicker(); + loadStats(); +} diff --git a/public/js/order-list.js b/public/js/order-list.js new file mode 100644 index 0000000..f5b0ede --- /dev/null +++ b/public/js/order-list.js @@ -0,0 +1,300 @@ +let orderPage = 1; +let orderHasMore = true; +let orderLoading = false; + +async function loadOrders(append) { + if (orderLoading || (!append && !orderHasMore)) return; + orderLoading = true; + document.getElementById('orderLoading').style.display = 'block'; + try { + const res = await fetch(API + '/api/records/orders?page=' + orderPage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); + if (!res.ok) return; + const data = await res.json(); + const container = document.getElementById('orderList'); + if (!append) container.innerHTML = ''; + if (data.records.length === 0) { + if (orderPage === 1) container.innerHTML = '
暂无接单记录
'; + orderHasMore = false; + } else { + const grouped = {}; + data.records.forEach(r => { + const date = formatLocalDate(r.created_at); + if (!grouped[date]) grouped[date] = []; + grouped[date].push(r); + }); + let html = ''; + for (const [date, records] of Object.entries(grouped)) { + html += `
+
${date}
`; + records.forEach(r => { + const time = formatLocalTime(r.created_at); + html += `
+
+ ${r.boss || '-'} + ${(r.amount || 0).toFixed(2)} +
+
+ ${r.quantity || 0}单 × ${(r.unit_price || 0).toFixed(2)} + ${time}${r.partner ? ' · ' + r.partner : ''} +
+
`; + }); + html += '
'; + } + 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'; +} + +// 滚动加载 +window.addEventListener('scroll', () => { + if (document.getElementById('orderListPage').classList.contains('active')) { + if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) { + if (orderHasMore && !orderLoading) loadOrders(true); + } + } + if (document.getElementById('dispatchListPage').classList.contains('active')) { + if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) { + if (dispatchHasMore && !dispatchLoading) loadDispatches(true); + } + } + if (document.getElementById('otherIncomeListPage').classList.contains('active')) { + if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) { + if (otherIncomeHasMore && !otherIncomeLoading) loadOtherIncome(true); + } + } + if (document.getElementById('redEnvelopeListPage').classList.contains('active')) { + if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) { + if (redEnvelopeHasMore && !redEnvelopeLoading) loadRedEnvelope(true); + } + } + if (document.getElementById('milkTeaListPage').classList.contains('active')) { + if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) { + if (milkTeaHasMore && !milkTeaLoading) loadMilkTea(true); + } + } +}); + +// 红包及其他收入列表 +let otherIncomePage = 1; +let otherIncomeHasMore = true; +let otherIncomeLoading = false; + +async function loadOtherIncome(append) { + if (otherIncomeLoading || (!append && !otherIncomeHasMore)) return; + otherIncomeLoading = true; + document.getElementById('otherIncomeLoading').style.display = 'block'; + try { + const res = await fetch(API + '/api/records/other-income?page=' + otherIncomePage + '&limit=20&year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); + if (!res.ok) return; + const data = await res.json(); + const container = document.getElementById('otherIncomeList'); + if (!append) container.innerHTML = ''; + if (data.records.length === 0) { + if (otherIncomePage === 1) container.innerHTML = '
暂无红包及其他收入记录
'; + otherIncomeHasMore = false; + } else { + const grouped = {}; + data.records.forEach(r => { + const date = formatLocalDate(r.created_at); + if (!grouped[date]) grouped[date] = []; + grouped[date].push(r); + }); + let html = ''; + for (const [date, records] of Object.entries(grouped)) { + html += `
+
${date}
`; + records.forEach(r => { + const time = formatLocalTime(r.created_at); + const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-'); + html += `
+
+ ${displayCategory}${r.boss ? ' · ' + r.boss : ''} + ${(r.amount || 0).toFixed(2)} +
+
+ ${r.source || ''} + ${time} +
+
`; + }); + html += '
'; + } + container.insertAdjacentHTML('beforeend', html); + otherIncomeHasMore = data.records.length >= 20; + otherIncomePage++; + } + } catch (e) { console.error(e); } + otherIncomeLoading = false; + document.getElementById('otherIncomeLoading').style.display = 'none'; +} + +// 派单列表 +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 = '
暂无派单记录
'; + 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 += `
+
${date}
`; + records.forEach(r => { + const time = formatLocalTime(r.created_at); + html += `
+
+ ${r.boss || '-'} + ${(r.amount || 0).toFixed(2)} +
+
+ ${r.quantity || 0}单 × ${(r.rebate || r.amount / r.quantity).toFixed(2)}(单价${(r.unit_price || 0).toFixed(2)}) + ${time}${r.partner ? ' · ' + r.partner : ''} +
+
`; + }); + html += '
'; + } + container.insertAdjacentHTML('beforeend', html); + dispatchHasMore = data.records.length >= 20; + dispatchPage++; + } + } catch (e) { console.error(e); } + dispatchLoading = false; + document.getElementById('dispatchLoading').style.display = 'none'; +} + +// 红包收入列表 +let redEnvelopePage = 1; +let redEnvelopeHasMore = true; +let redEnvelopeLoading = false; + +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 = '
暂无红包收入记录
'; + 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 += `
+
${date}
`; + records.forEach(r => { + const time = formatLocalTime(r.created_at); + html += `
+
+ 红包 + ${(r.amount || 0).toFixed(2)} +
+
+ ${r.source || ''} + ${time} +
+
`; + }); + html += '
'; + } + container.insertAdjacentHTML('beforeend', html); + redEnvelopeHasMore = data.records.length >= 20; + redEnvelopePage++; + } + } catch (e) { console.error(e); } + redEnvelopeLoading = false; + document.getElementById('redEnvelopeLoading').style.display = 'none'; +} + +// 奶茶收入列表 +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 = '
暂无奶茶收入记录
'; + 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 += `
+
${date}
`; + records.forEach(r => { + const time = formatLocalTime(r.created_at); + html += `
+
+ 奶茶 + ${(r.amount || 0).toFixed(2)} +
+
+ ${r.source || ''} + ${time} +
+
`; + }); + html += '
'; + } + container.insertAdjacentHTML('beforeend', html); + milkTeaHasMore = data.records.length >= 20; + milkTeaPage++; + } + } catch (e) { console.error(e); } + milkTeaLoading = false; + document.getElementById('milkTeaLoading').style.display = 'none'; +} diff --git a/public/js/ranking.js b/public/js/ranking.js new file mode 100644 index 0000000..e15a6a2 --- /dev/null +++ b/public/js/ranking.js @@ -0,0 +1,63 @@ +let currentSort = 'total_income'; + +function switchSort(el) { + document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active')); + el.classList.add('active'); + currentSort = el.dataset.sort; +} + +async function loadRankingDetail() { + const startDate = document.getElementById('rankStartDate').value; + const endDate = document.getElementById('rankEndDate').value; + let url = API + '/api/stats/boss-ranking?sortBy=' + currentSort; + if (startDate) url += '&startDate=' + startDate; + if (endDate) { + const d = new Date(endDate); + d.setDate(d.getDate() + 1); + url += '&endDate=' + formatLocalDate(d); + } + try { + const res = await fetch(url, { headers: headers() }); + if (!res.ok) return; + const rows = await res.json(); + const container = document.getElementById('rankingDetailList'); + if (rows.length === 0) { + container.innerHTML = '
暂无数据
'; + return; + } + if (currentSort === 'dispatch_count') { + 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 `
+
${i + 1}
+
${r.boss}
+
+
${r.dispatch_count}单
+
`; + }).join(''); + } else if (currentSort === 'take_count') { + const max = Math.max(...rows.map(r => r.take_count), 1); + container.innerHTML = rows.map((r, i) => { + const pct = Math.max((r.take_count / max) * 100, 2); + return `
+
${i + 1}
+
${r.boss}
+
+
${r.take_count}单
+
`; + }).join(''); + } else { + const max = Math.max(...rows.map(r => r.total_income), 1); + container.innerHTML = rows.map((r, i) => { + const pct = Math.max((r.total_income / max) * 100, 2); + return `
+
${i + 1}
+
${r.boss}
+
+
${r.total_income.toFixed(2)}
+
`; + }).join(''); + } + } catch (e) { console.error(e); } +} diff --git a/public/js/record-form.js b/public/js/record-form.js new file mode 100644 index 0000000..f118391 --- /dev/null +++ b/public/js/record-form.js @@ -0,0 +1,174 @@ +function switchRecordType(type) { + currentType = type; + document.querySelectorAll('.type-btn').forEach((b, i) => { + b.classList.toggle('active', (type === 'income' && i === 0) || (type === 'expense' && i === 1)); + }); + document.getElementById('incomeCats').style.display = type === 'income' ? 'grid' : 'none'; + document.getElementById('expenseCats').style.display = type === 'expense' ? 'grid' : 'none'; + const firstCat = type === 'income' + ? document.querySelector('#incomeCats .category-item') + : document.querySelector('#expenseCats .category-item'); + const catName = type === 'income' ? '接单' : '红包支出'; + selectCategory(firstCat, catName); +} + +function selectCategory(el, cat) { + currentCategory = cat; + el.parentElement.querySelectorAll('.category-item').forEach(c => c.classList.remove('active')); + el.classList.add('active'); + renderForm(); +} + +function renderForm() { + const form = document.getElementById('recordForm'); + const now = new Date(); + const defaultTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + let html = ''; + + if (currentCategory === '接单') { + html = ` +
+
+
+
+
+
预计收入
0.00
+ + `; + } else if (currentCategory === '派单') { + html = ` +
+
+
+
+
+
+
预计收入
0.00
+ + `; + } else if (currentCategory === '红包收入') { + html = ` +
+
+
+ + `; + } else if (currentCategory === '红包支出') { + html = ` +
+
+
+ + `; + } else if (currentCategory === '其他支出') { + html = ` +
+
+
+
+ + `; + } else if (currentCategory === '奶茶') { + html = ` +
+
+
+ + `; + } else if (currentCategory === '比心') { + html = ` +
+
+
+ + `; + } + + form.innerHTML = html; + + // 绑定自动补全 + if (currentCategory === '接单' || currentCategory === '派单') { + acSetup('fBoss', 'boss'); + acSetup('fPartner', 'partner'); + } else if (currentCategory === '红包收入' || currentCategory === '奶茶') { + acSetup('fSource', 'source'); + } else if (currentCategory === '红包支出' || currentCategory === '其他支出') { + acSetup('fDest', 'destination'); + } else if (currentCategory === '比心') { + acSetup('fDest', 'destination'); + } +} + +function calcAmount() { + const q = parseFloat(document.getElementById('fQuantity')?.value) || 0; + let price; + if (currentCategory === '派单') { + price = parseFloat(document.getElementById('fRebate')?.value) || 0; + } else { + price = parseFloat(document.getElementById('fPrice')?.value) || 0; + } + const el = document.getElementById('calcTotal'); + if (el) el.textContent = '' + (q * price).toFixed(2); +} + +async function submitRecord() { + let body = { type: currentType, category: currentCategory }; + const fTime = document.getElementById('fTime')?.value; + if (fTime) body.created_at = new Date(fTime).toISOString(); + + if (currentCategory === '接单' || currentCategory === '派单') { + const quantity = parseInt(document.getElementById('fQuantity').value); + const unit_price = parseFloat(document.getElementById('fPrice').value); + const boss = document.getElementById('fBoss').value.trim(); + const partner = document.getElementById('fPartner')?.value.trim() || ''; + if (currentCategory === '派单') { + const rebate = parseFloat(document.getElementById('fRebate').value); + if (!quantity || !unit_price || !rebate || !boss) { showToast('请填写完整信息'); return; } + body.quantity = quantity; + body.unit_price = unit_price; + body.rebate = rebate; + body.amount = quantity * rebate; + } else { + if (!quantity || !unit_price || !boss) { showToast('请填写完整信息'); return; } + body.quantity = quantity; + body.unit_price = unit_price; + body.amount = quantity * unit_price; + } + body.boss = boss; + body.partner = partner; + } else if (currentCategory === '红包收入' || currentCategory === '奶茶') { + const amount = parseFloat(document.getElementById('fAmount').value); + const source = document.getElementById('fSource').value.trim(); + if (!amount || !source) { showToast('请填写完整信息'); return; } + body.amount = amount; + body.source = source; + body.boss = source; // 红包来源就是老板,同步设置 boss 字段 + } else if (currentCategory === '红包支出' || currentCategory === '其他支出' || currentCategory === '比心') { + const amount = parseFloat(document.getElementById('fAmount').value); + if (!amount) { showToast('请填写完整信息'); return; } + body.amount = amount; + if (currentCategory === '比心') { + const dest = document.getElementById('fDest').value.trim(); + body.destination = dest || '比心'; + } else { + const dest = document.getElementById('fDest').value.trim(); + if (!dest) { showToast('请填写完整信息'); return; } + body.destination = dest; + } + body.note = document.getElementById('fNote')?.value.trim() || ''; + } + + try { + const res = await fetch(API + '/api/records', { + method: 'POST', + headers: headers(), + body: JSON.stringify(body) + }); + if (!res.ok) { showToast('保存失败'); return; } + showToast('保存成功'); + acInvalidateCache(); + setTimeout(() => showPage('homePage'), 1000); + } catch (e) { + showToast('网络错误'); + } +} diff --git a/public/js/stats.js b/public/js/stats.js new file mode 100644 index 0000000..a694cdc --- /dev/null +++ b/public/js/stats.js @@ -0,0 +1,485 @@ +async function loadStats() { + try { + const res = await fetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); + if (!res.ok) return; + const s = await res.json(); + document.getElementById('takeCount').textContent = s.take_order_count; + document.getElementById('dispatchCount').textContent = s.dispatch_order_count; + document.getElementById('takeIncome').textContent = '' + s.take_order_income.toFixed(2); + document.getElementById('dispatchIncome').textContent = '' + s.dispatch_order_income.toFixed(2); + document.getElementById('redEnvelopeCount').textContent = s.redEnvelope_count || 0; + document.getElementById('redEnvelopeIncome').textContent = '' + (s.redEnvelope_income || 0).toFixed(2); + document.getElementById('milkTeaCount').textContent = s.milkTea_count || 0; + document.getElementById('milkTeaIncome').textContent = '' + (s.milkTea_income || 0).toFixed(2); + document.getElementById('totalIncome').textContent = '' + s.total_income.toFixed(2); + document.getElementById('totalExpense').textContent = '' + s.total_expense.toFixed(2); + document.getElementById('netIncome').textContent = '' + (s.total_income - s.total_expense).toFixed(2); + loadBossRankingPreview(); + loadPartnerRankingPreview(); + loadWeeklyChart(); + } catch (e) { console.error(e); } +} + +async function loadWeeklyChart() { + try { + const res = await fetch(API + '/api/stats/daily-income', { headers: headers() }); + if (!res.ok) return; + const days = await res.json(); + const container = document.getElementById('weeklyChartBars'); + const max = Math.max(...days.map(d => d.income), 1); + container.innerHTML = days.map(d => { + const pct = Math.max((d.income / max) * 100, 3); + const label = d.date.slice(5).replace('-', '/'); + const amount = d.income > 0 ? '' + d.income.toFixed(0) : '0'; + return `
+ ${amount} +
+ ${label} +
`; + }).join(''); + } catch (e) { console.error(e); } +} + +let currentDailyDate = ''; + +async function openDailyDetail(date) { + currentDailyDate = date; + document.getElementById('dailyDetailTitle').textContent = date + ' 收入'; + document.getElementById('dailyNavDate').textContent = date; + document.getElementById('dailySummary').innerHTML = '
加载中...
'; + document.getElementById('dailyDetailList').innerHTML = ''; + if (!document.getElementById('dailyDetailPage').classList.contains('active')) { + showPage('dailyDetailPage'); + } + try { + const res = await fetch(API + '/api/stats/daily-detail?date=' + date, { headers: headers() }); + if (!res.ok) return; + const data = await res.json(); + document.getElementById('dailySummary').innerHTML = ` +
${data.total_income.toFixed(2)}
+
当日总收入
+ `; + const container = document.getElementById('dailyDetailList'); + if (data.records.length === 0) { + container.innerHTML = '
当日暂无收入记录
'; + return; + } + container.innerHTML = data.records.map(r => { + const time = formatLocalTime(r.created_at); + const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-'); + return `
+
+ ${displayCategory}${r.boss ? ' · ' + r.boss : ''} + ${(r.amount || 0).toFixed(2)} +
+
+ ${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 || '')} + ${time}${r.partner ? ' · ' + r.partner : ''} +
+
`; + }).join(''); + } catch (e) { console.error(e); } +} + +function switchDailyDate(offset) { + const d = new Date(currentDailyDate); + d.setDate(d.getDate() + offset); + openDailyDetail(formatLocalDate(d)); +} + +let monthlyDetailYear, monthlyDetailMonth; + +function openMonthlyDetail(type) { + monthlyDetailYear = currentYear; + monthlyDetailMonth = currentMonth; + loadMonthlyDetail(type); +} + +function switchMonthlyDetail(type, offset) { + monthlyDetailMonth += offset; + if (monthlyDetailMonth > 12) { monthlyDetailMonth = 1; monthlyDetailYear++; } + if (monthlyDetailMonth < 1) { monthlyDetailMonth = 12; monthlyDetailYear--; } + loadMonthlyDetail(type); +} + +function openYearlyIncome() { + loadYearlyNetIncome(); +} + +async function loadYearlyNetIncome() { + document.getElementById('yearlyNetIncomeSummary').innerHTML = '
加载中...
'; + document.getElementById('yearlyNetIncomeList').innerHTML = ''; + + if (!document.getElementById('yearlyNetIncomePage').classList.contains('active')) { + showPage('yearlyNetIncomePage'); + } + + try { + const res = await fetch(API + '/api/stats/yearly-net-income', { headers: headers() }); + if (!res.ok) return; + const data = await res.json(); + + const grandNetIncome = (data.grandTotal.total_income || 0) - (data.grandTotal.total_expense || 0); + const grandColor = grandNetIncome >= 0 ? '#27ae60' : '#e74c3c'; + document.getElementById('yearlyNetIncomeSummary').innerHTML = ` +
${grandNetIncome.toFixed(2)}
+
累计净收入
+ `; + + const container = document.getElementById('yearlyNetIncomeList'); + const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; + + if (data.yearlyStats.length === 0) { + container.innerHTML = '
暂无数据
'; + return; + } + + container.innerHTML = data.yearlyStats.map(y => { + const year = y.year; + const income = y.total_income || 0; + const expense = y.total_expense || 0; + const net = income - expense; + const netColor = net >= 0 ? '#27ae60' : '#e74c3c'; + + const monthlyData = data.monthlyData[year] || []; + const monthlyHtml = monthlyData.map(m => { + const mIncome = m.total_income || 0; + const mExpense = m.total_expense || 0; + const mNet = mIncome - mExpense; + const mColor = mNet >= 0 ? '#27ae60' : '#e74c3c'; + return `
+ ${monthNames[parseInt(m.month) - 1]} + ${mNet.toFixed(2)} +
`; + }).join(''); + + return `
+
+ ${year}年 + ${net.toFixed(2)} +
+
+ 收入: ${income.toFixed(2)} | 支出: ${expense.toFixed(2)} +
+
${monthlyHtml}
+
`; + }).join(''); + } catch (e) { console.error(e); } +} + +async function loadMonthlyDetail(type) { + const pageId = type === 'income' ? 'monthlyIncomeDetailPage' : 'monthlyExpenseDetailPage'; + const titleId = type === 'income' ? 'monthlyIncomeTitle' : 'monthlyExpenseTitle'; + const navId = type === 'income' ? 'monthlyIncomeNav' : 'monthlyExpenseNav'; + const summaryId = type === 'income' ? 'monthlyIncomeSummary' : 'monthlyExpenseSummary'; + const listId = type === 'income' ? 'monthlyIncomeList' : 'monthlyExpenseList'; + const label = type === 'income' ? '收入' : '支出'; + + document.getElementById(titleId).textContent = `${monthlyDetailYear}年${monthlyDetailMonth}月${label}`; + document.getElementById(navId).textContent = `${monthlyDetailYear}年${monthlyDetailMonth}月`; + document.getElementById(summaryId).innerHTML = '
加载中...
'; + document.getElementById(listId).innerHTML = ''; + + if (!document.getElementById(pageId).classList.contains('active')) { + showPage(pageId); + } + + try { + const res = await fetch(API + '/api/stats/monthly-records?type=' + type + '&year=' + monthlyDetailYear + '&month=' + monthlyDetailMonth, { headers: headers() }); + if (!res.ok) return; + const data = await res.json(); + const color = type === 'income' ? '#27ae60' : '#e74c3c'; + document.getElementById(summaryId).innerHTML = ` +
${data.total.toFixed(2)}
+
当月总${label}
+ `; + const container = document.getElementById(listId); + if (data.records.length === 0) { + container.innerHTML = '
当月暂无' + label + '记录
'; + return; + } + const grouped = {}; + data.records.forEach(r => { + const date = formatLocalDate(r.created_at); + if (!grouped[date]) grouped[date] = []; + grouped[date].push(r); + }); + let html = ''; + for (const [date, records] of Object.entries(grouped)) { + html += `
${date}
`; + records.forEach(r => { + const time = formatLocalTime(r.created_at); + const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-'); + html += `
+
+ ${displayCategory}${r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : ''} + ${type === 'expense' ? '-' : ''}${(r.amount || 0).toFixed(2)} +
+
+ ${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 || '')} + ${time}${r.partner ? ' · ' + r.partner : ''} +
+
`; + }); + html += '
'; + } + container.innerHTML = html; + } catch (e) { console.error(e); } +} + +async function loadBossRankingPreview() { + try { + const res = await fetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); + if (!res.ok) return; + const rows = await res.json(); + const container = document.getElementById('rankingPreviewList'); + if (rows.length === 0) { + container.innerHTML = '
暂无数据
'; + return; + } + const max = Math.max(...rows.map(r => r.total_income), 1); + container.innerHTML = rows.map((r, i) => { + const pct = Math.max((r.total_income / max) * 100, 2); + return `
+
${i + 1}
+
${r.boss}
+
+
${r.total_income.toFixed(2)}
+
`; + }).join(''); + } catch (e) { console.error(e); } +} + +let currentBossName = ''; +let bossDetailFrom = 'homePage'; + +function openBossDetail(boss, from) { + currentBossName = boss; + bossDetailFrom = from || 'homePage'; + loadBossDetail(); +} + +function bossDetailGoBack() { + showPage(bossDetailFrom); +} + +async function loadBossDetail() { + document.getElementById('bossDetailTitle').textContent = currentBossName + ' 交易记录'; + document.getElementById('bossDetailSummary').innerHTML = '
加载中...
'; + document.getElementById('bossDetailList').innerHTML = ''; + showPage('bossDetailPage'); + + try { + const res = await fetch(API + '/api/stats/boss-records?boss=' + encodeURIComponent(currentBossName), { headers: headers() }); + if (!res.ok) return; + const data = await res.json(); + document.getElementById('bossDetailSummary').innerHTML = ` +
+
+
${data.boss_total_expense.toFixed(2)}
+
老板总支出
+
+
+
${data.my_income.toFixed(2)}
+
我的总收入
+
+
+ `; + const container = document.getElementById('bossDetailList'); + if (data.records.length === 0) { + container.innerHTML = '
暂无交易记录
'; + return; + } + const grouped = {}; + data.records.forEach(r => { + const d = new Date(r.created_at); + const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); + if (!grouped[key]) grouped[key] = { records: [], total: 0 }; + grouped[key].records.push(r); + grouped[key].total += r.amount || 0; + }); + let html = ''; + for (const [month, group] of Object.entries(grouped)) { + html += `
${month} 收入 ${group.total.toFixed(2)}
`; + group.records.forEach(r => { + const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at); + const displayCategory = r.category === '红包收入' ? '红包' : r.category; + html += `
+
+ ${displayCategory}${r.partner ? ' · ' + r.partner : ''} + ${(r.amount || 0).toFixed(2)} +
+
+ ${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 || '')} + ${time} +
+
`; + }); + html += '
'; + } + container.innerHTML = html; + } catch (e) { console.error(e); } +} + +async function loadPartnerRankingPreview() { + try { + const res = await fetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); + if (!res.ok) return; + const rows = await res.json(); + const container = document.getElementById('partnerRankingPreviewList'); + if (rows.length === 0) { + container.innerHTML = '
暂无数据
'; + 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 `
+
${i + 1}
+
${r.partner}
+
+
${r.dispatch_count}单
+
`; + }).join(''); + } catch (e) { console.error(e); } +} + +function openPartnerRankingPage() { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, '0'); + document.getElementById('partnerRankStartDate').value = y + '-' + m + '-01'; + document.getElementById('partnerRankEndDate').value = y + '-' + m + '-' + String(now.getDate()).padStart(2, '0'); + showPage('partnerRankingPage'); + loadPartnerRankingDetail(); +} + +async function loadPartnerRankingDetail() { + const startDate = document.getElementById('partnerRankStartDate').value; + const endDate = document.getElementById('partnerRankEndDate').value; + let url = API + '/api/stats/partner-ranking'; + const params = []; + if (startDate) params.push('startDate=' + startDate); + if (endDate) { + const d = new Date(endDate); + d.setDate(d.getDate() + 1); + params.push('endDate=' + formatLocalDate(d)); + } + if (params.length) url += '?' + params.join('&'); + console.log('partner-ranking url:', url); + try { + const res = await fetch(url, { headers: headers() }); + console.log('partner-ranking res.ok:', res.ok); + if (!res.ok) return; + const data = await res.json(); + console.log('partner-ranking data:', data); + const rows = data.rows || []; + console.log('rows:', rows); + const summary = data.summary || { total_partner_income: 0, total_rebate_income: 0, total_milkTea_income: 0 }; + + // 计算派单总数(从列表) + const totalDispatch = rows.reduce((sum, r) => sum + (r.dispatch_count || 0), 0); + console.log('totalDispatch:', totalDispatch); + + // 更新统计卡片 + document.getElementById('partnerTotalDispatchCount').textContent = totalDispatch; + document.getElementById('totalPartnerIncome').textContent = (summary.total_partner_income || 0).toFixed(2); + document.getElementById('totalRebateIncome').textContent = summary.total_rebate_income.toFixed(2); + document.getElementById('totalMilkTeaIncome').textContent = (summary.total_milkTea_income || 0).toFixed(2); + + const container = document.getElementById('partnerRankingDetailList'); + if (rows.length === 0) { + container.innerHTML = '
暂无数据
'; + 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 `
+
${i + 1}
+
${r.partner}
+
+
${r.dispatch_count}单
+
`; + }).join(''); + } catch (e) { console.error(e); } +} + +let currentPartnerName = ''; +let partnerDetailFrom = 'homePage'; + +function openPartnerDetail(partner, from) { + currentPartnerName = partner; + partnerDetailFrom = from || 'homePage'; + loadPartnerDetail(); +} + +function partnerDetailGoBack() { + showPage(partnerDetailFrom); +} + +async function loadPartnerDetail() { + document.getElementById('partnerDetailTitle').textContent = currentPartnerName + ' 交易记录'; + document.getElementById('partnerDetailSummary').innerHTML = '
加载中...
'; + document.getElementById('partnerDetailList').innerHTML = ''; + showPage('partnerDetailPage'); + + try { + const res = await fetch(API + '/api/stats/partner-records?partner=' + encodeURIComponent(currentPartnerName), { headers: headers() }); + if (!res.ok) return; + const data = await res.json(); + document.getElementById('partnerDetailSummary').innerHTML = ` +
+
+
${data.dispatch_count}
+
总派单数
+
+
+
${data.spread_income.toFixed(2)}
+
陪玩总收入
+
+
+
${data.rebate_income.toFixed(2)}
+
总返点收入
+
+
+
${data.other_income.toFixed(2)}
+
红包及其他
+
+
+ `; + const container = document.getElementById('partnerDetailList'); + if (data.records.length === 0) { + container.innerHTML = '
暂无交易记录
'; + return; + } + const grouped = {}; + data.records.forEach(r => { + const d = new Date(r.created_at); + const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); + if (!grouped[key]) grouped[key] = { records: [], total: 0 }; + grouped[key].records.push(r); + grouped[key].total += r.amount || 0; + }); + let html = ''; + for (const [month, group] of Object.entries(grouped)) { + html += `
${month} 收入 ${group.total.toFixed(2)}
`; + group.records.forEach(r => { + const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at); + const displayCategory = r.category === '红包收入' ? '红包' : r.category; + html += `
+
+ ${displayCategory}${r.boss ? ' · ' + r.boss : ''} + ${(r.amount || 0).toFixed(2)} +
+
+ ${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 || '')} + ${time} +
+
`; + }); + html += '
'; + } + container.innerHTML = html; + } catch (e) { console.error(e); } +} diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..af4fba6 --- /dev/null +++ b/server/app.js @@ -0,0 +1,40 @@ +const express = require('express'); +const cors = require('cors'); +const path = require('path'); + +const db = require('./db'); +const { router: authRouter, auth } = require('./routes/auth'); +const statsRouter = require('./routes/stats'); +const recordsRouter = require('./routes/records'); +const ioRouter = require('./routes/io'); + +const app = express(); + +app.use(cors()); +app.use(express.json()); +app.use(express.static(path.join(__dirname, '..', 'public'))); + +// 联系人自动补全 +app.get('/api/contacts', auth, (req, res) => { + const field = req.query.field; + const allowed = ['boss', 'partner', 'source', 'destination']; + if (!field || !allowed.includes(field)) return res.status(400).json({ error: '无效字段' }); + + const rows = db.prepare(` + SELECT ${field} as name, MAX(created_at) as last_used, COUNT(*) as freq + FROM records WHERE user_id = ? AND ${field} IS NOT NULL AND ${field} != '' + GROUP BY ${field} ORDER BY MAX(created_at) DESC + `).all(req.user.id); + res.json(rows.map(r => r.name)); +}); + +// 路由挂载 +app.use('/api', authRouter); +app.use('/api/stats', auth, statsRouter); +app.use('/api/records', auth, recordsRouter); +app.use('/api', auth, ioRouter); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, '0.0.0.0', () => { + console.log(`服务器运行在 http://0.0.0.0:${PORT}`); +}); diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..8965e48 --- /dev/null +++ b/server/db.js @@ -0,0 +1,84 @@ +const Database = require('better-sqlite3'); +const bcrypt = require('bcryptjs'); +const path = require('path'); + +const db = new Database(path.join(__dirname, '..', 'data.db')); + +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// 创建表 +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('income', 'expense')), + category TEXT NOT NULL, + amount REAL NOT NULL, + quantity INTEGER, + unit_price REAL, + boss TEXT, + partner TEXT, + source TEXT, + destination TEXT, + note TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); +`); + +// 迁移:去掉 category 的 CHECK 约束 +try { + const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get(); + if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) { + db.exec(` + CREATE TABLE records_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('income', 'expense')), + category TEXT NOT NULL, + amount REAL NOT NULL, + quantity INTEGER, + unit_price REAL, + boss TEXT, + partner TEXT, + source TEXT, + destination TEXT, + note TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + INSERT INTO records_new SELECT * FROM records; + DROP TABLE records; + ALTER TABLE records_new RENAME TO records; + `); + } +} catch (e) { + console.log('Migration skipped:', e.message); +} + +// 迁移:添加 rebate 列(派单返点) +try { + const cols = db.prepare("PRAGMA table_info(records)").all(); + if (!cols.find(c => c.name === 'rebate')) { + db.exec("ALTER TABLE records ADD COLUMN rebate REAL"); + } +} catch (e) { + console.log('Rebate migration skipped:', e.message); +} + +// 初始化默认用户 lizhilun +const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun'); +if (!existingUser) { + const hash = bcrypt.hashSync('lizhilun', 10); + db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash); +} + +module.exports = db; diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..e33cf4e --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,32 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); +const db = require('../db'); + +const router = express.Router(); +const SECRET = 'gamer-order-secret-2024'; + +// JWT 中间件 +function auth(req, res, next) { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) return res.status(401).json({ error: '未登录' }); + try { + req.user = jwt.verify(token, SECRET); + next(); + } catch { + res.status(401).json({ error: '登录已过期' }); + } +} + +// 登录 +router.post('/login', (req, res) => { + const { username, password } = req.body; + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); + if (!user || !bcrypt.compareSync(password, user.password)) { + return res.status(401).json({ error: '用户名或密码错误' }); + } + const token = jwt.sign({ id: user.id, username: user.username }, SECRET, { expiresIn: '7d' }); + res.json({ token, username: user.username }); +}); + +module.exports = { router, auth }; diff --git a/server/routes/io.js b/server/routes/io.js new file mode 100644 index 0000000..834409d --- /dev/null +++ b/server/routes/io.js @@ -0,0 +1,136 @@ +const express = require('express'); +const db = require('../db'); +const { parseCSVLine } = require('../utils/helpers'); + +const router = express.Router(); + +// 导出数据 +router.get('/export', (req, res) => { + const format = req.query.format; + if (!format || !['json', 'csv'].includes(format)) { + return res.status(400).json({ error: '请指定格式: json 或 csv' }); + } + + const records = db.prepare( + 'SELECT type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at FROM records WHERE user_id = ? ORDER BY created_at DESC' + ).all(req.user.id); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `gamer-export-${timestamp}`; + + if (format === 'json') { + const exportData = { + exportVersion: 1, + exportedAt: new Date().toISOString(), + username: req.user.username, + recordCount: records.length, + records + }; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`); + return res.send(JSON.stringify(exportData, null, 2)); + } + + // CSV with UTF-8 BOM + const BOM = '\uFEFF'; + const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at']; + const csvRows = records.map(r => { + return cols.map(col => { + const val = r[col]; + if (val === null || val === undefined) return ''; + const str = String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; + }).join(','); + }); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`); + res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n')); +}); + +// 导入数据 +router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res) => { + try { + const raw = req.body; + if (!raw || typeof raw !== 'string') { + return res.status(400).json({ error: '未收到文件内容' }); + } + + let records = []; + const requiredFields = ['type', 'category', 'amount', 'created_at']; + const allFields = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at']; + const trimmed = raw.replace(/^\uFEFF/, '').trim(); + + if (trimmed.startsWith('{')) { + const data = JSON.parse(trimmed); + if (!data.exportVersion || !Array.isArray(data.records)) { + return res.status(400).json({ error: '无效的JSON导出文件格式' }); + } + records = data.records; + } else { + const lines = trimmed.split('\n').map(l => l.replace(/\r$/, '')); + if (lines.length < 2) { + return res.status(400).json({ error: 'CSV文件为空或格式不正确' }); + } + if (lines[0] !== allFields.join(',')) { + return res.status(400).json({ error: 'CSV列头不匹配,请使用本系统导出的文件' }); + } + const csvHeaders = lines[0].split(','); + for (let i = 1; i < lines.length; i++) { + if (!lines[i].trim()) continue; + const values = parseCSVLine(lines[i]); + const record = {}; + csvHeaders.forEach((h, idx) => { record[h] = values[idx] || null; }); + if (record.amount) record.amount = parseFloat(record.amount); + if (record.quantity) record.quantity = parseInt(record.quantity) || null; + if (record.unit_price) record.unit_price = parseFloat(record.unit_price) || null; + if (record.rebate) record.rebate = parseFloat(record.rebate) || null; + records.push(record); + } + } + + for (const r of records) { + for (const f of requiredFields) { + if (!r[f] && r[f] !== 0) { + return res.status(400).json({ error: `记录缺少必填字段: ${f}` }); + } + } + if (!['income', 'expense'].includes(r.type)) { + return res.status(400).json({ error: `无效的类型: ${r.type}` }); + } + } + + const existingCheck = db.prepare( + 'SELECT id FROM records WHERE user_id = ? AND created_at = ? AND category = ? AND amount = ?' + ); + const insertStmt = db.prepare(` + INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + let imported = 0, skipped = 0; + const insertMany = db.transaction((recs) => { + for (const r of recs) { + if (existingCheck.get(req.user.id, r.created_at, r.category, r.amount)) { + skipped++; continue; + } + insertStmt.run(req.user.id, r.type, r.category, r.amount, + r.quantity || null, r.unit_price || null, r.rebate || null, + r.boss || null, r.partner || null, + r.source || null, r.destination || null, + r.note || null, r.created_at); + imported++; + } + }); + insertMany(records); + + res.json({ imported, skipped, total: records.length }); + } catch (e) { + console.error('Import error:', e); + res.status(400).json({ error: '文件解析失败: ' + e.message }); + } +}); + +module.exports = router; diff --git a/server/routes/records.js b/server/routes/records.js new file mode 100644 index 0000000..c28fae4 --- /dev/null +++ b/server/routes/records.js @@ -0,0 +1,134 @@ +const express = require('express'); +const db = require('../db'); +const { getMonthRange } = require('../utils/helpers'); + +const router = express.Router(); + +// 新增记录 +router.post('/', (req, res) => { + const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body; + const stmt = db.prepare(` + INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run(req.user.id, type, category, amount, quantity || null, unit_price || null, rebate || null, boss || null, partner || null, source || null, destination || null, note || null, created_at || new Date().toISOString()); + res.json({ id: result.lastInsertRowid }); +}); + +// 接单列表 +router.get('/orders', (req, res) => { + const { page = 1, limit = 20 } = req.query; + const offset = (Number(page) - 1) * Number(limit); + const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); + + const total = db.prepare( + `SELECT COUNT(*) as count FROM records WHERE user_id = ? AND category = '接单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?` + ).get(req.user.id, startDate, endDate).count; + + const records = db.prepare( + `SELECT * FROM records WHERE user_id = ? AND category = '接单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?` + ).all(req.user.id, startDate, endDate, Number(limit), offset); + + res.json({ records, total, page: Number(page), limit: Number(limit) }); +}); + +// 红包及其他收入列表 +router.get('/other-income', (req, res) => { + const { page = 1, limit = 20 } = req.query; + const offset = (Number(page) - 1) * Number(limit); + const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); + + const total = db.prepare( + `SELECT COUNT(*) as count FROM records WHERE user_id = ? AND type = 'income' AND category != '接单' AND category != '派单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?` + ).get(req.user.id, startDate, endDate).count; + + const records = db.prepare( + `SELECT * FROM records WHERE user_id = ? AND type = 'income' AND category != '接单' AND category != '派单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?` + ).all(req.user.id, startDate, endDate, Number(limit), offset); + + res.json({ records, total, page: Number(page), limit: Number(limit) }); +}); + +// 红包收入列表 +router.get('/red-envelope', (req, res) => { + const { page = 1, limit = 20 } = req.query; + const offset = (Number(page) - 1) * Number(limit); + const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); + + const total = db.prepare( + `SELECT COUNT(*) as count FROM records WHERE user_id = ? AND type = 'income' AND category = '红包收入' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?` + ).get(req.user.id, startDate, endDate).count; + + const records = db.prepare( + `SELECT * FROM records WHERE user_id = ? AND type = 'income' AND category = '红包收入' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?` + ).all(req.user.id, startDate, endDate, Number(limit), offset); + + res.json({ records, total, page: Number(page), limit: Number(limit) }); +}); + +// 奶茶收入列表 +router.get('/milk-tea', (req, res) => { + const { page = 1, limit = 20 } = req.query; + const offset = (Number(page) - 1) * Number(limit); + const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); + + const total = db.prepare( + `SELECT COUNT(*) as count FROM records WHERE user_id = ? AND type = 'income' AND category = '奶茶' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?` + ).get(req.user.id, startDate, endDate).count; + + const records = db.prepare( + `SELECT * FROM records WHERE user_id = ? AND type = 'income' AND category = '奶茶' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?` + ).all(req.user.id, startDate, endDate, Number(limit), offset); + + res.json({ records, total, page: Number(page), limit: Number(limit) }); +}); + +// 派单列表 +router.get('/dispatches', (req, res) => { + const { page = 1, limit = 20 } = req.query; + const offset = (Number(page) - 1) * Number(limit); + const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); + + const total = db.prepare( + `SELECT COUNT(*) as count FROM records WHERE user_id = ? AND category = '派单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?` + ).get(req.user.id, startDate, endDate).count; + + const records = db.prepare( + `SELECT * FROM records WHERE user_id = ? AND category = '派单' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? ORDER BY created_at DESC LIMIT ? OFFSET ?` + ).all(req.user.id, startDate, endDate, Number(limit), offset); + + res.json({ records, total, page: Number(page), limit: Number(limit) }); +}); + +// 获取记录列表 +router.get('/', (req, res) => { + const { type, page = 1, limit = 20 } = req.query; + const offset = (page - 1) * limit; + let sql = 'SELECT * FROM records WHERE user_id = ?'; + const params = [req.user.id]; + if (type) { + sql += ' AND type = ?'; + params.push(type); + } + sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + params.push(Number(limit), Number(offset)); + res.json(db.prepare(sql).all(...params)); +}); + +// 更新记录 +router.put('/:id', (req, res) => { + const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body; + db.prepare(` + UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ? + WHERE id = ? AND user_id = ? + `).run(quantity || null, unit_price || null, rebate || null, amount, boss || null, partner || null, source || null, destination || null, note || null, created_at || null, req.params.id, req.user.id); + res.json({ success: true }); +}); + +// 删除记录 +router.delete('/:id', (req, res) => { + db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/server/routes/stats.js b/server/routes/stats.js new file mode 100644 index 0000000..74fe008 --- /dev/null +++ b/server/routes/stats.js @@ -0,0 +1,423 @@ +const express = require('express'); +const db = require('../db'); +const { getMonthRange } = require('../utils/helpers'); + +const router = express.Router(); + +// 格式化本地日期为 YYYY-MM-DD +function formatLocalDate(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +// 本月统计 +router.get('/monthly', (req, res) => { + const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); + const stats = db.prepare(` + SELECT + COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_order_count, + COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as take_order_income, + COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_order_count, + COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as dispatch_order_income, + COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income, + COALESCE(SUM(CASE WHEN category = '红包收入' THEN 1 ELSE 0 END), 0) as redEnvelope_count, + COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as milkTea_income, + COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count, + COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income, + COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense + FROM records + WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + `).get(req.user.id, startDate, endDate); + res.json(stats); +}); + +// 月收入/支出记录列表 +router.get('/monthly-records', (req, res) => { + const { type } = req.query; + if (!type || !['income', 'expense'].includes(type)) return res.status(400).json({ error: '缺少类型参数' }); + const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); + + const summary = db.prepare(` + SELECT COALESCE(SUM(amount), 0) as total + FROM records WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + `).get(req.user.id, type, startDate, endDate); + + const records = db.prepare(` + SELECT * FROM records + WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + ORDER BY created_at DESC + `).all(req.user.id, type, startDate, endDate); + + res.json({ total: summary.total, records }); +}); + +// 年度净收入按年统计 +router.get('/yearly-net-income', (req, res) => { + // 获取所有年份数据 + const yearlyStats = db.prepare(` + SELECT + strftime('%Y', created_at) as year, + COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income, + COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense + FROM records + WHERE user_id = ? + GROUP BY year ORDER BY year DESC + `).all(req.user.id); + + // 获取所有年份的每月数据 + const monthlyByYear = db.prepare(` + SELECT + strftime('%Y', created_at) as year, + strftime('%m', created_at) as month, + COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income, + COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense + FROM records + WHERE user_id = ? + GROUP BY year, month ORDER BY year DESC, month + `).all(req.user.id); + + // 按年份分组每月数据 + const monthlyData = {}; + monthlyByYear.forEach(m => { + if (!monthlyData[m.year]) monthlyData[m.year] = []; + monthlyData[m.year].push({ + month: m.month, + total_income: m.total_income, + total_expense: m.total_expense + }); + }); + + const grandTotal = db.prepare(` + SELECT + COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income, + COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense + FROM records WHERE user_id = ? + `).get(req.user.id); + + res.json({ yearlyStats, monthlyData, grandTotal }); +}); + +// 陪玩派单榜预览 +router.get('/partner-ranking/preview', (req, res) => { + const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); + const rows = db.prepare(` + SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income + FROM records + WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != '' + AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + GROUP BY partner ORDER BY dispatch_count DESC LIMIT 10 + `).all(req.user.id, startDate, endDate); + res.json(rows); +}); + +// 陪玩派单榜详情 +router.get('/partner-ranking', (req, res) => { + const { startDate, endDate } = req.query; + let start, end; + if (startDate && endDate) { + start = startDate; + end = endDate; + } else { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + start = `${year}-${month}-01`; + const endMonth = now.getMonth() + 2 > 12 ? 1 : now.getMonth() + 2; + const endYear = endMonth === 1 ? year + 1 : year; + end = `${endYear}-${String(endMonth).padStart(2, '0')}-01`; + } + const rows = db.prepare(` + SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(quantity * COALESCE(unit_price, 0)), 0) as dispatch_income, COALESCE(SUM(amount), 0) as rebate_income + FROM records + WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != '' + AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + GROUP BY partner ORDER BY dispatch_count DESC + `).all(req.user.id, start, end); + // 计算每个陪玩的陪玩总收入:(单价-返点)*单数 = 单价*单数 - 返点 + rows.forEach(row => { + row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0); + }); + const summary = db.prepare(` + SELECT + COALESCE(SUM(quantity), 0) as total_dispatch_count, + COALESCE(SUM(quantity * COALESCE(unit_price, 0)), 0) as total_dispatch_income, + COALESCE(SUM(amount), 0) as total_rebate_income, + COALESCE(SUM(quantity * COALESCE(unit_price, 0)), 0) - COALESCE(SUM(amount), 0) as total_partner_income + FROM records + WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != '' + AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + `).get(req.user.id, start, end); + // 获取奶茶收入 + const milkTeaStats = db.prepare(` + SELECT COALESCE(SUM(amount), 0) as total_milkTea_income + FROM records + WHERE user_id = ? AND type = 'income' AND category = '奶茶' + AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + `).get(req.user.id, start, end); + summary.total_milkTea_income = milkTeaStats.total_milkTea_income; + res.json({ rows, summary }); +}); + +// 总接单数按老板排行 +router.get('/take-ranking', (req, res) => { + const rows = db.prepare(` + SELECT boss as name, COALESCE(SUM(quantity), 0) as count, COALESCE(SUM(amount), 0) as income + FROM records WHERE user_id = ? AND category = '接单' AND boss IS NOT NULL AND boss != '' + GROUP BY boss ORDER BY count DESC + `).all(req.user.id); + // 获取接单总收入和红包收入 + const stats = db.prepare(` + SELECT + COALESCE(SUM(quantity), 0) as total_take_count, + COALESCE(SUM(amount), 0) as total_take_income + FROM records WHERE user_id = ? AND category = '接单' + `).get(req.user.id); + const redEnvelopeStats = db.prepare(` + SELECT COALESCE(SUM(amount), 0) as total_redEnvelope_income + FROM records WHERE user_id = ? AND type = 'income' AND category = '红包收入' + `).get(req.user.id); + const result = { + rows: rows, + total_take_count: stats.total_take_count, + total_take_income: stats.total_take_income, + total_redEnvelope_income: redEnvelopeStats.total_redEnvelope_income + }; + res.json(result); +}); + +// 总派单数按陪玩排行 +router.get('/dispatch-ranking', (req, res) => { + const rows = db.prepare(` + SELECT + partner as name, + COALESCE(SUM(quantity), 0) as dispatch_count, + COALESCE(SUM(quantity * COALESCE(unit_price, 0)), 0) as dispatch_income, + COALESCE(SUM(amount), 0) as rebate_income + FROM records WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != '' + GROUP BY partner ORDER BY dispatch_count DESC + `).all(req.user.id); + // 计算每个陪玩的陪玩总收入:(单价-返点)*单数 = 单价*单数 - 返点 + rows.forEach(row => { + row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0); + }); + // 获取派单总数和奶茶收入 + const dispatchTotal = db.prepare(` + SELECT COALESCE(SUM(quantity), 0) as total_dispatch_count + FROM records WHERE user_id = ? AND category = '派单' + `).get(req.user.id); + const milkTeaStats = db.prepare(` + SELECT COALESCE(SUM(amount), 0) as total_milkTea_income + FROM records WHERE user_id = ? AND type = 'income' AND category = '奶茶' + `).get(req.user.id); + const result = { + rows: rows, + total_dispatch_count: dispatchTotal.total_dispatch_count, + total_milkTea_income: milkTeaStats.total_milkTea_income + }; + res.json(result); +}); + +// 用户总统计 +router.get('/total', (req, res) => { + const stats = db.prepare(` + SELECT + COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count, + COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count, + COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income, + COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense + FROM records WHERE user_id = ? + `).get(req.user.id); + stats.net_income = stats.total_income - stats.total_expense; + res.json(stats); +}); + +// 老板交易记录 +router.get('/boss-records', (req, res) => { + const boss = req.query.boss; + if (!boss) return res.status(400).json({ error: '缺少老板参数' }); + + // 我的收入:只统计 type='income' 的记录(接单、派单返点、红包收入等) + // 使用 COALESCE(boss, source) 兼容旧数据(之前的红包收入只有 source 字段) + const myIncomeSummary = db.prepare(` + SELECT COALESCE(SUM(amount), 0) as total + FROM records WHERE user_id = ? AND COALESCE(boss, source) = ? AND type = 'income' + `).get(req.user.id, boss); + + // 老板支出:派单支出 + 接单支出 + 红包收入(老板发的红包也算老板支出) + const expenseSummary = db.prepare(` + SELECT + COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as dispatch_expense, + COALESCE(SUM(CASE WHEN category = '接单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as order_expense, + COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_expense + FROM records WHERE user_id = ? AND COALESCE(boss, source) = ? + `).get(req.user.id, boss); + + const boss_total_expense = expenseSummary.dispatch_expense + expenseSummary.order_expense + expenseSummary.redEnvelope_expense; + + const records = db.prepare(` + SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ? ORDER BY created_at DESC + `).all(req.user.id, boss); + + res.json({ + my_income: myIncomeSummary.total, + boss_total_expense: boss_total_expense, + records + }); +}); + +// 陪玩交易记录 +router.get('/partner-records', (req, res) => { + const partner = req.query.partner; + if (!partner) return res.status(400).json({ error: '缺少陪玩参数' }); + + const dispatchStats = db.prepare(` + SELECT + COALESCE(SUM(quantity), 0) as dispatch_count, + COALESCE(SUM(quantity * (COALESCE(unit_price, 0) - COALESCE(rebate, amount * 1.0 / CASE WHEN quantity > 0 THEN quantity ELSE 1 END))), 0) as spread_income, + COALESCE(SUM(amount), 0) as rebate_income + FROM records WHERE user_id = ? AND category = '派单' AND partner = ? + `).get(req.user.id, partner); + + const otherIncome = db.prepare(` + SELECT COALESCE(SUM(amount), 0) as total + FROM records WHERE user_id = ? AND type = 'income' AND category != '派单' AND category != '接单' AND source = ? + `).get(req.user.id, partner); + + const records = db.prepare(` + SELECT * FROM records + WHERE user_id = ? AND ( + (category = '派单' AND partner = ?) OR + (type = 'income' AND category != '派单' AND category != '接单' AND source = ?) + ) ORDER BY created_at DESC + `).all(req.user.id, partner, partner); + + res.json({ + dispatch_count: dispatchStats.dispatch_count, + spread_income: dispatchStats.spread_income, + rebate_income: dispatchStats.rebate_income, + other_income: otherIncome.total, + records + }); +}); + +// 最近7天每日收入 +router.get('/daily-income', (req, res) => { + const today = new Date(); + const days = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + days.push(formatLocalDate(d)); + } + const startDate = days[0]; + const endDay = new Date(today); + endDay.setDate(endDay.getDate() + 1); + const endDate = formatLocalDate(endDay); + + const rows = db.prepare(` + SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income + FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + GROUP BY DATE(created_at, 'localtime') + `).all(req.user.id, startDate, endDate); + + const map = {}; + rows.forEach(r => { map[r.date] = r.income; }); + res.json(days.map(d => ({ date: d, income: map[d] || 0 }))); +}); + +// 某天收入记录详情 +router.get('/daily-detail', (req, res) => { + const { date } = req.query; + if (!date) return res.status(400).json({ error: '缺少日期参数' }); + const nextDay = new Date(date); + nextDay.setDate(nextDay.getDate() + 1); + const endDate = formatLocalDate(nextDay); + + const summary = db.prepare(` + SELECT COALESCE(SUM(amount), 0) as total_income + FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + `).get(req.user.id, date, endDate); + + const records = db.prepare(` + SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + ORDER BY created_at DESC + `).all(req.user.id, date, endDate); + + res.json({ total_income: summary.total_income, records }); +}); + +// 老板收入排行 - 首页预览 +router.get('/boss-ranking/preview', (req, res) => { + const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); + const rows = db.prepare(` + SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income, + COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count, + COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count, + COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income + FROM records + WHERE user_id = ? AND type = 'income' AND (boss IS NOT NULL AND boss != '' OR source IS NOT NULL AND source != '') + AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + GROUP BY COALESCE(boss, source) ORDER BY total_income DESC LIMIT 10 + `).all(req.user.id, startDate, endDate); + res.json(rows); +}); + +// 老板收入排行 - 详情页(支持筛选) +router.get('/boss-ranking', (req, res) => { + const { startDate, endDate, sortBy = 'total_income' } = req.query; + let start, end; + if (startDate && endDate) { + start = startDate; + end = endDate; + } else { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + start = `${year}-${month}-01`; + const endMonth = now.getMonth() + 2 > 12 ? 1 : now.getMonth() + 2; + const endYear = endMonth === 1 ? year + 1 : year; + end = `${endYear}-${String(endMonth).padStart(2, '0')}-01`; + } + + const allowedSorts = ['total_income', 'take_count', 'dispatch_count']; + const orderCol = allowedSorts.includes(sortBy) ? sortBy : 'total_income'; + + if (sortBy === 'dispatch_count') { + const rows = db.prepare(` + SELECT COALESCE(boss, source) as boss, + COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count, + COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as dispatch_income, + 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 amount ELSE 0 END), 0) as take_income, + COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income + FROM records + WHERE user_id = ? AND type = 'income' AND (boss IS NOT NULL AND boss != '' OR source IS NOT NULL AND source != '') + AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + GROUP BY COALESCE(boss, source) ORDER BY dispatch_count DESC + `).all(req.user.id, start, end); + return res.json(rows.filter(r => r.dispatch_count > 0)); + } + + const rows = db.prepare(` + SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income, + COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count, + COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as take_income, + COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count, + COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as dispatch_income, + COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income + FROM records + WHERE user_id = ? AND type = 'income' AND (boss IS NOT NULL AND boss != '' OR source IS NOT NULL AND source != '') + AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + GROUP BY COALESCE(boss, source) ORDER BY ${orderCol} DESC + `).all(req.user.id, start, end); + + if (sortBy === 'take_count') { + return res.json(rows.filter(r => r.take_count > 0)); + } + res.json(rows); +}); + +module.exports = router; diff --git a/server/utils/helpers.js b/server/utils/helpers.js new file mode 100644 index 0000000..6d9a885 --- /dev/null +++ b/server/utils/helpers.js @@ -0,0 +1,34 @@ +// 日期范围辅助函数 +function getMonthRange(qYear, qMonth) { + const now = new Date(); + const y = Number(qYear) || now.getFullYear(); + const m = Number(qMonth) || (now.getMonth() + 1); + const startDate = `${y}-${String(m).padStart(2, '0')}-01`; + const nm = m + 1 > 12 ? 1 : m + 1; + const ny = m + 1 > 12 ? y + 1 : y; + const endDate = `${ny}-${String(nm).padStart(2, '0')}-01`; + return { startDate, endDate }; +} + +// CSV行解析(处理引号内的逗号) +function parseCSVLine(line) { + const result = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"' && line[i + 1] === '"') { current += '"'; i++; } + else if (ch === '"') { inQuotes = false; } + else { current += ch; } + } else { + if (ch === '"') { inQuotes = true; } + else if (ch === ',') { result.push(current); current = ''; } + else { current += ch; } + } + } + result.push(current); + return result; +} + +module.exports = { getMonthRange, parseCSVLine };