feat: tab导航、recharts图表、库存统计、出勤率里程、区域城市下钻、数据一致性修复
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- 新增tab导航(总览/按部门/按区域/按客户)+ 移动端底部导航
- 新增recharts柱状图(区域分布)和饼图(客户分布)
- 新增库存统计模块(按区域/按车型,筛选面板)
- 对接ln_vehicle_day_mileage表实现出勤率和日均里程
- 区域运营支持区域→城市→车型三级下钻
- 修复ownership取字段错误(改用truck_rent_status)
- 修复部门统计闲置定义(当日无行驶里程)
- 修复区域图表"其他"重复问题(后端Top N合并)
- 修复城市名空值降级(resolveCity取province)
- 修复下钻数据不一致(统一category/vehicleType参数)
- 扩展/list端点支持大区过滤和未分配匹配
- 所有筛选改为searchable datalist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-03-28 18:09:18 +08:00
parent 709e6d4238
commit 2ba25427de
6 changed files with 736 additions and 236 deletions

446
package-lock.json generated
View File

@@ -15,7 +15,9 @@
"motion": "^12.23.24",
"mysql2": "^3.11.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"tsx": "^4.21.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
@@ -25,7 +27,6 @@
"@vitejs/plugin-react": "^5.0.4",
"concurrently": "^9.1.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
@@ -319,7 +320,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -336,7 +336,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -353,7 +352,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -370,7 +368,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -387,7 +384,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -404,7 +400,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -421,7 +416,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -438,7 +432,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -455,7 +448,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -472,7 +464,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -489,7 +480,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -506,7 +496,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -523,7 +512,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -540,7 +528,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -557,7 +544,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -574,7 +560,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -591,7 +576,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -608,7 +592,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -625,7 +608,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -642,7 +624,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -659,7 +640,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -676,7 +656,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -693,7 +672,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -710,7 +688,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -727,7 +704,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -744,7 +720,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -816,6 +791,42 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -1173,6 +1184,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
@@ -1490,6 +1513,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1510,7 +1596,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1526,6 +1612,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@@ -1695,6 +1787,15 @@
"node": ">=12"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1751,9 +1852,130 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1772,6 +1994,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -1831,11 +2059,20 @@
"node": ">=10.13.0"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -1883,6 +2120,12 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1932,7 +2175,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -1976,7 +2218,6 @@
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
@@ -2027,6 +2268,25 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -2575,6 +2835,36 @@
"react": "^19.2.4"
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -2585,6 +2875,51 @@
"node": ">=0.10.0"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -2595,11 +2930,16 @@
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
@@ -2785,6 +3125,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2822,7 +3168,6 @@
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
@@ -2889,6 +3234,37 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",

View File

@@ -12,25 +12,26 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@hono/node-server": "^1.13.0",
"dotenv": "^16.4.0",
"hono": "^4.7.0",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"hono": "^4.7.0",
"@hono/node-server": "^1.13.0",
"mysql2": "^3.11.0",
"dotenv": "^16.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"tsx": "^4.21.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@types/node": "^22.14.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",
"@tailwindcss/vite": "^4.1.14",
"concurrently": "^9.1.0",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"concurrently": "^9.1.0"
"vite": "^6.2.0"
}
}

View File

@@ -32,7 +32,7 @@ import {
LabelList,
} from 'recharts';
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats } from './api';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
import type { WeeklyDetailItem } from './api';
// --- Constants ---
@@ -86,6 +86,7 @@ export default function App() {
// Region section state
const [expandedRegions, setExpandedRegions] = useState<Set<string>>(new Set());
const [expandedRegionCities, setExpandedRegionCities] = useState<Set<string>>(new Set());
const [regionFilters, setRegionFilters] = useState({ region: '', city: '', customer: '' });
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
@@ -105,6 +106,7 @@ export default function App() {
// Chart view states
const [customerChartView, setCustomerChartView] = useState<'region' | 'city'>('region');
const [regionChartView, setRegionChartView] = useState<'region' | 'city'>('region');
const [regionChartData, setRegionChartData] = useState<{ name: string; value: number }[]>([]);
// Modal filter state
const [modalFilters, setModalFilters] = useState({ plateNumber: '', model: '', brand: '', location: '' });
@@ -149,6 +151,11 @@ export default function App() {
return () => clearInterval(interval);
}, [loadData]);
// Fetch region chart data when view changes
useEffect(() => {
fetchRegionChart(regionChartView, regionChartView === 'city' ? 5 : 8).then(setRegionChartData).catch(() => setRegionChartData([]));
}, [regionChartView]);
// Load modal vehicles
useEffect(() => {
if (!showPlateNumbers) {
@@ -185,24 +192,17 @@ export default function App() {
if (showPlateNumbers.isColdChain !== undefined) params.isColdChain = String(showPlateNumbers.isColdChain);
if (showPlateNumbers.isTrailer !== undefined) params.isTrailer = String(showPlateNumbers.isTrailer);
}
// Map prototype's type field to backend vehicleType
// Map type field to backend vehicleType
if (showPlateNumbers.type) {
if (showPlateNumbers.type === '4.5T') {
if (showPlateNumbers.isColdChain === true) {
params.vehicleType = '4.5T冷链';
} else if (showPlateNumbers.isColdChain === false) {
params.vehicleType = '4.5T普货';
}
} else if (showPlateNumbers.type === '18T') {
params.vehicleType = '18T';
} else if (showPlateNumbers.type === '49T') {
params.vehicleType = '49T';
} else if (showPlateNumbers.type === '其他车型') {
if (showPlateNumbers.isTrailer === true) {
params.isTrailer = 'true';
} else if (showPlateNumbers.isTrailer === false) {
params.vehicleType = '其他';
}
const t = showPlateNumbers.type;
if (t === '4.5T') {
if (showPlateNumbers.isColdChain === true) params.vehicleType = '4.5T冷链';
else if (showPlateNumbers.isColdChain === false) params.vehicleType = '4.5T普货';
} else if (t === '4.5T普货' || t === '4.5T冷链' || t === '18T' || t === '49T' || t === '挂车' || t === '其他') {
params.vehicleType = t;
} else if (t === '其他车型') {
if (showPlateNumbers.isTrailer === true) params.isTrailer = 'true';
else if (showPlateNumbers.isTrailer === false) params.vehicleType = '其他';
}
}
fetchVehicleList(params)
@@ -256,6 +256,13 @@ export default function App() {
setExpandedRegions(newSet);
};
const toggleRegionCity = (key: string) => {
const newSet = new Set(expandedRegionCities);
if (newSet.has(key)) newSet.delete(key);
else newSet.add(key);
setExpandedRegionCities(newSet);
};
const toggleCustomer = (customer: string) => {
const newSet = new Set(expandedCustomers);
if (newSet.has(customer)) newSet.delete(customer);
@@ -477,7 +484,7 @@ export default function App() {
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5"></div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold text-blue-600 leading-none">{SUMMARY.operating.total}</span>
<span className="text-[8px] text-gray-400 leading-none">{SUMMARY.operating.self} {SUMMARY.operating.leased}</span>
<span className="text-[8px] text-gray-400 leading-none">{SUMMARY.operating.self} {SUMMARY.operating.leased}{SUMMARY.operating.hanging > 0 ? `${SUMMARY.operating.hanging}` : ''}</span>
</div>
</div>
</div>
@@ -1542,7 +1549,7 @@ export default function App() {
<div className="flex flex-col">
<span className="text-[9px] opacity-50 uppercase font-bold tracking-widest mb-0.5 text-blue-400"></span>
<span className="text-xl font-black text-blue-400">
{'—'}
{deptData.length > 0 ? (deptData.reduce((acc, d) => acc + d.attendanceRate, 0) / deptData.length).toFixed(1) : 0}%
</span>
</div>
</div>
@@ -1561,7 +1568,7 @@ export default function App() {
onClick={() => setDeptViewMode('manager')}
className={`px-4 py-1.5 text-xs font-bold rounded-md transition-all ${deptViewMode === 'manager' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
>
</button>
</div>
@@ -1578,7 +1585,7 @@ export default function App() {
className="w-full pl-9 pr-8 py-1.5 bg-white border border-gray-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm font-bold text-gray-700"
/>
<datalist id="dl-dept-manager">
<option value="All"></option>
<option value="All"></option>
{allManagersList.map(m => (
<option key={m} value={m} />
))}
@@ -1593,7 +1600,7 @@ export default function App() {
<table className="w-full text-left border-collapse min-w-[900px]">
<thead>
<tr className="bg-gray-100/50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-200">
<th className="p-2 font-bold border-r border-gray-100 w-48">{deptViewMode === 'department' ? '部门名称' : '业务'}</th>
<th className="p-2 font-bold border-r border-gray-100 w-48">{deptViewMode === 'department' ? '部门名称' : '业务负责人'}</th>
{deptViewMode === 'manager' && <th className="p-2 font-bold border-r border-gray-100 w-32"></th>}
<th className="p-2 font-bold border-r border-gray-100 text-center w-24">{deptViewMode === 'department' ? '出勤率' : '合计资产'}</th>
{deptViewMode === 'department' && (
@@ -1634,7 +1641,7 @@ export default function App() {
</td>
<td className="p-2 border-r border-gray-100 text-center">
<span className="bg-blue-50 text-blue-600 text-[10px] font-bold px-2 py-0.5 rounded-full">
{'—'}
{dept.attendanceRate}%
</span>
</td>
<td className="p-2 border-r border-gray-100 text-center font-black text-gray-800 text-sm">
@@ -1642,7 +1649,7 @@ export default function App() {
</td>
<td className="p-2 border-r border-gray-100 text-center">
<div className="flex items-baseline justify-center gap-1">
<span className="font-black text-gray-800 text-sm">{'—'}</span>
<span className="font-black text-gray-800 text-sm">{dept.avgMileage}</span>
<span className="text-[9px] text-gray-400 font-bold">km</span>
</div>
</td>
@@ -1688,42 +1695,42 @@ export default function App() {
<div className="grid grid-cols-3 gap-1 mt-2">
<div
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase">4.5T</div>
<div className="text-[10px] font-bold text-gray-600">{m.t4_5}</div>
</div>
<div
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase"></div>
<div className="text-[10px] font-bold text-gray-600">{m.t4_5c}</div>
</div>
<div
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase">18T</div>
<div className="text-[10px] font-bold text-gray-600">{m.t18}</div>
</div>
<div
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase">49T</div>
<div className="text-[10px] font-bold text-gray-600">{m.t49}</div>
</div>
<div
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase"></div>
<div className="text-[10px] font-bold text-gray-600">{m.trailer}</div>
</div>
<div
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase"></div>
<div className="text-[10px] font-bold text-gray-600">{m.other}</div>
@@ -1759,7 +1766,7 @@ export default function App() {
className="p-2 border-r border-gray-100 text-center font-black text-blue-600 text-sm cursor-pointer hover:bg-blue-50"
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, source: 'department' });
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department' });
}}
>
{m.total}
@@ -1786,27 +1793,27 @@ export default function App() {
<tr className="bg-gray-50/50 border-b border-gray-100">
<td colSpan={10} className="p-0">
<div className="grid grid-cols-6 text-[10px] bg-white/50">
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, source: 'department' })}>
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department' })}>
<span className="text-gray-400 uppercase mb-1">4.5T</span>
<span className="font-bold text-gray-600">{m.t4_5}</span>
</div>
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, source: 'department' })}>
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department' })}>
<span className="text-gray-400 uppercase mb-1"></span>
<span className="font-bold text-gray-600">{m.t4_5c}</span>
</div>
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', source: 'department' })}>
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department' })}>
<span className="text-gray-400 uppercase mb-1">18T</span>
<span className="font-bold text-gray-600">{m.t18}</span>
</div>
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', source: 'department' })}>
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department' })}>
<span className="text-gray-400 uppercase mb-1">49T</span>
<span className="font-bold text-gray-600">{m.t49}</span>
</div>
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, source: 'department' })}>
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department' })}>
<span className="text-gray-400 uppercase mb-1"></span>
<span className="font-bold text-gray-600">{m.trailer}</span>
</div>
<div className="p-2 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, source: 'department' })}>
<div className="p-2 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department' })}>
<span className="text-gray-400 uppercase mb-1"></span>
<span className="font-bold text-gray-600">{m.other}</span>
</div>
@@ -1836,7 +1843,7 @@ export default function App() {
<div className="flex justify-between items-center mb-2">
<h3 className="text-sm font-bold text-gray-800">{dept.department}</h3>
<span className="bg-blue-50 text-blue-600 text-[9px] font-bold px-2 py-0.5 rounded-full">
:
: {dept.attendanceRate}%
</span>
</div>
<div className="grid grid-cols-4 gap-2">
@@ -1846,7 +1853,7 @@ export default function App() {
</div>
<div className="text-center">
<div className="text-[8px] text-gray-400 uppercase font-bold mb-0.5"></div>
<div className="text-xs font-black text-gray-800">{'—'}</div>
<div className="text-xs font-black text-gray-800">{dept.avgMileage}</div>
</div>
<div className="text-center">
<div className="text-[8px] text-green-500 uppercase font-bold mb-0.5"></div>
@@ -1878,7 +1885,7 @@ export default function App() {
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, source: 'department' });
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department' });
}}
className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded"
>
@@ -1890,42 +1897,42 @@ export default function App() {
<div className="p-2 border-t border-gray-50 bg-gray-50/30 grid grid-cols-3 gap-1">
<div
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase">4.5T</div>
<div className="text-[10px] font-bold text-gray-600">{m.t4_5}</div>
</div>
<div
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase"></div>
<div className="text-[10px] font-bold text-gray-600">{m.t4_5c}</div>
</div>
<div
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase">18T</div>
<div className="text-[10px] font-bold text-gray-600">{m.t18}</div>
</div>
<div
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase">49T</div>
<div className="text-[10px] font-bold text-gray-600">{m.t49}</div>
</div>
<div
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase"></div>
<div className="text-[10px] font-bold text-gray-600">{m.trailer}</div>
</div>
<div
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase"></div>
<div className="text-[10px] font-bold text-gray-600">{m.other}</div>
@@ -1958,7 +1965,7 @@ export default function App() {
className="text-[11px] font-bold text-blue-600 whitespace-nowrap"
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, source: 'department' });
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department' });
}}
>
: {m.total}
@@ -1981,42 +1988,42 @@ export default function App() {
<div className="p-2 border-t border-gray-50 bg-gray-50/30 grid grid-cols-3 gap-1">
<div
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase">4.5T</div>
<div className="text-[10px] font-bold text-gray-600">{m.t4_5}</div>
</div>
<div
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase"></div>
<div className="text-[10px] font-bold text-gray-600">{m.t4_5c}</div>
</div>
<div
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase">18T</div>
<div className="text-[10px] font-bold text-gray-600">{m.t18}</div>
</div>
<div
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase">49T</div>
<div className="text-[10px] font-bold text-gray-600">{m.t49}</div>
</div>
<div
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase"></div>
<div className="text-[10px] font-bold text-gray-600">{m.trailer}</div>
</div>
<div
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, source: 'department' })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department' })}
>
<div className="text-[8px] text-gray-400 uppercase"></div>
<div className="text-[10px] font-bold text-gray-600">{m.other}</div>
@@ -2052,25 +2059,9 @@ export default function App() {
></button>
</div>
</div>
<div className="h-64 w-full">
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={(() => {
if (regionChartView === 'region') {
const regions: { [key: string]: number } = {};
customerData.forEach(item => {
regions[item.region] = (regions[item.region] || 0) + item.total;
});
return Object.entries(regions).map(([name, value]) => ({ name, value }));
} else {
const cities: { [key: string]: number } = {};
customerData.forEach(item => {
cities[item.city] = (cities[item.city] || 0) + item.total;
});
return Object.entries(cities)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
}
})()}>
<BarChart data={regionChartData} margin={{ top: 20 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 12 }} />
@@ -2212,90 +2203,55 @@ export default function App() {
</tr>
</thead>
<tbody className="text-xs">
{uniqueRegions.filter(r => !regionFilters.region || r === regionFilters.region).map((region) => {
const regionStats = customerData.filter(s => {
const matchRegion = s.region === region;
const matchCity = !regionFilters.city || s.city === regionFilters.city;
const matchCustomer = !regionFilters.customer || s.customer.toLowerCase().includes(regionFilters.customer.toLowerCase());
return matchRegion && matchCity && matchCustomer;
});
const totalAssets = regionStats.reduce((acc, s) => acc + s.total, 0);
if (totalAssets === 0) return null;
const isExpanded = expandedRegions.has(region);
{regionData.filter(r => !regionFilters.region || r.region === regionFilters.region).map((r) => {
const isExpanded = expandedRegions.has(r.region);
return (
<React.Fragment key={region}>
<React.Fragment key={r.region}>
<tr
className={`border-b border-slate-100 cursor-pointer transition-colors ${isExpanded ? 'bg-slate-50' : 'bg-white hover:bg-slate-50/50'}`}
onClick={() => toggleRegion(region)}
onClick={() => toggleRegion(r.region)}
>
<td className="p-2 font-bold text-slate-700 flex items-center gap-2">
{isExpanded ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<Truck size={14} className="text-slate-400" />
{region}
</td>
<td className="p-2 text-center font-bold text-slate-600">{totalAssets}</td>
<td
className="p-2 text-center text-green-600 font-bold cursor-pointer hover:bg-green-50"
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset' });
}}
>
{Math.floor(totalAssets * 0.8)}
</td>
<td
className="p-2 text-center text-orange-600 font-bold cursor-pointer hover:bg-orange-50"
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset' });
}}
>
{Math.floor(totalAssets * 0.05)}
</td>
<td className="p-2 text-center text-slate-500 font-medium">
{regionStats.slice(0, 2).map(s => s.customer).join(', ')}
{r.region}
</td>
<td className="p-2 text-center font-bold text-slate-600">{r.totalAssets}</td>
<td className="p-2 text-center text-green-600 font-bold">{r.operatingCount}</td>
<td className="p-2 text-center text-orange-600 font-bold">{r.pendingCount || ''}</td>
<td className="p-2 text-center text-slate-500 font-medium">{r.customers.slice(0, 2).join(', ')}</td>
</tr>
{isExpanded && ['4.5T', '18T', '49T'].map(type => {
const typeTotal = regionStats.reduce((acc, s) => {
if (type === '4.5T') return acc + s.t4_5 + s.t4_5c;
if (type === '18T') return acc + s.t18;
if (type === '49T') return acc + s.t49;
return acc;
}, 0);
if (typeTotal === 0) return null;
{isExpanded && r.cities.map((city) => {
const cityKey = `${r.region}-${city.city}`;
const isCityExpanded = expandedRegionCities.has(cityKey);
return (
<React.Fragment key={type}>
<tr className="border-b border-gray-50 hover:bg-gray-50">
<td className="p-2 pl-8 text-gray-500 flex items-center gap-2">
<div className="w-1 h-1 bg-slate-300 rounded-full"></div>
{type}
</td>
<td className="p-2 text-center text-gray-600">{typeTotal}</td>
<td
className="p-2 text-center text-green-600 cursor-pointer hover:bg-green-50"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type, category: 'Operating', source: 'asset' })}
>
{Math.floor(typeTotal * 0.8)}
</td>
<td
className="p-2 text-center text-orange-600 cursor-pointer hover:bg-orange-50"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type, category: 'Pending', source: 'asset' })}
>
{Math.floor(typeTotal * 0.05)}
</td>
<td className="p-2 text-center text-gray-400 italic">
{regionStats.filter(s => {
if (type === '4.5T') return (s.t4_5 + s.t4_5c) > 0;
if (type === '18T') return s.t18 > 0;
if (type === '49T') return s.t49 > 0;
return false;
}).map(s => s.customer).join(', ')}
<React.Fragment key={city.city}>
<tr
className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer"
onClick={() => toggleRegionCity(cityKey)}
>
<td className="p-2 pl-8 text-slate-600 flex items-center gap-2">
{isCityExpanded ? <ChevronDown size={12} className="text-slate-400" /> : <ChevronRight size={12} className="text-slate-400" />}
<MapPin size={12} className="text-slate-300" />
<span className="font-medium">{city.city}</span>
</td>
<td className="p-2 text-center text-slate-600 font-medium">{city.totalAssets}</td>
<td className="p-2 text-center text-green-600">{city.operatingCount}</td>
<td className="p-2 text-center text-orange-600">{city.pendingCount || ''}</td>
<td className="p-2 text-center text-slate-400 text-[10px] italic">{city.customers.slice(0, 2).join(', ')}</td>
</tr>
{isCityExpanded && city.typeBreakdown.map(tb => (
<tr key={tb.type} className="border-b border-gray-50/50 bg-slate-50/30">
<td className="p-2 pl-14 text-gray-400 flex items-center gap-2">
<div className="w-1 h-1 bg-slate-300 rounded-full"></div>
{tb.type}
</td>
<td className="p-2 text-center text-gray-500">{tb.total}</td>
<td className="p-2 text-center text-green-500">{tb.operating}</td>
<td className="p-2 text-center text-orange-500">{tb.inventory || ''}</td>
<td className="p-2 text-center text-gray-400 text-[10px] italic">{tb.customers.slice(0, 2).join(', ')}</td>
</tr>
))}
</React.Fragment>
);
})}
@@ -2645,7 +2601,7 @@ export default function App() {
<tr className="bg-emerald-700 text-white text-[11px] uppercase tracking-wider border-b border-emerald-800">
<th className="p-2 font-semibold border-r border-emerald-600 w-40"></th>
<th className="p-2 font-semibold border-r border-emerald-600 w-24"></th>
<th className="p-2 font-semibold border-r border-emerald-600 w-24"></th>
<th className="p-2 font-semibold border-r border-emerald-600 w-24"></th>
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">4.5T</th>
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">4.5T冷链</th>
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">18T</th>
@@ -2689,7 +2645,7 @@ export default function App() {
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div>
<div className="text-sm font-bold text-gray-700">{cust.customer}</div>
<div className="text-xs text-gray-500 mt-1">: {cust.manager}</div>
<div className="text-xs text-gray-500 mt-1">: {cust.manager}</div>
</div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div>

View File

@@ -74,6 +74,10 @@ export async function fetchInventoryStats(): Promise<RegionalInventoryStats[]> {
return fetchJson<RegionalInventoryStats[]>(`${BASE}/inventory-stats`);
}
export async function fetchRegionChart(groupBy: string, top = 8): Promise<{ name: string; value: number }[]> {
return fetchJson<{ name: string; value: number }[]>(`${BASE}/region-chart?groupBy=${groupBy}&top=${top}`);
}
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?type=${type}`);
}

View File

@@ -156,15 +156,23 @@ function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Pend
return 'Inventory';
}
// Map ownership status
// Actual DB values: 自有(0), 外租(1), 挂靠(2)
function mapOwnership(ascriptionLabel: string | null): string {
if (!ascriptionLabel) return 'Self';
const s = ascriptionLabel.trim();
if (s === '自') return 'Self';
if (s === '租') return 'Leased';
// Map ownership from truck_rent_status (rentStatusLabel)
// DB values: 自营(1), 租赁(2), 挂靠(8) → these are the operating subtypes
function mapOwnership(rentStatusLabel: string | null): string {
if (!rentStatusLabel) return 'Unknown';
const s = rentStatusLabel.trim();
if (s === '自') return 'Self';
if (s === '租') return 'Leased';
if (s === '挂靠') return 'Hanging';
return 'Self';
return 'Unknown';
}
// Resolve city name: clean brackets, fallback to province for municipalities
function resolveCity(city: string | null, province: string | null): string {
const c = (city || '').replace(/[\[\]]/g, '').trim();
if (c) return c;
const p = (province || '').replace(/[\[\]]/g, '').trim();
return p || '其他';
}
// Derive vehicle type category from model label
@@ -268,7 +276,7 @@ function transformRow(row: VehicleRow): Vehicle {
province: row.省,
city: row.市,
status: mapStatus(row.Label),
ownership: mapOwnership(row.Label),
ownership: mapOwnership(row.Label),
rentCompany: row.租赁公司 || '',
contractNo: row.合同编码,
customerName: row.客户名称,
@@ -639,68 +647,168 @@ app.get('/inventory-analysis', async (c) => {
return c.json(result);
});
// GET /api/vehicles/dept-stats — department & manager breakdown
// GET /api/vehicles/dept-stats — department & manager breakdown with mileage/attendance
app.get('/dept-stats', async (c) => {
const vehicles = await getVehicles();
const withManager = vehicles.filter((v) => v.customerManager && v.status === 'Operating');
const withManager = vehicles.filter((v) => v.status === 'Operating');
// Query mileage data: last 30 days attendance & avg mileage per plate
// + today's mileage for idle detection
const [[mileageRows], [todayRows]] = await Promise.all([
pool.query<any[]>(`
SELECT plateNumber,
COUNT(CASE WHEN dayMileage > 0 THEN 1 END) AS activeDays,
COUNT(*) AS totalDays,
AVG(dayMileage) AS avgMileage
FROM ln_vehicle_day_mileage
WHERE dates >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY plateNumber
`),
pool.query<any[]>(`
SELECT plateNumber, dayMileage
FROM ln_vehicle_day_mileage
WHERE dates = CURDATE()
`),
]);
const mileageMap = new Map<string, { activeDays: number; totalDays: number; avgMileage: number }>();
for (const row of mileageRows as any[]) {
mileageMap.set(row.plateNumber, {
activeDays: Number(row.activeDays),
totalDays: Number(row.totalDays),
avgMileage: Number(row.avgMileage),
});
}
const todayMileageMap = new Map<string, number>();
for (const row of todayRows as any[]) {
todayMileageMap.set(row.plateNumber, Number(row.dayMileage));
}
const deptMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of withManager) {
const dept = v.departmentName || '未分配';
const mgr = v.customerManager!;
const mgr = v.customerManager || '未分配';
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
const mgrMap = deptMap.get(dept)!;
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
mgrMap.get(mgr)!.push(v);
}
// Compute attendance & mileage for a set of vehicles
const getMileageStats = (vList: Vehicle[]) => {
let totalActive = 0;
let totalDays = 0;
let totalMileage = 0;
let count = 0;
for (const v of vList) {
const m = mileageMap.get(v.plateNumber);
if (m) {
totalActive += m.activeDays;
totalDays += m.totalDays;
totalMileage += m.avgMileage;
count++;
}
}
return {
attendanceRate: totalDays > 0 ? Math.round((totalActive / totalDays) * 1000) / 10 : 0,
avgMileage: count > 0 ? Math.round(totalMileage / count) : 0,
};
};
const result = Array.from(deptMap.entries()).map(([department, mgrMap]) => {
const allDeptVehicles = Array.from(mgrMap.values()).flat();
const deptMileage = getMileageStats(allDeptVehicles);
const managers = Array.from(mgrMap.entries())
.map(([manager, mvs]) => ({ manager, department, ...countByType(mvs) }))
.sort((a, b) => b.total - a.total);
return {
department,
totalAssets: allDeptVehicles.length,
operatingCount: allDeptVehicles.filter((v) => v.status === 'Operating').length,
idleCount: allDeptVehicles.filter((v) => v.status !== 'Operating').length,
operatingCount: allDeptVehicles.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length,
idleCount: allDeptVehicles.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) === 0).length,
attendanceRate: deptMileage.attendanceRate,
avgMileage: deptMileage.avgMileage,
managers,
};
}).sort((a, b) => b.totalAssets - a.totalAssets);
}).sort((a, b) => {
// 按部门名中的数字排序(业务一部=1, 业务二部=2, ...
const numMap: Record<string, number> = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10 };
const getNum = (name: string) => {
const m = name.match(/[一二三四五六七八九十]/);
return m ? (numMap[m[0]] || 99) : 99;
};
return getNum(a.department) - getNum(b.department);
});
return c.json(result);
});
// GET /api/vehicles/region-stats — macro-region breakdown for operating vehicles
// GET /api/vehicles/region-stats — macro-region with city drill-down
app.get('/region-stats', async (c) => {
const vehicles = await getVehicles();
const operating = vehicles.filter((v) => v.status === 'Operating');
const operating = vehicles.filter((v) => v.status === 'Operating' || v.status === 'Pending');
const regionMap = new Map<string, Vehicle[]>();
const regionCityMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of operating) {
const region = mapMacroRegion(v.province, v.city);
if (!regionMap.has(region)) regionMap.set(region, []);
regionMap.get(region)!.push(v);
const city = resolveCity(v.city, v.province);
if (!regionCityMap.has(region)) regionCityMap.set(region, new Map());
const cityMap = regionCityMap.get(region)!;
if (!cityMap.has(city)) cityMap.set(city, []);
cityMap.get(city)!.push(v);
}
const getTypeBreakdown = (vList: Vehicle[]) =>
['4.5T', '18T', '49T'].map((type) => {
const tv = vList.filter((v) => v.type === type);
return { type, total: tv.length, operating: tv.filter((v) => v.status === 'Operating').length, inventory: tv.filter((v) => v.status === 'Inventory').length, customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[] };
}).filter((t) => t.total > 0);
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
const result = regionOrder
.filter((r) => regionMap.has(r))
.filter((r) => regionCityMap.has(r))
.map((region) => {
const rv = regionMap.get(region)!;
const customers = Array.from(new Set(rv.map((v) => v.customerName).filter(Boolean))) as string[];
const typeBreakdown = ['4.5T', '18T', '49T'].map((type) => {
const typeVehicles = rv.filter((v) => v.type === type);
return {
type,
total: typeVehicles.length,
operating: typeVehicles.filter((v) => v.status === 'Operating').length,
inventory: typeVehicles.filter((v) => v.status === 'Inventory').length,
customers: Array.from(new Set(typeVehicles.map((v) => v.customerName).filter(Boolean))) as string[],
};
}).filter((t) => t.total > 0);
const cityMap = regionCityMap.get(region)!;
const allVehicles = Array.from(cityMap.values()).flat();
const customers = Array.from(new Set(allVehicles.map((v) => v.customerName).filter(Boolean))) as string[];
const allCities = Array.from(cityMap.entries())
.map(([city, cv]) => ({
city,
totalAssets: cv.length,
operatingCount: cv.filter((v) => v.status === 'Operating').length,
pendingCount: cv.filter((v) => v.status === 'Pending').length,
customers: Array.from(new Set(cv.map((v) => v.customerName).filter(Boolean))) as string[],
typeBreakdown: getTypeBreakdown(cv),
}))
.sort((a, b) => b.totalAssets - a.totalAssets);
return { region, totalAssets: rv.length, operatingCount: rv.filter((v) => v.status === 'Operating').length, inventoryCount: rv.filter((v) => v.status === 'Inventory').length, customers, typeBreakdown };
// Top 8 cities + merge rest into "其他"
const topCities = allCities.slice(0, 8);
const restCities = allCities.slice(8);
if (restCities.length > 0) {
const restVehicles = restCities.flatMap((c) => {
const key = c.city;
return cityMap.get(key) || [];
});
topCities.push({
city: '其他',
totalAssets: restCities.reduce((s, c) => s + c.totalAssets, 0),
operatingCount: restCities.reduce((s, c) => s + c.operatingCount, 0),
pendingCount: restCities.reduce((s, c) => s + (c.pendingCount || 0), 0),
customers: Array.from(new Set(restVehicles.map((v) => v.customerName).filter(Boolean))) as string[],
typeBreakdown: getTypeBreakdown(restVehicles),
});
}
const cities = topCities;
return {
region,
totalAssets: allVehicles.length,
operatingCount: allVehicles.filter((v) => v.status === 'Operating').length,
pendingCount: allVehicles.filter((v) => v.status === 'Pending').length,
customers,
typeBreakdown: getTypeBreakdown(allVehicles),
cities,
};
});
return c.json(result);
@@ -709,11 +817,11 @@ app.get('/region-stats', async (c) => {
// GET /api/vehicles/customer-stats — per-customer breakdown for operating vehicles
app.get('/customer-stats', async (c) => {
const vehicles = await getVehicles();
const operating = vehicles.filter((v) => v.status === 'Operating' && v.customerName);
const operating = vehicles.filter((v) => v.status === 'Operating');
const custMap = new Map<string, Vehicle[]>();
for (const v of operating) {
const cust = v.customerName!;
const cust = v.customerName || '未分配客户';
if (!custMap.has(cust)) custMap.set(cust, []);
custMap.get(cust)!.push(v);
}
@@ -742,7 +850,8 @@ const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
'4.5T冷链': (v) => v.type === '4.5T' && v.model.includes('冷链'),
'18T': (v) => v.type === '18T',
'49T': (v) => v.type === '49T',
'其他': (v) => !['4.5T', '18T', '49T'].includes(v.type),
'挂车': (v) => v.type === '挂车' || v.model.includes('挂车'),
'其他': (v) => classifyVehicleType(v) === 'other',
};
// GET /api/vehicles/list — flat list with optional filters
@@ -761,10 +870,15 @@ app.get('/list', async (c) => {
filtered = filtered.filter((v) => v.model === model);
}
if (location && location !== 'All') {
// Support both display region names and inventory region names
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
const mappedLocation = inventoryRegionMap[location] || location;
filtered = filtered.filter((v) => v.location === mappedLocation);
// Support: display regions (嘉兴/广东), inventory regions (江浙沪), cities (嘉兴市), macro regions (华东/华南)
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
if (macroRegions.includes(location) || location === '其他') {
filtered = filtered.filter((v) => mapMacroRegion(v.province, v.city) === location);
} else {
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
const mappedLocation = inventoryRegionMap[location] || location;
filtered = filtered.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
}
}
if (status && status !== 'All') {
filtered = filtered.filter((v) => v.status === status);
@@ -777,10 +891,10 @@ app.get('/list', async (c) => {
}
}
if (manager) {
filtered = filtered.filter((v) => v.customerManager === manager);
filtered = filtered.filter((v) => manager === '未分配' ? !v.customerManager : v.customerManager === manager);
}
if (customer) {
filtered = filtered.filter((v) => v.customerName === customer);
filtered = filtered.filter((v) => customer === '未分配客户' ? !v.customerName : v.customerName === customer);
}
if (isColdChain !== undefined) {
const wantCold = isColdChain === 'true';
@@ -795,6 +909,7 @@ app.get('/list', async (c) => {
filtered.map((v) => ({
id: v.id,
plateNumber: v.plateNumber,
vin: v.vin,
type: v.type,
model: v.model,
location: v.location,
@@ -832,7 +947,7 @@ app.get('/inventory-stats', async (c) => {
const typeCategory = classifyVehicleType(v);
const typeName = TYPE_NAME_MAP[typeCategory];
const region = mapMacroRegion(v.province, v.city);
const city = v.city || '其他';
const city = resolveCity(v.city, v.province);
const brand = v.brandLabel || '未知';
const model = v.model;
const batch = v.contractNo || 'N/A';
@@ -907,4 +1022,31 @@ app.get('/debug', async (c) => {
});
});
// GET /api/vehicles/region-chart — aggregated chart data with top N + "其他"
app.get('/region-chart', async (c) => {
const vehicles = await getVehicles();
const operating = vehicles.filter((v) => v.status === 'Operating');
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'city'
const top = Number(c.req.query('top')) || 8;
const counts = new Map<string, number>();
for (const v of operating) {
const key = groupBy === 'city' ? resolveCity(v.city, v.province) : mapMacroRegion(v.province, v.city);
counts.set(key, (counts.get(key) || 0) + 1);
}
// 分离"其他",对非"其他"排序取 Top N剩余全部合入"其他"
const otherCount = counts.get('其他') || 0;
counts.delete('其他');
const sorted = Array.from(counts.entries())
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
const result = sorted.slice(0, top);
const restTotal = sorted.slice(top).reduce((s, item) => s + item.value, 0) + otherCount;
if (restTotal > 0) result.push({ name: '其他', value: restTotal });
return c.json(result);
});
export default app;

View File

@@ -98,6 +98,7 @@ export interface InventoryTypeSummary {
export interface VehicleListItem {
id: number;
plateNumber: string;
vin: string;
type: string;
model: string;
location: string;
@@ -141,16 +142,36 @@ export interface DeptGroup {
totalAssets: number;
operatingCount: number;
idleCount: number;
attendanceRate: number;
avgMileage: number;
managers: ManagerStats[];
}
export interface RegionTypeBreakdown {
type: string;
total: number;
operating: number;
inventory: number;
customers: string[];
}
export interface RegionCityGroup {
city: string;
totalAssets: number;
operatingCount: number;
pendingCount: number;
customers: string[];
typeBreakdown: RegionTypeBreakdown[];
}
export interface RegionGroup {
region: string;
totalAssets: number;
operatingCount: number;
inventoryCount: number;
pendingCount: number;
customers: string[];
typeBreakdown: { type: string; total: number; operating: number; inventory: number; customers: string[] }[];
typeBreakdown: RegionTypeBreakdown[];
cities: RegionCityGroup[];
}
export interface CustomerStats {