Compare commits
2 Commits
6dbd36dcd3
...
f66049dcbc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f66049dcbc | ||
|
|
2575778293 |
155
package-lock.json
generated
155
package-lock.json
generated
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"hono": "^4.7.0",
|
"hono": "^4.7.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
@@ -1583,6 +1585,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.15",
|
"version": "22.19.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
@@ -1721,6 +1739,12 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001781",
|
"version": "1.0.30001781",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
|
||||||
@@ -2031,6 +2055,15 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.325",
|
"version": "1.5.325",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz",
|
||||||
@@ -2346,6 +2379,61 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/jsonwebtoken/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/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/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -2607,6 +2695,48 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/long": {
|
"node_modules/long": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
@@ -2702,7 +2832,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
@@ -3000,6 +3129,26 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
|||||||
@@ -13,8 +13,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"hono": "^4.7.0",
|
"hono": "^4.7.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
|
|||||||
30
src/App.tsx
30
src/App.tsx
@@ -2,12 +2,40 @@ import { Truck, Route } from 'lucide-react';
|
|||||||
import { Shell, type ModuleConfig } from './components/Shell';
|
import { Shell, type ModuleConfig } from './components/Shell';
|
||||||
import AssetsModule from './modules/assets/AssetsModule';
|
import AssetsModule from './modules/assets/AssetsModule';
|
||||||
import MileageModule from './modules/mileage/MileageModule';
|
import MileageModule from './modules/mileage/MileageModule';
|
||||||
|
import AuthProvider from './auth/AuthProvider';
|
||||||
|
import { useAuth } from './auth/useAuth';
|
||||||
|
import UnauthorizedPage from './auth/UnauthorizedPage';
|
||||||
|
|
||||||
const MODULES: ModuleConfig[] = [
|
const MODULES: ModuleConfig[] = [
|
||||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function App() {
|
function AuthGate() {
|
||||||
|
const { isLoading, isAuthenticated, error } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
|
||||||
|
<p className="text-xs text-slate-400 font-bold">正在验证身份...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <UnauthorizedPage message={error || undefined} />;
|
||||||
|
}
|
||||||
|
|
||||||
return <Shell modules={MODULES} />;
|
return <Shell modules={MODULES} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthGate />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
118
src/auth/AuthProvider.tsx
Normal file
118
src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||||
|
import { AuthContext, type AuthState } from './useAuth';
|
||||||
|
import { setTokenGetter } from './api-client';
|
||||||
|
|
||||||
|
const AUTH_API = '/api/auth';
|
||||||
|
|
||||||
|
export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, setState] = useState<AuthState>({
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const tokenRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 设置全局 token getter
|
||||||
|
setTokenGetter(() => tokenRef.current);
|
||||||
|
|
||||||
|
// 监听 401 事件
|
||||||
|
const onUnauthorized = () => {
|
||||||
|
tokenRef.current = null;
|
||||||
|
sessionStorage.removeItem('bi_jwt');
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' });
|
||||||
|
};
|
||||||
|
window.addEventListener('auth:unauthorized', onUnauthorized);
|
||||||
|
|
||||||
|
authenticate();
|
||||||
|
|
||||||
|
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
// 1. 检查 sessionStorage 中是否有 JWT
|
||||||
|
const savedToken = sessionStorage.getItem('bi_jwt');
|
||||||
|
if (savedToken) {
|
||||||
|
tokenRef.current = savedToken;
|
||||||
|
// 验证 token 是否仍然有效(尝试请求 health)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/health', {
|
||||||
|
headers: { Authorization: `Bearer ${savedToken}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const savedUser = sessionStorage.getItem('bi_user');
|
||||||
|
setState({
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: savedUser ? JSON.parse(savedUser) : null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* token 无效,继续流程 */ }
|
||||||
|
sessionStorage.removeItem('bi_jwt');
|
||||||
|
sessionStorage.removeItem('bi_user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 从 URL 提取 jumpToken
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const jumpToken = params.get('jumpToken');
|
||||||
|
|
||||||
|
if (!jumpToken) {
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '未提供跳转令牌' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. 通过后端代理交换 jumpToken
|
||||||
|
const exchangeRes = await fetch(`${AUTH_API}/exchange?jumpToken=${encodeURIComponent(jumpToken)}`);
|
||||||
|
const exchangeData = await exchangeRes.json();
|
||||||
|
|
||||||
|
if (!exchangeRes.ok || !exchangeData.token) {
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '跳转令牌无效或已过期' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 用 sessionToken 登录获取 JWT
|
||||||
|
const loginRes = await fetch(`${AUTH_API}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: exchangeData.token }),
|
||||||
|
});
|
||||||
|
const loginData = await loginRes.json();
|
||||||
|
|
||||||
|
if (!loginRes.ok || !loginData.token) {
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '获取用户信息失败' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 存储 JWT
|
||||||
|
tokenRef.current = loginData.token;
|
||||||
|
sessionStorage.setItem('bi_jwt', loginData.token);
|
||||||
|
sessionStorage.setItem('bi_user', JSON.stringify(loginData.user));
|
||||||
|
|
||||||
|
// 6. 清除 URL 中的 jumpToken
|
||||||
|
params.delete('jumpToken');
|
||||||
|
const cleanUrl = params.toString()
|
||||||
|
? `${window.location.pathname}?${params.toString()}${window.location.hash}`
|
||||||
|
: `${window.location.pathname}${window.location.hash}`;
|
||||||
|
window.history.replaceState({}, '', cleanUrl);
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: loginData.user,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={state}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/auth/UnauthorizedPage.tsx
Normal file
20
src/auth/UnauthorizedPage.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ShieldX } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function UnauthorizedPage({ message }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
|
||||||
|
<div className="text-center max-w-sm">
|
||||||
|
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
|
||||||
|
<ShieldX size={36} className="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg font-black text-slate-800 mb-2">未授权访问</h1>
|
||||||
|
<p className="text-xs text-slate-400 mb-1">
|
||||||
|
{message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-300">
|
||||||
|
请从资产管理平台点击跳转链接进入此系统
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/auth/api-client.ts
Normal file
24
src/auth/api-client.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/** 全局认证 fetch 客户端 */
|
||||||
|
|
||||||
|
let tokenGetter: () => string | null = () => null;
|
||||||
|
|
||||||
|
export function setTokenGetter(fn: () => string | null) {
|
||||||
|
tokenGetter = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
|
const token = tokenGetter();
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options?.headers,
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
19
src/auth/useAuth.ts
Normal file
19
src/auth/useAuth.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
user: { userName: string; permissionLevel: string; depName: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthState>({
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
@@ -7,15 +7,10 @@ import type {
|
|||||||
CustomerStats,
|
CustomerStats,
|
||||||
RegionalInventoryStats,
|
RegionalInventoryStats,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
|
||||||
const BASE = '/api/vehicles';
|
const BASE = '/api/vehicles';
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSummary(): Promise<SummaryData> {
|
export async function fetchSummary(): Promise<SummaryData> {
|
||||||
return fetchJson<SummaryData>(`${BASE}/summary`);
|
return fetchJson<SummaryData>(`${BASE}/summary`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
|
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||||
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
|
||||||
const BASE = '/api/mileage';
|
const BASE = '/api/mileage';
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchMonitoring(params?: {
|
export async function fetchMonitoring(params?: {
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
|
|||||||
102
src/server/auth/login.ts
Normal file
102
src/server/auth/login.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import pool from '../db.js';
|
||||||
|
import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
|
||||||
|
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com';
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
||||||
|
|
||||||
|
/** GET /api/auth/exchange?jumpToken=xxx — 代理 jumpToken 换取 sessionToken */
|
||||||
|
app.get('/exchange', async (c) => {
|
||||||
|
const jumpToken = c.req.query('jumpToken');
|
||||||
|
if (!jumpToken) return c.json({ error: 'Missing jumpToken' }, 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${EXTERNAL_API_BASE}/api/lingniu-manager-v1/v1/auth/issueTokenByJump?jumpToken=${encodeURIComponent(jumpToken)}`
|
||||||
|
);
|
||||||
|
const data = await res.json() as { code: number; data: string | null; message: string };
|
||||||
|
|
||||||
|
if (data.code !== 0 || !data.data) {
|
||||||
|
return c.json({ error: 'Token exchange failed', message: data.message }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ token: data.data });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error('jumpToken exchange error:', e);
|
||||||
|
return c.json({ error: 'Token exchange failed' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/auth/login — 用外部 sessionToken 获取用户信息,签发 JWT */
|
||||||
|
app.post('/login', async (c) => {
|
||||||
|
const body = await c.req.json<{ token: string }>().catch(() => null);
|
||||||
|
if (!body?.token) return c.json({ error: 'Missing token' }, 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用外部 API 获取用户信息
|
||||||
|
const res = await fetch(
|
||||||
|
`${EXTERNAL_API_BASE}/api/lingniu-manager-v1/v1/auth/getLoginUserInfo`,
|
||||||
|
{ headers: { g7litegtoken: body.token } }
|
||||||
|
);
|
||||||
|
const data = await res.json() as {
|
||||||
|
code: number;
|
||||||
|
data: {
|
||||||
|
id: string;
|
||||||
|
userName: string;
|
||||||
|
loginName: string;
|
||||||
|
depCode: string;
|
||||||
|
orgId: string;
|
||||||
|
roles: { roleName: string; id: string }[];
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.code !== 0 || !data.data) {
|
||||||
|
return c.json({ error: 'Failed to get user info' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = data.data;
|
||||||
|
const roleNames = userInfo.roles.map(r => r.roleName);
|
||||||
|
|
||||||
|
// 确定权限级别
|
||||||
|
let permissionLevel: PermissionLevel = 'personal';
|
||||||
|
if (roleNames.some(r => FULL_ACCESS_ROLES.includes(r))) {
|
||||||
|
permissionLevel = 'full';
|
||||||
|
} else if (roleNames.some(r => DEPT_ACCESS_ROLES.includes(r))) {
|
||||||
|
permissionLevel = 'department';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询 depCode 对应的部门名称
|
||||||
|
let depName = '';
|
||||||
|
if (userInfo.depCode) {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
|
||||||
|
[userInfo.depCode]
|
||||||
|
) as [{ dep_name: string }[], unknown];
|
||||||
|
depName = rows[0]?.dep_name || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
userId: userInfo.id,
|
||||||
|
userName: userInfo.userName,
|
||||||
|
loginName: userInfo.loginName,
|
||||||
|
depCode: userInfo.depCode,
|
||||||
|
depName,
|
||||||
|
permissionLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });
|
||||||
|
|
||||||
|
const authUser: AuthUser = { ...payload };
|
||||||
|
|
||||||
|
return c.json({ token, user: authUser });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error('login error:', e);
|
||||||
|
return c.json({ error: 'Login failed' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
37
src/server/auth/middleware.ts
Normal file
37
src/server/auth/middleware.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Context, Next } from 'hono';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { JwtPayload, AuthUser } from './types.js';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
||||||
|
|
||||||
|
export async function authMiddleware(c: Context, next: Next) {
|
||||||
|
const path = c.req.path;
|
||||||
|
|
||||||
|
// 跳过不需要认证的路径
|
||||||
|
if (path === '/api/health' || path.startsWith('/api/auth/')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, JWT_SECRET) as JwtPayload;
|
||||||
|
const user: AuthUser = {
|
||||||
|
userId: payload.userId,
|
||||||
|
userName: payload.userName,
|
||||||
|
loginName: payload.loginName,
|
||||||
|
depCode: payload.depCode,
|
||||||
|
depName: payload.depName,
|
||||||
|
permissionLevel: payload.permissionLevel,
|
||||||
|
};
|
||||||
|
c.set('user', user);
|
||||||
|
return next();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: 'Invalid or expired token' }, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/server/auth/permissions.ts
Normal file
26
src/server/auth/permissions.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { AuthUser } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用权限过滤函数
|
||||||
|
* 适配 CachedVehicle(department, manager, managerId)和 Vehicle(departmentName, customerManager, managerId)
|
||||||
|
*/
|
||||||
|
export function filterByPermission<T>(
|
||||||
|
items: T[],
|
||||||
|
user: AuthUser,
|
||||||
|
): T[] {
|
||||||
|
if (user.permissionLevel === 'full') return items;
|
||||||
|
|
||||||
|
if (user.permissionLevel === 'department') {
|
||||||
|
return items.filter(v => {
|
||||||
|
const obj = v as any;
|
||||||
|
const dept = obj.departmentName || obj.department || null;
|
||||||
|
return dept === user.depName;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// personal: 仅看自己负责的车辆 (bd = userId)
|
||||||
|
return items.filter(v => {
|
||||||
|
const obj = v as any;
|
||||||
|
return obj.managerId === user.userId;
|
||||||
|
});
|
||||||
|
}
|
||||||
27
src/server/auth/types.ts
Normal file
27
src/server/auth/types.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export type PermissionLevel = 'full' | 'department' | 'personal';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
loginName: string;
|
||||||
|
depCode: string;
|
||||||
|
depName: string;
|
||||||
|
permissionLevel: PermissionLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
loginName: string;
|
||||||
|
depCode: string;
|
||||||
|
depName: string;
|
||||||
|
permissionLevel: PermissionLevel;
|
||||||
|
iat?: number;
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全量权限角色名 */
|
||||||
|
export const FULL_ACCESS_ROLES = ['所有权限', '数智中心', 'BI-Leader'];
|
||||||
|
|
||||||
|
/** 部门级权限角色名 */
|
||||||
|
export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
|
||||||
@@ -5,12 +5,21 @@ import { cors } from 'hono/cors';
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import vehiclesRouter from './routes/vehicles.js';
|
import vehiclesRouter from './routes/vehicles.js';
|
||||||
import mileageRouter from './routes/mileage/index.js';
|
import mileageRouter from './routes/mileage/index.js';
|
||||||
|
import authRouter from './auth/login.js';
|
||||||
|
import { authMiddleware } from './auth/middleware.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.use('/api/*', cors());
|
app.use('/api/*', cors());
|
||||||
|
|
||||||
|
// Auth 路由(不需要中间件)
|
||||||
|
app.route('/api/auth', authRouter);
|
||||||
|
|
||||||
|
// Auth 中间件(保护后续所有 /api/* 路由)
|
||||||
|
app.use('/api/*', authMiddleware);
|
||||||
|
|
||||||
app.route('/api/vehicles', vehiclesRouter);
|
app.route('/api/vehicles', vehiclesRouter);
|
||||||
app.route('/api/mileage', mileageRouter);
|
app.route('/api/mileage', mileageRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ function mergeVehicles(
|
|||||||
customer: info?.customer || null,
|
customer: info?.customer || null,
|
||||||
department: info?.department || null,
|
department: info?.department || null,
|
||||||
manager: info?.manager || null,
|
manager: info?.manager || null,
|
||||||
|
managerId: info?.manager_id || null,
|
||||||
rentStatus: info?.rent_status || null,
|
rentStatus: info?.rent_status || null,
|
||||||
entity: info?.entity || null,
|
entity: info?.entity || null,
|
||||||
project: info?.project || null,
|
project: info?.project || null,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { getCache, queryDateMileage, buildDateFilters } from './cache.js';
|
import { getCache, queryDateMileage, buildDateFilters } from './cache.js';
|
||||||
|
import { filterByPermission } from '../../auth/permissions.js';
|
||||||
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
|
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -86,6 +88,13 @@ app.get('/', async (c) => {
|
|||||||
filters = cache.filters;
|
filters = cache.filters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 权限过滤
|
||||||
|
const user = (c as any).get('user') as AuthUser | undefined;
|
||||||
|
if (user) {
|
||||||
|
allVehicles = filterByPermission(allVehicles, user);
|
||||||
|
filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围
|
||||||
|
}
|
||||||
|
|
||||||
const filtered = applyFilters(allVehicles, filterParams);
|
const filtered = applyFilters(allVehicles, filterParams);
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import pool from '../../db.js';
|
|||||||
import mileagePool from '../../mileage-db.js';
|
import mileagePool from '../../mileage-db.js';
|
||||||
import { getCache } from './cache.js';
|
import { getCache } from './cache.js';
|
||||||
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
|
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
|
||||||
|
import { filterByPermission } from '../../auth/permissions.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -170,7 +171,9 @@ app.get('/:id/vehicles', async (c) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json(result);
|
const user = (c as any).get('user') as import('../../auth/types.js').AuthUser | undefined;
|
||||||
|
const filtered = user ? filterByPermission(result, user) : result;
|
||||||
|
return c.json(filtered);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('target vehicles error:', e);
|
console.error('target vehicles error:', e);
|
||||||
return c.json([], 500);
|
return c.json([], 500);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface CachedVehicle {
|
|||||||
customer: string | null;
|
customer: string | null;
|
||||||
department: string | null;
|
department: string | null;
|
||||||
manager: string | null;
|
manager: string | null;
|
||||||
|
managerId: string | null;
|
||||||
rentStatus: string | null;
|
rentStatus: string | null;
|
||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
@@ -68,6 +69,7 @@ export interface VehicleInfoRow {
|
|||||||
customer: string | null;
|
customer: string | null;
|
||||||
department: string | null;
|
department: string | null;
|
||||||
manager: string | null;
|
manager: string | null;
|
||||||
|
manager_id: string | null;
|
||||||
rent_status: string | null;
|
rent_status: string | null;
|
||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const VEHICLE_INFO_SQL = `SELECT
|
|||||||
cus.customer_name AS customer,
|
cus.customer_name AS customer,
|
||||||
dep.dep_name AS department,
|
dep.dep_name AS department,
|
||||||
u.user_name AS manager,
|
u.user_name AS manager,
|
||||||
|
CAST(c.bd AS CHAR) AS manager_id,
|
||||||
dic_status.dic_name AS rent_status,
|
dic_status.dic_name AS rent_status,
|
||||||
org_truck.org_name AS entity,
|
org_truck.org_name AS entity,
|
||||||
c.project_name AS project
|
c.project_name AS project
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import type {
|
|||||||
BatchGroup,
|
BatchGroup,
|
||||||
InventoryTypeSummary,
|
InventoryTypeSummary,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
|
import { filterByPermission } from '../auth/permissions.js';
|
||||||
|
import type { AuthUser } from '../auth/types.js';
|
||||||
|
import type { Context } from 'hono';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -39,7 +42,8 @@ const MAIN_SQL = `SELECT
|
|||||||
dep.dep_name AS 合同归属部门,
|
dep.dep_name AS 合同归属部门,
|
||||||
org_truck.org_name AS 主体,
|
org_truck.org_name AS 主体,
|
||||||
c.project_name AS 项目名称,
|
c.project_name AS 项目名称,
|
||||||
u.user_name AS 客户经理
|
u.user_name AS 客户经理,
|
||||||
|
CAST(c.bd AS CHAR) AS 经理ID
|
||||||
FROM tab_truck truck
|
FROM tab_truck truck
|
||||||
LEFT JOIN tab_truck_remote_sync_realtime_info info
|
LEFT JOIN tab_truck_remote_sync_realtime_info info
|
||||||
ON info.id = truck.id
|
ON info.id = truck.id
|
||||||
@@ -285,6 +289,7 @@ function transformRow(row: VehicleRow): Vehicle {
|
|||||||
subjectOrg: row.主体,
|
subjectOrg: row.主体,
|
||||||
projectName: row.项目名称,
|
projectName: row.项目名称,
|
||||||
customerManager: row.客户经理,
|
customerManager: row.客户经理,
|
||||||
|
managerId: row.经理ID || null,
|
||||||
brandLabel: row.车辆品牌Label,
|
brandLabel: row.车辆品牌Label,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -305,6 +310,12 @@ async function getVehicles(): Promise<Vehicle[]> {
|
|||||||
return cachedVehicles;
|
return cachedVehicles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
|
||||||
|
const all = await getVehicles();
|
||||||
|
const user = c.get('user') as AuthUser | undefined;
|
||||||
|
return user ? filterByPermission(all, user) : all;
|
||||||
|
}
|
||||||
|
|
||||||
function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record<string, number> {
|
function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record<string, number> {
|
||||||
return regions.reduce((acc, reg) => {
|
return regions.reduce((acc, reg) => {
|
||||||
acc[reg] = vehicles.filter((v) => v.location === reg).length;
|
acc[reg] = vehicles.filter((v) => v.location === reg).length;
|
||||||
@@ -566,7 +577,7 @@ app.get('/by-type', async (c) => {
|
|||||||
|
|
||||||
// GET /api/vehicles/by-batch
|
// GET /api/vehicles/by-batch
|
||||||
app.get('/by-batch', async (c) => {
|
app.get('/by-batch', async (c) => {
|
||||||
const vehicles = await getVehicles();
|
const vehicles = await getVehiclesForUser(c);
|
||||||
const batches = Array.from(new Set(vehicles.map((v) => v.contractNo || '未知')))
|
const batches = Array.from(new Set(vehicles.map((v) => v.contractNo || '未知')))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.sort()
|
.sort()
|
||||||
@@ -595,7 +606,7 @@ app.get('/by-batch', async (c) => {
|
|||||||
|
|
||||||
// GET /api/vehicles/inventory-analysis
|
// GET /api/vehicles/inventory-analysis
|
||||||
app.get('/inventory-analysis', async (c) => {
|
app.get('/inventory-analysis', async (c) => {
|
||||||
const vehicles = await getVehicles();
|
const vehicles = await getVehiclesForUser(c);
|
||||||
|
|
||||||
const typeFilters = [
|
const typeFilters = [
|
||||||
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
|
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
|
||||||
@@ -649,7 +660,7 @@ app.get('/inventory-analysis', async (c) => {
|
|||||||
|
|
||||||
// GET /api/vehicles/dept-stats — department & manager breakdown with mileage/attendance
|
// GET /api/vehicles/dept-stats — department & manager breakdown with mileage/attendance
|
||||||
app.get('/dept-stats', async (c) => {
|
app.get('/dept-stats', async (c) => {
|
||||||
const vehicles = await getVehicles();
|
const vehicles = await getVehiclesForUser(c);
|
||||||
const withManager = vehicles.filter((v) => v.status === 'Operating');
|
const withManager = vehicles.filter((v) => v.status === 'Operating');
|
||||||
|
|
||||||
// Query realtime day_mileage from tab_truck_remote_sync_realtime_info
|
// Query realtime day_mileage from tab_truck_remote_sync_realtime_info
|
||||||
@@ -723,7 +734,7 @@ app.get('/dept-stats', async (c) => {
|
|||||||
|
|
||||||
// GET /api/vehicles/region-stats — macro-region with city drill-down
|
// GET /api/vehicles/region-stats — macro-region with city drill-down
|
||||||
app.get('/region-stats', async (c) => {
|
app.get('/region-stats', async (c) => {
|
||||||
const vehicles = await getVehicles();
|
const vehicles = await getVehiclesForUser(c);
|
||||||
const { customer, city: filterCity, region: filterRegion } = c.req.query();
|
const { customer, city: filterCity, region: filterRegion } = c.req.query();
|
||||||
let operating = vehicles.filter((v) => v.status === 'Operating' || v.status === 'Pending');
|
let operating = vehicles.filter((v) => v.status === 'Operating' || v.status === 'Pending');
|
||||||
if (customer) operating = operating.filter((v) => v.customerName === customer);
|
if (customer) operating = operating.filter((v) => v.customerName === customer);
|
||||||
@@ -799,7 +810,7 @@ app.get('/region-stats', async (c) => {
|
|||||||
|
|
||||||
// GET /api/vehicles/customer-stats — per-customer breakdown for operating vehicles
|
// GET /api/vehicles/customer-stats — per-customer breakdown for operating vehicles
|
||||||
app.get('/customer-stats', async (c) => {
|
app.get('/customer-stats', async (c) => {
|
||||||
const vehicles = await getVehicles();
|
const vehicles = await getVehiclesForUser(c);
|
||||||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||||||
|
|
||||||
const custMap = new Map<string, Vehicle[]>();
|
const custMap = new Map<string, Vehicle[]>();
|
||||||
@@ -839,7 +850,7 @@ const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
|
|||||||
|
|
||||||
// GET /api/vehicles/list — flat list with optional filters
|
// GET /api/vehicles/list — flat list with optional filters
|
||||||
app.get('/list', async (c) => {
|
app.get('/list', async (c) => {
|
||||||
const vehicles = await getVehicles();
|
const vehicles = await getVehiclesForUser(c);
|
||||||
const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query();
|
const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query();
|
||||||
|
|
||||||
let filtered = vehicles;
|
let filtered = vehicles;
|
||||||
@@ -935,7 +946,7 @@ app.get('/list', async (c) => {
|
|||||||
|
|
||||||
// GET /api/vehicles/inventory-stats — grouped inventory stats for the inventory statistics section
|
// GET /api/vehicles/inventory-stats — grouped inventory stats for the inventory statistics section
|
||||||
app.get('/inventory-stats', async (c) => {
|
app.get('/inventory-stats', async (c) => {
|
||||||
const vehicles = await getVehicles();
|
const vehicles = await getVehiclesForUser(c);
|
||||||
const inventory = vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
|
const inventory = vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
|
||||||
|
|
||||||
const TYPE_NAME_MAP: Record<string, string> = {
|
const TYPE_NAME_MAP: Record<string, string> = {
|
||||||
@@ -997,7 +1008,7 @@ app.get('/weekly-detail', async (c) => {
|
|||||||
app.get('/refresh', async (c) => {
|
app.get('/refresh', async (c) => {
|
||||||
lastFetchTime = 0;
|
lastFetchTime = 0;
|
||||||
weeklyStatsLastFetch = 0;
|
weeklyStatsLastFetch = 0;
|
||||||
const vehicles = await getVehicles();
|
const vehicles = await getVehiclesForUser(c);
|
||||||
return c.json({ message: 'Cache refreshed', count: vehicles.length });
|
return c.json({ message: 'Cache refreshed', count: vehicles.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1032,7 +1043,7 @@ app.get('/debug', async (c) => {
|
|||||||
|
|
||||||
// GET /api/vehicles/region-chart — aggregated chart data with top N + "其他"
|
// GET /api/vehicles/region-chart — aggregated chart data with top N + "其他"
|
||||||
app.get('/region-chart', async (c) => {
|
app.get('/region-chart', async (c) => {
|
||||||
const vehicles = await getVehicles();
|
const vehicles = await getVehiclesForUser(c);
|
||||||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||||||
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'province'
|
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'province'
|
||||||
const source = c.req.query('source') || 'realtime'; // 'realtime' | 'vehicle'
|
const source = c.req.query('source') || 'realtime'; // 'realtime' | 'vehicle'
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface VehicleRow {
|
|||||||
主体: string | null;
|
主体: string | null;
|
||||||
项目名称: string | null;
|
项目名称: string | null;
|
||||||
客户经理: string | null;
|
客户经理: string | null;
|
||||||
|
经理ID: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Vehicle {
|
export interface Vehicle {
|
||||||
@@ -48,6 +49,7 @@ export interface Vehicle {
|
|||||||
subjectOrg: string | null;
|
subjectOrg: string | null;
|
||||||
projectName: string | null;
|
projectName: string | null;
|
||||||
customerManager: string | null;
|
customerManager: string | null;
|
||||||
|
managerId: string | null;
|
||||||
brandLabel: string | null;
|
brandLabel: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user