feat: add vehicle and hydrogen heatmaps
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
kkfluous
2026-07-13 21:41:20 +08:00
parent a558db5795
commit 392a36a0ec
23 changed files with 2313 additions and 52 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
.env
.git
.codex
outputs
reports
data/geo
data/vehicle-heatmap/source.csv

View File

@@ -23,6 +23,14 @@ services:
SERVER_PORT: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com"
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
HEATMAP_DB_USER: "${HEATMAP_DB_USER}"
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
deploy:
replicas: 1
restart_policy:

162
package-lock.json generated
View File

@@ -1,13 +1,14 @@
{
"name": "ln-bi",
"version": "1.1.6",
"version": "1.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ln-bi",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10",
"ali-oss": "^6.23.0",
@@ -17,6 +18,7 @@
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"mysql2": "^3.11.0",
"pg": "^8.22.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
@@ -27,6 +29,7 @@
"@tailwindcss/vite": "^4.1.14",
"@types/ali-oss": "^6.23.3",
"@types/node": "^22.14.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",
@@ -36,6 +39,12 @@
"vite": "^6.2.0"
}
},
"node_modules/@amap/amap-jsapi-loader": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
"integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -1620,6 +1629,18 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -3521,6 +3542,95 @@
"through": "~2.3"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3576,6 +3686,45 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -3969,6 +4118,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "ln-bi",
"private": true,
"version": "1.1.6",
"version": "1.1.7",
"type": "module",
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
@@ -12,6 +12,7 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10",
"ali-oss": "^6.23.0",
@@ -21,6 +22,7 @@
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"mysql2": "^3.11.0",
"pg": "^8.22.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
@@ -31,6 +33,7 @@
"@tailwindcss/vite": "^4.1.14",
"@types/ali-oss": "^6.23.3",
"@types/node": "^22.14.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",

View File

@@ -1,5 +1,5 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt } from "lucide-react";
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt, MapPinned } from "lucide-react";
import { Shell, type ModuleConfig } from "./components/Shell";
import AuthProvider from "./auth/AuthProvider";
import { useAuth } from "./auth/useAuth";
@@ -15,6 +15,8 @@ const ElectricModule = lazy(() => import("./modules/energy/ElectricModule"));
const EtcModule = lazy(() => import("./modules/energy/EtcModule"));
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
const VehicleHeatmapModule = lazy(() => import("./modules/vehicle-heatmap/VehicleHeatmapModule"));
const HydrogenHeatmapModule = lazy(() => import("./modules/hydrogen-heatmap/HydrogenHeatmapModule"));
const ASSETS_MODULE: ModuleConfig = {
id: "assets",
@@ -30,6 +32,13 @@ const MILEAGE_MODULE: ModuleConfig = {
component: MileageModule,
};
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
id: "vehicle-heatmap",
label: "车辆",
icon: MapPinned,
component: VehicleHeatmapModule,
};
const SCHEDULING_MODULE: ModuleConfig = {
id: "scheduling",
label: "智能调度",
@@ -44,6 +53,13 @@ const HYDROGEN_MODULE: ModuleConfig = {
component: HydrogenModule,
};
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
id: "hydrogen-heatmap",
label: "加氢",
icon: MapPinned,
component: HydrogenHeatmapModule,
};
const ELECTRIC_MODULE: ModuleConfig = {
id: "electric",
label: "电能",
@@ -138,7 +154,16 @@ function AuthGate() {
if (pathSet === "energy") {
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
}
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE];
const heatmapChildren = canAccessEnergy(user?.roles)
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
: [VEHICLE_HEATMAP_MODULE];
const heatmapModule: ModuleConfig = {
id: "heatmap",
label: "热力图",
icon: MapPinned,
children: heatmapChildren,
};
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE, heatmapModule];
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
return result;
}, [pathSet, user?.roles]);

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
import { motion } from 'motion/react';
import { Building2, ShieldCheck } from 'lucide-react';
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab';
@@ -11,7 +11,12 @@ export interface ModuleConfig {
id: string;
label: string;
icon: ComponentType<{ size?: number; className?: string }>;
component: ElementType;
component?: ElementType;
children?: ModuleConfig[];
}
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] {
return modules.flatMap((module) => module.children ?? [module]);
}
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id */
@@ -31,16 +36,27 @@ function getHashModule(modules: ModuleConfig[]): string {
}
export function Shell({ modules }: { modules: ModuleConfig[] }) {
const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
const flatModules = useMemo(() => flattenModules(modules), [modules]);
const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules)));
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
useEffect(() => {
const onHashChange = () => {
const h = getHashModule(modules);
const h = getHashModule(flatModules);
if (h) setActiveModule(h);
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, [modules]);
}, [flatModules]);
useEffect(() => {
if (!openGroupId) return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpenGroupId(null);
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [openGroupId]);
useEffect(() => {
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
@@ -53,16 +69,20 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}, [activeModule]);
const switchModule = (id: string) => {
if (getHashHead() === id) return;
setOpenGroupId(null);
if (getHashHead() === id) {
setActiveModule(id);
return;
}
const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
setActiveModule(id);
};
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component;
const { user } = useAuth();
const activeLabel = modules.find((m) => m.id === activeModule)?.label ?? '业务看板';
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
const watermarkText = useMemo(() => {
const name = user?.userName || '未登录';
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
@@ -87,29 +107,43 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
<div className="flex w-full flex-1 flex-col items-center gap-2">
{modules.map((m) => {
const Icon = m.icon;
const isActive = m.id === activeModule;
const isGroup = Boolean(m.children?.length);
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
const isOpen = openGroupId === m.id;
return (
<button
key={m.id}
onClick={() => switchModule(m.id)}
className={cn(
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
isActive
? 'text-white'
: 'text-slate-400 hover:bg-white/8 hover:text-white',
)}
title={m.label}
>
{isActive ? (
<motion.span
layoutId="desktop-shell-active"
className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30"
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
/>
<div key={m.id} className="relative">
<button
type="button"
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
aria-expanded={isGroup ? isOpen : undefined}
aria-haspopup={isGroup ? 'menu' : undefined}
className={cn(
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
)}
title={m.label}
>
{isActive ? (
<motion.span layoutId="desktop-shell-active" className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
) : null}
<Icon size={21} className="relative mb-1" />
<span className="relative leading-tight">{m.label}</span>
{isGroup ? <ChevronRight size={11} className={cn('absolute right-1 top-1/2 -translate-y-1/2 transition-transform', isOpen && 'rotate-180')} /> : null}
</button>
{isGroup && isOpen ? (
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute left-[calc(100%+12px)] top-1/2 w-36 -translate-y-1/2 rounded-2xl border border-slate-700 bg-slate-900 p-2 shadow-2xl shadow-slate-950/30">
{m.children!.map((child) => {
const ChildIcon = child.icon;
const childActive = child.id === activeModule;
return (
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-black transition-colors', childActive ? 'bg-blue-600 text-white' : 'text-slate-300 hover:bg-white/8 hover:text-white')}>
<ChildIcon size={16} />{child.label}
</button>
);
})}
</div>
) : null}
<Icon size={21} className="relative mb-1" />
<span className="relative leading-tight">{m.label}</span>
</button>
</div>
);
})}
</div>
@@ -139,26 +173,38 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
{modules.map((m) => {
const Icon = m.icon;
const isActive = m.id === activeModule;
const isGroup = Boolean(m.children?.length);
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
const isOpen = openGroupId === m.id;
return (
<button
key={m.id}
onClick={() => switchModule(m.id)}
className={cn(
'relative flex min-h-12 flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-black transition-colors',
isActive ? 'text-blue-700' : 'text-slate-400',
)}
>
{isActive ? (
<motion.span
layoutId="mobile-shell-active"
className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100"
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
/>
<div key={m.id} className="relative flex justify-center">
<button
type="button"
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
aria-expanded={isGroup ? isOpen : undefined}
aria-haspopup={isGroup ? 'menu' : undefined}
className={cn('relative flex min-h-12 w-full flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-black transition-colors', isActive ? 'text-blue-700' : 'text-slate-400')}
>
{isActive ? (
<motion.span layoutId="mobile-shell-active" className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
) : null}
<Icon size={20} className="relative" />
<span className="relative">{m.label}</span>
</button>
{isGroup && isOpen ? (
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute bottom-[calc(100%+14px)] left-1/2 w-36 -translate-x-1/2 rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl shadow-slate-900/15">
{m.children!.map((child) => {
const ChildIcon = child.icon;
const childActive = child.id === activeModule;
return (
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-black transition-colors', childActive ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50')}>
<ChildIcon size={16} />{child.label}
</button>
);
})}
</div>
) : null}
<Icon size={20} className="relative" />
<span className="relative">{m.label}</span>
</button>
</div>
);
})}
</div>

View File

@@ -20,6 +20,32 @@ html {
body {
overflow: auto;
height: 100%;
color: #0f172a;
background: #fff;
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select {
font: inherit;
}
.amap-logo,
.amap-copyright {
opacity: 0.82;
}
.amap-toolbar {
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
box-shadow: 0 8px 24px rgb(15 23 42 / 10%) !important;
overflow: hidden;
}
@media (max-width: 767px) {
.amap-logo,
.amap-copyright {
bottom: 60px !important;
}
}
@keyframes marquee {

View File

@@ -0,0 +1,148 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { AmapConfig, HydrogenHeatmapMetric, HydrogenHeatmapPoint } from './types';
type Props = {
config: AmapConfig;
points: HydrogenHeatmapPoint[];
max: number;
metric: HydrogenHeatmapMetric;
focusQuery: string;
onMapClick: (longitude: number, latitude: number) => void;
};
type AmapInstance = {
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
HeatMap: new (map: any, options: Record<string, unknown>) => any;
ToolBar: new (options?: Record<string, unknown>) => any;
Scale: new (options?: Record<string, unknown>) => any;
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
};
declare global {
interface Window {
_AMapSecurityConfig?: { securityJsCode: string };
}
}
function getBounds(points: HydrogenHeatmapPoint[]) {
if (!points.length) return null;
return points.reduce((bounds, point) => ({
minLng: Math.min(bounds.minLng, point.lng),
maxLng: Math.max(bounds.maxLng, point.lng),
minLat: Math.min(bounds.minLat, point.lat),
maxLat: Math.max(bounds.maxLat, point.lat),
}), { minLng: points[0].lng, maxLng: points[0].lng, minLat: points[0].lat, maxLat: points[0].lat });
}
export default function HydrogenAmapCanvas({ config, points, max, metric, focusQuery, onMapClick }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const heatmapRef = useRef<any>(null);
const amapRef = useRef<AmapInstance | null>(null);
const clickHandlerRef = useRef(onMapClick);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const intensity = useMemo(() => {
const transform = metric === 'kg'
? (value: number) => Math.log1p(value)
: (value: number) => Math.sqrt(value);
return {
points: points.map((point) => ({ ...point, count: transform(point.count) })),
max: transform(Math.max(1, max)),
};
}, [max, metric, points]);
useEffect(() => {
clickHandlerRef.current = onMapClick;
}, [onMapClick]);
useEffect(() => {
let cancelled = false;
const container = containerRef.current;
if (!container) return undefined;
async function initialize() {
try {
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
const loaderModule = await import('@amap/amap-jsapi-loader');
const AMap = await loaderModule.default.load({
key: config.key,
version: '2.0',
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
}) as unknown as AmapInstance;
if (cancelled || !container) return;
const map = new AMap.Map(container, {
viewMode: '2D',
zoom: 5,
center: [105.4, 34.4],
mapStyle: 'amap://styles/whitesmoke',
resizeEnable: true,
showLabel: true,
});
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
const heatmap = new AMap.HeatMap(map, {
radius: 34,
opacity: [0.14, 0.84],
gradient: {
0.1: '#2563eb',
0.3: '#0891b2',
0.5: '#16a34a',
0.68: '#eab308',
0.84: '#f97316',
1: '#dc2626',
},
});
map.on('click', (event: any) => clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat()));
mapRef.current = map;
heatmapRef.current = heatmap;
amapRef.current = AMap;
setStatus('ready');
} catch (error) {
console.error('Hydrogen heatmap AMap initialization failed', error);
if (!cancelled) setStatus('error');
}
}
initialize();
return () => {
cancelled = true;
heatmapRef.current?.setMap?.(null);
mapRef.current?.destroy?.();
heatmapRef.current = null;
mapRef.current = null;
amapRef.current = null;
};
}, [config.key, config.securityCode]);
useEffect(() => {
if (status !== 'ready' || !heatmapRef.current) return;
heatmapRef.current.setDataSet({ data: intensity.points, max: intensity.max });
if (!focusQuery || !points.length || !mapRef.current || !amapRef.current) return;
const bounds = getBounds(points);
if (!bounds) return;
if (points.length === 1) {
mapRef.current.setZoomAndCenter(12, [points[0].lng, points[0].lat]);
return;
}
mapRef.current.setBounds(new amapRef.current.Bounds(
[bounds.minLng, bounds.minLat],
[bounds.maxLng, bounds.maxLat],
), false, [80, 80, 80, 80]);
}, [focusQuery, intensity, points, status]);
return (
<div className="absolute inset-0 bg-[#eef3f7]">
<div ref={containerRef} className="h-full w-full" aria-label="加氢站加氢热力图地图" />
{status === 'loading' ? (
<div className="absolute inset-0 grid place-items-center bg-white/76 text-sm font-medium text-slate-500 backdrop-blur-[2px]">
<span className="flex items-center gap-2.5"><i className="h-4 w-4 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /></span>
</div>
) : null}
{status === 'error' ? (
<div className="absolute inset-0 grid place-items-center bg-slate-50 text-center">
<div><p className="text-sm font-semibold text-slate-700"></p><p className="mt-1 text-xs text-slate-400"> Key</p></div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { ChevronRight, Fuel, X } from 'lucide-react';
import type { HydrogenHeatmapMetric, HydrogenStationRank } from './types';
type Props = {
title: string;
subtitle: string;
kg: number;
refuelCount: number;
vehicleCount: number;
stationCount: number;
stations: HydrogenStationRank[];
metric: HydrogenHeatmapMetric;
selected: boolean;
loading: boolean;
coverageText: string;
onClose: () => void;
onHide: () => void;
};
const integerFormat = new Intl.NumberFormat('zh-CN');
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
function metricLabel(metric: HydrogenHeatmapMetric) {
if (metric === 'refuels') return '按加氢次数排序';
if (metric === 'vehicles') return '按车辆数排序';
return '按加氢量排序';
}
function metricValue(station: HydrogenStationRank, metric: HydrogenHeatmapMetric) {
if (metric === 'kg') return `${kgFormat.format(station.kg)} kg`;
if (metric === 'refuels') return `${integerFormat.format(station.refuelCount)}`;
return `${integerFormat.format(station.vehicleCount)}`;
}
export default function HydrogenHeatmapDetailPanel(props: Props) {
return (
<aside className="relative z-20 flex h-full w-[310px] shrink-0 flex-col border-l border-slate-200 bg-white max-lg:absolute max-lg:bottom-0 max-lg:left-0 max-lg:right-0 max-lg:h-[43vh] max-lg:w-full max-lg:rounded-t-2xl max-lg:border-l-0 max-lg:border-t max-lg:shadow-[0_-10px_30px_rgba(15,23,42,0.14)] max-md:h-[50dvh]">
<div className="border-b border-slate-100 px-5 pb-4 pt-5 max-md:px-4 max-md:pb-3 max-md:pt-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-[17px] font-semibold tracking-tight text-slate-900">{props.title}</h2>
{props.loading ? <i className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}
</div>
<p className="mt-1 text-[11px] text-slate-400">{props.subtitle}</p>
<p className="mt-1 text-[10px] font-medium text-emerald-600">{props.coverageText}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{props.selected ? (
<button type="button" onClick={props.onClose} aria-label="关闭加氢区域详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-50 hover:text-slate-700"><X size={18} strokeWidth={1.8} /></button>
) : null}
<button type="button" onClick={props.onHide} aria-label="隐藏加氢详情" title="隐藏详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 transition hover:bg-cyan-50 hover:text-cyan-700 max-md:h-10 max-md:w-10"><ChevronRight size={18} strokeWidth={1.8} /></button>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-slate-100 pt-4 max-md:mt-3 max-md:gap-y-2 max-md:pt-3">
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[19px] font-semibold tracking-tight text-cyan-700">{kgFormat.format(props.kg)} <small className="text-[10px] font-medium">kg</small></p></div>
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[19px] font-semibold tracking-tight text-cyan-700">{integerFormat.format(props.refuelCount)}</p></div>
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[16px] font-semibold text-slate-700">{integerFormat.format(props.vehicleCount)}</p></div>
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[16px] font-semibold text-slate-700">{integerFormat.format(props.stationCount)}</p></div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4 max-md:px-4 max-md:py-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold text-slate-800"> TOP10</h3>
<span className="text-[10px] text-slate-400">{metricLabel(props.metric)}</span>
</div>
{props.stations.length ? (
<ol className="divide-y divide-slate-100">
{props.stations.map((station, index) => (
<li key={station.stationId} className="grid grid-cols-[24px_minmax(0,1fr)_68px] items-center gap-2 py-2.5">
<span className={`text-[11px] font-medium ${index < 3 ? 'text-cyan-700' : 'text-slate-400'}`}>{index + 1}</span>
<span className="min-w-0"><span className="block truncate text-[12px] font-medium text-slate-700" title={station.stationName}>{station.stationName}</span><span className="mt-0.5 block truncate text-[9px] text-slate-400" title={station.address}>{station.address || '地址未维护'}</span></span>
<span className="text-right text-[10px] tabular-nums text-slate-600">{metricValue(station, props.metric)}</span>
</li>
))}
</ol>
) : (
<div className="grid h-44 place-items-center text-center text-xs text-slate-400"><span><Fuel className="mx-auto mb-2 text-slate-300" size={22} /></span></div>
)}
</div>
</aside>
);
}

View File

@@ -0,0 +1,85 @@
import { CalendarDays, ChevronUp, RotateCcw, Search } from 'lucide-react';
import type { HydrogenHeatmapMetric, HydrogenPayer, HydrogenStationOption } from './types';
type Props = {
startDate: string;
endDate: string;
minDate: string;
maxDate: string;
query: string;
payer: HydrogenPayer;
metric: HydrogenHeatmapMetric;
stations: HydrogenStationOption[];
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
onQueryChange: (value: string) => void;
onPayerChange: (value: HydrogenPayer) => void;
onMetricChange: (value: HydrogenHeatmapMetric) => void;
onReset: () => void;
onHide: () => void;
};
const FIELD = 'relative h-11 min-w-0 rounded-lg border border-slate-200 bg-white transition focus-within:border-cyan-400 focus-within:ring-2 focus-within:ring-cyan-100';
const LABEL = 'pointer-events-none absolute left-3 top-1 z-10 text-[9px] font-medium leading-none text-slate-400';
const CONTROL = 'h-full w-full min-w-0 rounded-lg border-0 bg-transparent px-3 pt-3 text-[12px] text-slate-700 outline-none';
export default function HydrogenHeatmapFilters(props: Props) {
return (
<div className="pointer-events-auto grid max-h-[calc(100dvh-180px)] w-full grid-cols-2 gap-2 overflow-y-auto rounded-xl border border-slate-200/80 bg-white/96 p-2 shadow-[0_8px_28px_rgba(15,23,42,0.12)] backdrop-blur-md md:flex md:w-auto md:min-w-max md:items-center md:overflow-visible">
<label className={`${FIELD} col-span-2 md:w-[320px]`}>
<span className={`${LABEL} left-9`}></span>
<span className="flex h-full min-w-0 items-center pl-8 pt-2">
<CalendarDays className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={15} strokeWidth={1.8} />
<input aria-label="加氢开始日期" type="date" min={props.minDate} max={props.endDate} value={props.startDate} onChange={(event) => props.onStartDateChange(event.target.value)} className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none" />
<span className="shrink-0 px-0.5 text-slate-300"></span>
<input aria-label="加氢结束日期" type="date" min={props.startDate} max={props.maxDate} value={props.endDate} onChange={(event) => props.onEndDateChange(event.target.value)} className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none" />
</span>
</label>
<label className={`${FIELD} col-span-2 md:w-[145px]`}>
<span className={LABEL}></span>
<span className="relative block h-full">
<input
aria-label="搜索加氢站"
value={props.query}
list="hydrogen-heatmap-stations"
onChange={(event) => props.onQueryChange(event.target.value)}
placeholder="输入站点名称"
className={`${CONTROL} pr-9`}
/>
<Search className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} strokeWidth={1.8} />
<datalist id="hydrogen-heatmap-stations">
{props.stations.map((station) => <option key={station.stationId} value={station.stationName} />)}
</datalist>
</span>
</label>
<label className={`${FIELD} md:w-[100px]`}>
<span className={LABEL}></span>
<select aria-label="费用承担" value={props.payer} onChange={(event) => props.onPayerChange(event.target.value as HydrogenPayer)} className={CONTROL}>
<option value="all"></option>
<option value="lingniu"></option>
<option value="customer"></option>
</select>
</label>
<label className={`${FIELD} md:w-[100px]`}>
<span className={LABEL}></span>
<select aria-label="加氢统计维度" value={props.metric} onChange={(event) => props.onMetricChange(event.target.value as HydrogenHeatmapMetric)} className={CONTROL}>
<option value="kg"></option>
<option value="refuels"></option>
<option value="vehicles"></option>
</select>
</label>
<div className="col-span-2 flex min-w-0 justify-end gap-2 md:w-auto">
<button type="button" onClick={props.onReset} className="flex h-11 min-w-[72px] items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-[12px] font-medium text-slate-600 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700">
<RotateCcw size={14} strokeWidth={1.8} />
</button>
<button type="button" onClick={props.onHide} aria-label="隐藏加氢筛选" title="隐藏筛选" className="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-slate-200 bg-white text-slate-400 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:w-10">
<ChevronUp size={16} strokeWidth={1.8} />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,222 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, Clock3, Fuel, Maximize2, Minimize2, SlidersHorizontal } from 'lucide-react';
import HydrogenAmapCanvas from './HydrogenAmapCanvas';
import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel';
import HydrogenHeatmapFilters from './HydrogenHeatmapFilters';
import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api';
import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types';
const DEFAULT_START = '2026-01-01';
const DEFAULT_END = '2026-07-13';
const integerFormat = new Intl.NumberFormat('zh-CN');
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
function Metric({ value, label }: { value: string; label: string }) {
return (
<div className="min-w-[88px] text-center max-md:min-w-[68px]">
<p className="text-[21px] font-semibold leading-none tracking-tight text-cyan-700 tabular-nums max-md:text-[16px]">{value}</p>
<p className="mt-1.5 text-[11px] text-slate-500 max-md:mt-1 max-md:text-[9px]">{label}</p>
</div>
);
}
function metricName(metric: HydrogenHeatmapMetric) {
if (metric === 'refuels') return '加氢频次';
if (metric === 'vehicles') return '车辆覆盖';
return '加氢量强度';
}
function metricDescription(metric: HydrogenHeatmapMetric) {
if (metric === 'refuels') return '每个站点累计有效加氢订单数;平方根平滑强度。';
if (metric === 'vehicles') return '每个站点按车牌去重统计车辆数;平方根平滑强度。';
return '每个站点累计有效加氢量kg对数平滑强度。';
}
export default function HydrogenHeatmapModule() {
const [meta, setMeta] = useState<HydrogenHeatmapMeta | null>(null);
const [config, setConfig] = useState<AmapConfig | null>(null);
const [data, setData] = useState<HydrogenHeatmapResponse | null>(null);
const [nearby, setNearby] = useState<HydrogenNearbyResponse | null>(null);
const [startDate, setStartDate] = useState(DEFAULT_START);
const [endDate, setEndDate] = useState(DEFAULT_END);
const [query, setQuery] = useState('');
const [payer, setPayer] = useState<HydrogenPayer>('all');
const [metric, setMetric] = useState<HydrogenHeatmapMetric>('kg');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showFilters, setShowFilters] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [showDetails, setShowDetails] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [loading, setLoading] = useState(true);
const [nearbyLoading, setNearbyLoading] = useState(false);
const [error, setError] = useState('');
const deferredQuery = useDeferredValue(query.trim());
useEffect(() => {
if (!isFullscreen) return undefined;
const previousOverflow = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsFullscreen(false);
};
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [isFullscreen]);
useEffect(() => {
const controller = new AbortController();
Promise.all([fetchHydrogenHeatmapMeta(controller.signal), fetchAmapConfig(controller.signal)])
.then(([nextMeta, nextConfig]) => {
setMeta(nextMeta);
setConfig(nextConfig);
setStartDate(nextMeta.startDate);
setEndDate(nextMeta.endDate);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('加氢热力图基础配置加载失败');
});
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setError('');
fetchHydrogenHeatmapPoints({ startDate, endDate, query: deferredQuery, payer, metric }, controller.signal)
.then((response) => {
setData(response);
setNearby(null);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('加氢热力图数据加载失败,请稍后重试');
})
.finally(() => setLoading(false));
}, 220);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [deferredQuery, endDate, metric, payer, startDate]);
const handleMapClick = useCallback((longitude: number, latitude: number) => {
const controller = new AbortController();
setNearbyLoading(true);
fetchNearbyHydrogenStations({ lng: longitude, lat: latitude, startDate, endDate, query: deferredQuery, payer, metric }, controller.signal)
.then(setNearby)
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('区域加氢站明细加载失败');
})
.finally(() => setNearbyLoading(false));
}, [deferredQuery, endDate, metric, payer, startDate]);
const reset = () => {
setStartDate(meta?.startDate || DEFAULT_START);
setEndDate(meta?.endDate || DEFAULT_END);
setQuery('');
setPayer('all');
setMetric('kg');
setNearby(null);
};
const coverageText = meta
? `GPS覆盖 ${(meta.gpsCoverageRate * 100).toFixed(3)}% · 已排除 ${integerFormat.format(meta.excludedRefuelCount)} 笔缺坐标数据`
: '正在核对站点 GPS 覆盖率';
const detail = useMemo(() => {
if (nearby) {
return {
title: '选中区域',
subtitle: `${nearby.center.lng.toFixed(3)}, ${nearby.center.lat.toFixed(3)} · 半径 ${nearby.radiusKm} 公里`,
kg: nearby.kg,
refuelCount: nearby.refuelCount,
vehicleCount: nearby.vehicleCount,
stationCount: nearby.stationCount,
stations: nearby.topStations,
selected: true,
};
}
return {
title: deferredQuery ? '站点筛选结果' : '全国 · 当前范围',
subtitle: `${payer === 'all' ? '' : `${payer === 'lingniu' ? '羚牛承担' : '客户承担'} · `}${startDate} ${endDate}`,
kg: data?.kg || 0,
refuelCount: data?.refuelCount || 0,
vehicleCount: data?.vehicleCount || 0,
stationCount: data?.stationCount || 0,
stations: data?.topStations || [],
selected: false,
};
}, [data, deferredQuery, endDate, nearby, payer, startDate]);
return (
<section className={`flex flex-col overflow-hidden bg-white ${isFullscreen ? 'fixed inset-0 z-[100] h-screen w-screen min-h-0' : 'h-[100dvh] min-h-[620px] max-md:h-[calc(100dvh-4rem)] max-md:min-h-[520px]'}`}>
<header className="relative z-30 flex min-h-[98px] shrink-0 items-center justify-between border-b border-slate-200 bg-white px-8 max-md:min-h-[104px] max-md:flex-wrap max-md:gap-y-2 max-md:px-3 max-md:py-3">
<div className="min-w-0">
<h1 className="truncate text-[25px] font-semibold tracking-[-0.025em] text-slate-950 max-md:text-[18px]"></h1>
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-400 max-md:mt-1 max-md:text-[9px]"><Fuel size={13} /> × GPS · </p>
</div>
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-7 max-md:order-3 max-md:static max-md:w-full max-md:translate-x-0 max-md:translate-y-0 max-md:justify-center max-md:gap-2">
<Metric value={kgFormat.format(data?.kg ?? meta?.eligibleKg ?? 0)} label="加氢量 kg" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={integerFormat.format(data?.refuelCount ?? meta?.eligibleRefuelCount ?? 0)} label="加氢次数" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={integerFormat.format(data?.stationCount ?? meta?.eligibleStationCount ?? 0)} label="站点" />
</div>
<div className="flex items-center gap-3">
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} /> {meta?.endDate || DEFAULT_END}</p>
<button type="button" onClick={() => setIsFullscreen((current) => !current)} className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700" title={isFullscreen ? '退出全屏' : '地图全屏'} aria-label={isFullscreen ? '退出加氢地图全屏' : '加氢地图全屏'} aria-pressed={isFullscreen}>
{isFullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
</div>
</header>
<div className="relative flex min-h-0 flex-1">
<div className="relative z-0 min-w-0 flex-1 overflow-hidden">
{config ? <HydrogenAmapCanvas config={config} points={data?.points || []} max={data?.max || 1} metric={metric} focusQuery={deferredQuery} onMapClick={handleMapClick} /> : <div className="absolute inset-0 bg-slate-100" />}
{showFilters ? (
<div className="pointer-events-none absolute left-3 right-3 top-3 z-10 md:left-5 md:right-auto md:top-5">
<HydrogenHeatmapFilters
startDate={startDate}
endDate={endDate}
minDate={meta?.startDate || DEFAULT_START}
maxDate={meta?.endDate || DEFAULT_END}
query={query}
payer={payer}
metric={metric}
stations={meta?.stations || []}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onQueryChange={setQuery}
onPayerChange={setPayer}
onMetricChange={setMetric}
onReset={reset}
onHide={() => setShowFilters(false)}
/>
</div>
) : (
<button type="button" onClick={() => setShowFilters(true)} aria-label="显示加氢筛选" className="absolute left-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:left-5 md:top-5 md:h-10"><SlidersHorizontal size={15} strokeWidth={1.8} /></button>
)}
{!showDetails ? (
<button type="button" onClick={() => setShowDetails(true)} aria-label="显示加氢详情" className={`absolute right-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:right-5 md:top-5 md:h-10 ${showFilters ? 'max-md:hidden' : ''}`}><ChevronLeft size={16} strokeWidth={1.8} /></button>
) : null}
<div className="pointer-events-none absolute bottom-5 left-5 z-10 w-[300px] rounded-xl border border-slate-200/80 bg-white/95 p-3.5 shadow-[0_8px_30px_rgba(15,23,42,0.11)] backdrop-blur-sm max-lg:bottom-[calc(43vh+16px)] max-md:hidden">
<div className="flex items-center justify-between text-[11px] font-medium text-slate-700"><span>{metricName(metric)}</span>{loading ? <i className="h-3 w-3 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">{metricDescription(metric)}</p>
<div className="mt-2.5 h-2.5 rounded-full bg-[linear-gradient(90deg,#2563eb_0%,#0891b2_25%,#16a34a_45%,#eab308_65%,#f97316_82%,#dc2626_100%)]" />
<div className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span></span><span></span></div>
</div>
{error ? <div className="absolute bottom-5 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-xs text-white shadow-xl"><AlertTriangle size={15} className="text-amber-300" />{error}</div> : null}
</div>
{showDetails ? (
<HydrogenHeatmapDetailPanel {...detail} metric={metric} loading={nearbyLoading} coverageText={coverageText} onClose={() => setNearby(null)} onHide={() => setShowDetails(false)} />
) : null}
</div>
</section>
);
}

View File

@@ -0,0 +1,51 @@
import { fetchJson } from '../../auth/api-client';
import type {
AmapConfig,
HydrogenHeatmapMeta,
HydrogenHeatmapMetric,
HydrogenHeatmapResponse,
HydrogenNearbyResponse,
HydrogenPayer,
} from './types';
const BASE = '/api/hydrogen-heatmap';
export function fetchAmapConfig(signal?: AbortSignal) {
return fetchJson<AmapConfig>(`${BASE}/config`, { signal });
}
export function fetchHydrogenHeatmapMeta(signal?: AbortSignal) {
return fetchJson<HydrogenHeatmapMeta>(`${BASE}/meta`, { signal });
}
export function fetchHydrogenHeatmapPoints(
params: { startDate: string; endDate: string; query: string; payer: HydrogenPayer; metric: HydrogenHeatmapMetric },
signal?: AbortSignal,
) {
return fetchJson<HydrogenHeatmapResponse>(`${BASE}/points?${new URLSearchParams(params)}`, { signal });
}
export function fetchNearbyHydrogenStations(
params: {
lng: number;
lat: number;
startDate: string;
endDate: string;
query: string;
payer: HydrogenPayer;
metric: HydrogenHeatmapMetric;
},
signal?: AbortSignal,
) {
const search = new URLSearchParams({
lng: String(params.lng),
lat: String(params.lat),
startDate: params.startDate,
endDate: params.endDate,
query: params.query,
payer: params.payer,
metric: params.metric,
radiusKm: '50',
});
return fetchJson<HydrogenNearbyResponse>(`${BASE}/nearby?${search}`, { signal });
}

View File

@@ -0,0 +1,72 @@
export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
export type HydrogenPayer = 'all' | 'lingniu' | 'customer';
export type HydrogenHeatmapPoint = {
lng: number;
lat: number;
count: number;
};
export type HydrogenStationRank = {
stationId: string;
stationName: string;
address: string;
lng: number;
lat: number;
kg: number;
refuelCount: number;
vehicleCount: number;
firstRefuel: string;
lastRefuel: string;
metricValue: number;
};
export type HydrogenStationOption = {
stationId: string;
stationName: string;
};
export type HydrogenHeatmapMeta = {
startDate: string;
endDate: string;
totalRefuelCount: number;
eligibleRefuelCount: number;
excludedRefuelCount: number;
gpsCoverageRate: number;
totalKg: number;
eligibleKg: number;
vehicleCount: number;
totalStationCount: number;
eligibleStationCount: number;
stations: HydrogenStationOption[];
};
export type HydrogenHeatmapResponse = {
startDate: string;
endDate: string;
metric: HydrogenHeatmapMetric;
payer: HydrogenPayer;
kg: number;
refuelCount: number;
vehicleCount: number;
stationCount: number;
dayCount: number;
points: HydrogenHeatmapPoint[];
max: number;
topStations: HydrogenStationRank[];
};
export type HydrogenNearbyResponse = {
center: { lng: number; lat: number };
radiusKm: number;
kg: number;
refuelCount: number;
vehicleCount: number;
stationCount: number;
topStations: HydrogenStationRank[];
};
export type AmapConfig = {
key: string;
securityCode: string;
};

View File

@@ -0,0 +1,158 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { AmapConfig, HeatmapMetric, HeatmapPoint } from './types';
type Props = {
config: AmapConfig;
points: HeatmapPoint[];
max: number;
metric: HeatmapMetric;
focusQuery: string;
onMapClick: (longitude: number, latitude: number) => void;
};
type AmapInstance = {
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
HeatMap: new (map: any, options: Record<string, unknown>) => any;
ToolBar: new (options?: Record<string, unknown>) => any;
Scale: new (options?: Record<string, unknown>) => any;
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
};
declare global {
interface Window {
_AMapSecurityConfig?: { securityJsCode: string };
}
}
function getBounds(points: HeatmapPoint[]) {
if (points.length === 0) return null;
let minLng = points[0].lng;
let maxLng = points[0].lng;
let minLat = points[0].lat;
let maxLat = points[0].lat;
for (let index = 1; index < points.length; index += 1) {
const point = points[index];
minLng = Math.min(minLng, point.lng);
maxLng = Math.max(maxLng, point.lng);
minLat = Math.min(minLat, point.lat);
maxLat = Math.max(maxLat, point.lat);
}
return { minLng, maxLng, minLat, maxLat };
}
export default function AmapHeatmapCanvas({ config, points, max, metric, focusQuery, onMapClick }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const heatmapRef = useRef<any>(null);
const amapRef = useRef<AmapInstance | null>(null);
const clickHandlerRef = useRef(onMapClick);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const intensity = useMemo(() => {
const transform = metric === 'locations'
? (value: number) => Math.log1p(value)
: (value: number) => Math.sqrt(value);
return {
points: points.map((point) => ({ ...point, count: transform(point.count) })),
max: transform(Math.max(1, max)),
};
}, [max, metric, points]);
useEffect(() => {
clickHandlerRef.current = onMapClick;
}, [onMapClick]);
useEffect(() => {
let cancelled = false;
const container = containerRef.current;
if (!container) return undefined;
async function initialize() {
try {
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
const loaderModule = await import('@amap/amap-jsapi-loader');
const AMap = await loaderModule.default.load({
key: config.key,
version: '2.0',
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
}) as unknown as AmapInstance;
if (cancelled || !container) return;
const map = new AMap.Map(container, {
viewMode: '2D',
zoom: 5,
center: [105.4, 34.4],
mapStyle: 'amap://styles/whitesmoke',
resizeEnable: true,
showLabel: true,
});
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
const heatmap = new AMap.HeatMap(map, {
radius: 25,
opacity: [0.12, 0.82],
gradient: {
0.1: '#2563eb',
0.3: '#0891b2',
0.5: '#16a34a',
0.68: '#eab308',
0.84: '#f97316',
1: '#dc2626',
},
});
map.on('click', (event: any) => {
clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat());
});
mapRef.current = map;
heatmapRef.current = heatmap;
amapRef.current = AMap;
setStatus('ready');
} catch (error) {
console.error('AMap initialization failed', error);
if (!cancelled) setStatus('error');
}
}
initialize();
return () => {
cancelled = true;
heatmapRef.current?.setMap?.(null);
mapRef.current?.destroy?.();
heatmapRef.current = null;
mapRef.current = null;
amapRef.current = null;
};
}, [config.key, config.securityCode]);
useEffect(() => {
if (status !== 'ready' || !heatmapRef.current) return;
heatmapRef.current.setDataSet({ data: intensity.points, max: intensity.max });
if (!focusQuery || points.length === 0 || !mapRef.current || !amapRef.current) return;
const bounds = getBounds(points);
if (!bounds) return;
if (points.length === 1) {
mapRef.current.setZoomAndCenter(12, [points[0].lng, points[0].lat]);
return;
}
mapRef.current.setBounds(new amapRef.current.Bounds(
[bounds.minLng, bounds.minLat],
[bounds.maxLng, bounds.maxLat],
), false, [80, 80, 80, 80]);
}, [focusQuery, intensity, points, status]);
return (
<div className="absolute inset-0 bg-[#eef3f7]">
<div ref={containerRef} className="h-full w-full" aria-label="车辆运营热力图地图" />
{status === 'loading' ? (
<div className="absolute inset-0 grid place-items-center bg-white/76 text-sm font-medium text-slate-500 backdrop-blur-[2px]">
<span className="flex items-center gap-2.5"><i className="h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /></span>
</div>
) : null}
{status === 'error' ? (
<div className="absolute inset-0 grid place-items-center bg-slate-50 text-center">
<div><p className="text-sm font-semibold text-slate-700"></p><p className="mt-1 text-xs text-slate-400"> Key</p></div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { ChevronRight, Crosshair, X } from 'lucide-react';
import type { VehicleRank } from './types';
type Props = {
title: string;
subtitle: string;
locationCount: number;
vehicleCount: number;
vehicles: VehicleRank[];
selected: boolean;
loading: boolean;
onClose: () => void;
onHide: () => void;
};
const numberFormat = new Intl.NumberFormat('zh-CN');
export default function HeatmapDetailPanel(props: Props) {
return (
<aside className="relative z-20 flex h-full w-[300px] shrink-0 flex-col border-l border-slate-200 bg-white max-lg:absolute max-lg:bottom-0 max-lg:left-0 max-lg:right-0 max-lg:h-[43vh] max-lg:w-full max-lg:rounded-t-2xl max-lg:border-l-0 max-lg:border-t max-lg:shadow-[0_-10px_30px_rgba(15,23,42,0.14)] max-md:h-[50dvh]">
<div className="border-b border-slate-100 px-5 pb-4 pt-5 max-md:px-4 max-md:pb-3 max-md:pt-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-[17px] font-semibold tracking-tight text-slate-900">{props.title}</h2>
{props.loading ? <i className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /> : null}
</div>
<p className="mt-1 text-[11px] text-slate-400">{props.subtitle}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{props.selected ? (
<button type="button" onClick={props.onClose} aria-label="关闭区域详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-50 hover:text-slate-700">
<X size={18} strokeWidth={1.8} />
</button>
) : null}
<button
type="button"
onClick={props.onHide}
aria-label="隐藏详情"
title="隐藏详情"
className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 transition hover:bg-blue-50 hover:text-blue-600 max-md:h-10 max-md:w-10"
>
<ChevronRight size={18} strokeWidth={1.8} />
</button>
</div>
</div>
<div className="mt-5 grid grid-cols-2 divide-x divide-slate-200 border-t border-slate-100 pt-4 max-md:mt-3 max-md:pt-3">
<div>
<p className="text-[11px] text-slate-400"></p>
<p className="mt-1 text-[22px] font-semibold tracking-tight text-blue-600">{numberFormat.format(props.locationCount)}</p>
</div>
<div className="pl-5">
<p className="text-[11px] text-slate-400"></p>
<p className="mt-1 text-[22px] font-semibold tracking-tight text-blue-600">{numberFormat.format(props.vehicleCount)}</p>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4 max-md:px-4 max-md:py-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold text-slate-800"> TOP10</h3>
<span className="text-[10px] text-slate-400"></span>
</div>
{props.vehicles.length ? (
<ol className="divide-y divide-slate-100">
{props.vehicles.map((vehicle, index) => (
<li key={vehicle.vin} className="grid grid-cols-[24px_minmax(0,1fr)_42px] items-center gap-2 py-2.5">
<span className={`text-[11px] font-medium ${index < 3 ? 'text-blue-600' : 'text-slate-400'}`}>{index + 1}</span>
<span className="min-w-0">
<span className="block truncate text-[12px] font-medium text-slate-700">{vehicle.plateNumber || '未绑定车牌'}</span>
<span className="mt-0.5 block truncate font-mono text-[9px] text-slate-400">{vehicle.vin}</span>
</span>
<span className="text-right text-[12px] tabular-nums text-slate-600">{vehicle.locationCount}</span>
</li>
))}
</ol>
) : (
<div className="grid h-44 place-items-center text-center text-xs text-slate-400">
<span><Crosshair className="mx-auto mb-2 text-slate-300" size={22} /></span>
</div>
)}
</div>
</aside>
);
}

View File

@@ -0,0 +1,115 @@
import { CalendarDays, ChevronUp, RotateCcw, Search } from 'lucide-react';
import type { HeatmapMetric, VehicleOption } from './types';
type Props = {
startDate: string;
endDate: string;
minDate: string;
maxDate: string;
query: string;
batchModel: string;
batchModels: string[];
metric: HeatmapMetric;
vehicles: VehicleOption[];
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
onQueryChange: (value: string) => void;
onBatchModelChange: (value: string) => void;
onMetricChange: (value: HeatmapMetric) => void;
onReset: () => void;
onHide: () => void;
};
const FIELD = 'relative h-11 min-w-0 rounded-lg border border-slate-200 bg-white transition focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100';
const LABEL = 'pointer-events-none absolute left-3 top-1 z-10 text-[9px] font-medium leading-none text-slate-400';
const CONTROL = 'h-full w-full min-w-0 rounded-lg border-0 bg-transparent px-3 pt-3 text-[12px] text-slate-700 outline-none';
export default function HeatmapFilters(props: Props) {
return (
<div className="pointer-events-auto grid max-h-[calc(100dvh-180px)] w-full grid-cols-2 gap-2 overflow-y-auto rounded-xl border border-slate-200/80 bg-white/96 p-2 shadow-[0_8px_28px_rgba(15,23,42,0.12)] backdrop-blur-md md:flex md:w-auto md:min-w-max md:items-center md:overflow-visible">
<label className={`${FIELD} col-span-2 md:w-[320px]`}>
<span className={`${LABEL} left-9`}></span>
<span className="flex h-full min-w-0 items-center pl-8 pt-2">
<CalendarDays className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={15} strokeWidth={1.8} />
<input
aria-label="开始日期"
type="date"
min={props.minDate}
max={props.endDate}
value={props.startDate}
onChange={(event) => props.onStartDateChange(event.target.value)}
className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none"
/>
<span className="shrink-0 px-0.5 text-slate-300"></span>
<input
aria-label="结束日期"
type="date"
min={props.startDate}
max={props.maxDate}
value={props.endDate}
onChange={(event) => props.onEndDateChange(event.target.value)}
className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none"
/>
</span>
</label>
<label className={`${FIELD} col-span-2 md:w-[145px]`}>
<span className={LABEL}>VIN / </span>
<span className="relative block h-full">
<input
aria-label="搜索 VIN 或车牌"
value={props.query}
list="vehicle-heatmap-options"
onChange={(event) => props.onQueryChange(event.target.value)}
placeholder="输入 VIN 或车牌"
className={`${CONTROL} pr-9`}
/>
<Search className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} strokeWidth={1.8} />
<datalist id="vehicle-heatmap-options">
{props.vehicles.map((vehicle) => (
<option key={vehicle.vin} value={vehicle.plateNumber || vehicle.vin}>{vehicle.vin}</option>
))}
</datalist>
</span>
</label>
<label className={`${FIELD} col-span-2 md:w-[150px]`}>
<span className={LABEL}> / </span>
<select
aria-label="车型 / 批次型号"
value={props.batchModel}
onChange={(event) => props.onBatchModelChange(event.target.value)}
className={CONTROL}
title={props.batchModel || '全部车型'}
>
<option value=""></option>
{props.batchModels.map((batchModel) => (
<option key={batchModel} value={batchModel}>{batchModel}</option>
))}
</select>
</label>
<label className={`${FIELD} md:w-[110px]`}>
<span className={LABEL}></span>
<select
aria-label="统计维度"
value={props.metric}
onChange={(event) => props.onMetricChange(event.target.value as HeatmapMetric)}
className={CONTROL}
>
<option value="locations"></option>
<option value="vehicles"></option>
</select>
</label>
<div className="flex min-w-0 gap-2 md:w-auto">
<button type="button" onClick={props.onReset} className="flex h-11 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-[12px] font-medium text-slate-600 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:flex-none">
<RotateCcw size={14} strokeWidth={1.8} />
</button>
<button type="button" onClick={props.onHide} aria-label="隐藏筛选" title="隐藏筛选" className="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-slate-200 bg-white text-slate-400 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:w-10">
<ChevronUp size={16} strokeWidth={1.8} />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, Clock3, MapPinned, Maximize2, Minimize2, SlidersHorizontal } from 'lucide-react';
import AmapHeatmapCanvas from './AmapHeatmapCanvas';
import HeatmapDetailPanel from './HeatmapDetailPanel';
import HeatmapFilters from './HeatmapFilters';
import { fetchAmapConfig, fetchHeatmapMeta, fetchHeatmapPoints, fetchNearbyVehicles } from './api';
import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types';
const DEFAULT_START = '2026-01-01';
const DEFAULT_END = '2026-07-13';
const numberFormat = new Intl.NumberFormat('zh-CN');
function Metric({ value, label }: { value: number; label: string }) {
return (
<div className="min-w-[82px] text-center max-md:min-w-[64px]">
<p className="text-[22px] font-semibold leading-none tracking-tight text-blue-600 tabular-nums max-md:text-[16px]">{numberFormat.format(value)}</p>
<p className="mt-1.5 text-[11px] text-slate-500 max-md:mt-1 max-md:text-[9px]">{label}</p>
</div>
);
}
export default function VehicleHeatmapModule() {
const [meta, setMeta] = useState<HeatmapMeta | null>(null);
const [config, setConfig] = useState<AmapConfig | null>(null);
const [data, setData] = useState<HeatmapResponse | null>(null);
const [nearby, setNearby] = useState<NearbyResponse | null>(null);
const [startDate, setStartDate] = useState(DEFAULT_START);
const [endDate, setEndDate] = useState(DEFAULT_END);
const [query, setQuery] = useState('');
const [batchModel, setBatchModel] = useState('');
const [metric, setMetric] = useState<HeatmapMetric>('locations');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showFilters, setShowFilters] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [showDetails, setShowDetails] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [loading, setLoading] = useState(true);
const [nearbyLoading, setNearbyLoading] = useState(false);
const [error, setError] = useState('');
const deferredQuery = useDeferredValue(query.trim());
useEffect(() => {
if (!isFullscreen) return undefined;
const previousOverflow = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsFullscreen(false);
};
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [isFullscreen]);
const toggleFullscreen = useCallback(() => {
setIsFullscreen((current) => !current);
}, []);
useEffect(() => {
const controller = new AbortController();
Promise.all([fetchHeatmapMeta(controller.signal), fetchAmapConfig(controller.signal)])
.then(([nextMeta, nextConfig]) => {
setMeta(nextMeta);
setConfig(nextConfig);
setStartDate(nextMeta.startDate);
setEndDate(nextMeta.endDate);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('热力图基础配置加载失败');
});
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setError('');
fetchHeatmapPoints({ startDate, endDate, query: deferredQuery, batchModel, metric }, controller.signal)
.then((response) => {
setData(response);
setNearby(null);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('热力图数据加载失败,请稍后重试');
})
.finally(() => setLoading(false));
}, 220);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [batchModel, deferredQuery, endDate, metric, startDate]);
const handleMapClick = useCallback((longitude: number, latitude: number) => {
const controller = new AbortController();
setNearbyLoading(true);
fetchNearbyVehicles({ lng: longitude, lat: latitude, startDate, endDate, query: deferredQuery, batchModel }, controller.signal)
.then(setNearby)
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('区域车辆明细加载失败');
})
.finally(() => setNearbyLoading(false));
}, [batchModel, deferredQuery, endDate, startDate]);
const reset = () => {
setStartDate(meta?.startDate || DEFAULT_START);
setEndDate(meta?.endDate || DEFAULT_END);
setQuery('');
setBatchModel('');
setMetric('locations');
setNearby(null);
};
const detail = useMemo(() => {
if (nearby) {
return {
title: '选中区域',
subtitle: `${nearby.center.lng.toFixed(3)}, ${nearby.center.lat.toFixed(3)} · 半径 ${nearby.radiusKm} 公里`,
locationCount: nearby.locationCount,
vehicleCount: nearby.vehicleCount,
vehicles: nearby.topVehicles,
selected: true,
};
}
return {
title: deferredQuery ? '车辆定位概览' : '全国 · 当前范围',
subtitle: `${batchModel ? `${batchModel} · ` : ''}${startDate} ${endDate}`,
locationCount: data?.locationCount || 0,
vehicleCount: data?.vehicleCount || 0,
vehicles: data?.topVehicles || [],
selected: false,
};
}, [batchModel, data, deferredQuery, endDate, nearby, startDate]);
return (
<section className={`flex flex-col overflow-hidden bg-white ${isFullscreen ? 'fixed inset-0 z-[100] h-screen w-screen min-h-0' : 'h-[100dvh] min-h-[620px] max-md:h-[calc(100dvh-4rem)] max-md:min-h-[520px]'}`}>
<header className="relative z-30 flex min-h-[98px] shrink-0 items-center justify-between border-b border-slate-200 bg-white px-8 max-md:min-h-[104px] max-md:flex-wrap max-md:gap-y-2 max-md:px-3 max-md:py-3">
<div className="min-w-0">
<h1 className="truncate text-[25px] font-semibold tracking-[-0.025em] text-slate-950 max-md:text-[18px]"></h1>
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-400 max-md:mt-1 max-md:text-[9px]"><MapPinned size={13} /> · </p>
</div>
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-7 max-md:order-3 max-md:static max-md:w-full max-md:translate-x-0 max-md:translate-y-0 max-md:justify-center max-md:gap-2">
<Metric value={data?.locationCount ?? meta?.locationCount ?? 0} label="定位点" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={data?.vehicleCount ?? meta?.vehicleCount ?? 0} label="辆车" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={data?.dayCount ?? meta?.dayCount ?? 0} label="天" />
</div>
<div className="flex items-center gap-3">
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} /> {meta?.endDate || DEFAULT_END}</p>
<button
type="button"
onClick={toggleFullscreen}
className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
title={isFullscreen ? '退出全屏' : '地图全屏'}
aria-label={isFullscreen ? '退出地图全屏' : '地图全屏'}
aria-pressed={isFullscreen}
>
{isFullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
</div>
</header>
<div className="relative flex min-h-0 flex-1">
<div className="relative z-0 min-w-0 flex-1 overflow-hidden">
{config ? (
<AmapHeatmapCanvas
config={config}
points={data?.points || []}
max={data?.max || 1}
metric={metric}
focusQuery={deferredQuery}
onMapClick={handleMapClick}
/>
) : <div className="absolute inset-0 bg-slate-100" />}
{showFilters ? (
<div className="pointer-events-none absolute left-3 right-3 top-3 z-10 md:left-5 md:right-auto md:top-5">
<HeatmapFilters
startDate={startDate}
endDate={endDate}
minDate={meta?.startDate || DEFAULT_START}
maxDate={meta?.endDate || DEFAULT_END}
query={query}
batchModel={batchModel}
batchModels={meta?.batchModels || []}
metric={metric}
vehicles={meta?.vehicles || []}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onQueryChange={setQuery}
onBatchModelChange={setBatchModel}
onMetricChange={setMetric}
onReset={reset}
onHide={() => setShowFilters(false)}
/>
</div>
) : (
<button
type="button"
onClick={() => setShowFilters(true)}
aria-label="显示筛选"
className="absolute left-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:left-5 md:top-5 md:h-10"
>
<SlidersHorizontal size={15} strokeWidth={1.8} />
</button>
)}
{!showDetails ? (
<button
type="button"
onClick={() => setShowDetails(true)}
aria-label="显示详情"
className={`absolute right-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:right-5 md:top-5 md:h-10 ${showFilters ? 'max-md:hidden' : ''}`}
>
<ChevronLeft size={16} strokeWidth={1.8} />
</button>
) : null}
<div className="pointer-events-none absolute bottom-5 left-5 z-10 w-[286px] rounded-xl border border-slate-200/80 bg-white/95 p-3.5 shadow-[0_8px_30px_rgba(15,23,42,0.11)] backdrop-blur-sm max-lg:bottom-[calc(43vh+16px)] max-md:hidden">
<div className="flex items-center justify-between text-[11px] font-medium text-slate-700"><span>{metric === 'locations' ? '定位活跃度' : '车辆覆盖度'}</span>{loading ? <i className="h-3 w-3 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /> : null}</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
{metric === 'locations'
? '同一网格累计每日首个定位,同车跨天重复计数;对数平滑强度。'
: '同一网格按 VIN 去重,同车仅计 1 辆;平方根平滑强度。'}
</p>
<div className="mt-2.5 h-2.5 rounded-full bg-[linear-gradient(90deg,#2563eb_0%,#0891b2_25%,#16a34a_45%,#eab308_65%,#f97316_82%,#dc2626_100%)]" />
<div className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span></span><span></span></div>
</div>
{error ? (
<div className="absolute bottom-5 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-xs text-white shadow-xl">
<AlertTriangle size={15} className="text-amber-300" />{error}
</div>
) : null}
</div>
{showDetails ? (
<HeatmapDetailPanel
{...detail}
loading={nearbyLoading}
onClose={() => setNearby(null)}
onHide={() => setShowDetails(false)}
/>
) : null}
</div>
</section>
);
}

View File

@@ -0,0 +1,34 @@
import { fetchJson } from '../../auth/api-client';
import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types';
export function fetchAmapConfig(signal?: AbortSignal) {
return fetchJson<AmapConfig>('/api/vehicle-heatmap/config', { signal });
}
export function fetchHeatmapMeta(signal?: AbortSignal) {
return fetchJson<HeatmapMeta>('/api/vehicle-heatmap/meta', { signal });
}
export function fetchHeatmapPoints(
params: { startDate: string; endDate: string; query: string; batchModel: string; metric: HeatmapMetric },
signal?: AbortSignal,
) {
const search = new URLSearchParams(params);
return fetchJson<HeatmapResponse>(`/api/vehicle-heatmap/points?${search}`, { signal });
}
export function fetchNearbyVehicles(
params: { lng: number; lat: number; startDate: string; endDate: string; query: string; batchModel: string },
signal?: AbortSignal,
) {
const search = new URLSearchParams({
lng: String(params.lng),
lat: String(params.lat),
startDate: params.startDate,
endDate: params.endDate,
query: params.query,
batchModel: params.batchModel,
radiusKm: '50',
});
return fetchJson<NearbyResponse>(`/api/vehicle-heatmap/nearby?${search}`, { signal });
}

View File

@@ -0,0 +1,59 @@
export type HeatmapMetric = 'locations' | 'vehicles';
export type HeatmapPoint = {
lng: number;
lat: number;
count: number;
};
export type VehicleRank = {
vin: string;
plateNumber: string;
locationCount: number;
firstSeen: string;
lastSeen: string;
};
export type VehicleOption = {
vin: string;
plateNumber: string;
};
export type HeatmapMeta = {
startDate: string;
endDate: string;
locationCount: number;
vehicleCount: number;
dayCount: number;
totalLocationCount: number;
excludedLocationCount: number;
outsideMainlandCount: number;
tibetCount: number;
batchModels: string[];
vehicles: VehicleOption[];
};
export type HeatmapResponse = {
startDate: string;
endDate: string;
metric: HeatmapMetric;
locationCount: number;
vehicleCount: number;
dayCount: number;
points: HeatmapPoint[];
max: number;
topVehicles: VehicleRank[];
};
export type NearbyResponse = {
center: { lng: number; lat: number };
radiusKm: number;
locationCount: number;
vehicleCount: number;
topVehicles: VehicleRank[];
};
export type AmapConfig = {
key: string;
securityCode: string;
};

29
src/server/heatmap-db.ts Normal file
View File

@@ -0,0 +1,29 @@
import dotenv from 'dotenv';
import pg from 'pg';
dotenv.config();
const { Pool } = pg;
const heatmapPool = new Pool({
host: process.env.HEATMAP_DB_HOST,
port: Number(process.env.HEATMAP_DB_PORT) || 5432,
user: process.env.HEATMAP_DB_USER,
password: process.env.HEATMAP_DB_PASSWORD,
database: process.env.HEATMAP_DB_NAME,
ssl: process.env.HEATMAP_DB_SSL === 'true'
? { rejectUnauthorized: false }
: false,
max: 5,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 10_000,
statement_timeout: 30_000,
application_name: 'ln-bi-vehicle-heatmap',
options: '-c timezone=Asia/Shanghai -c default_transaction_read_only=on',
});
heatmapPool.on('error', (error) => {
console.error('[vehicle-heatmap] idle PostgreSQL client error:', error);
});
export default heatmapPool;

View File

@@ -9,6 +9,8 @@ import schedulingRouter from './routes/scheduling/index.js';
import energyRouter from './routes/energy/index.js';
import eleRouter from './routes/ele/index.js';
import feedbackRouter from './routes/feedback/index.js';
import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
import hydrogenHeatmapRouter from './routes/hydrogen-heatmap.js';
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
import authRouter from './auth/login.js';
import { authMiddleware } from './auth/middleware.js';
@@ -31,6 +33,8 @@ app.route('/api/scheduling', schedulingRouter);
app.route('/api/energy', energyRouter);
app.route('/api/ele', eleRouter);
app.route('/api/feedback', feedbackRouter);
app.route('/api/vehicle-heatmap', vehicleHeatmapRouter);
app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter);
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));

View File

@@ -0,0 +1,290 @@
import { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import pool from '../db.js';
import type { AuthUser } from '../auth/types.js';
import { canAccessEnergy } from '../auth/types.js';
type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
type Payer = 'all' | 'lingniu' | 'customer';
type StationRow = RowDataPacket & {
station_id: string;
station_name: string;
address: string;
longitude: string | number;
latitude: string | number;
kg: string | number;
refuel_count: string | number;
vehicle_count: string | number;
first_refuel: string;
last_refuel: string;
};
const DEFAULT_START = '2026-01-01';
const DEFAULT_END = '2026-07-13';
const VALID_COORDINATE = `
o.longitude BETWEEN 73.5 AND 135.1
AND o.latitude BETWEEN 18 AND 53.6
AND COALESCE(o.province, '') NOT LIKE '%西藏%'
`;
function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function parseMetric(value: string | undefined): HydrogenHeatmapMetric {
if (value === 'refuels' || value === 'vehicles') return value;
return 'kg';
}
function parsePayer(value: string | undefined): Payer {
if (value === 'lingniu' || value === 'customer') return value;
return 'all';
}
function buildWhere(startDate: string, endDate: string, query: string, payer: Payer) {
const clauses = [
`b.del_flag = '0'`,
`DATE(b.refuel_time) BETWEEN ? AND ?`,
VALID_COORDINATE,
];
const params: Array<string | number> = [startDate, endDate];
if (query) {
clauses.push(`CONVERT(CONCAT_WS(' ', s.station_name, s.station_short_name, b.station_name, o.station_name, o.fixed_station_name, o.station_address, o.city) USING utf8mb4) COLLATE utf8mb4_unicode_ci LIKE CONVERT(? USING utf8mb4) COLLATE utf8mb4_unicode_ci`);
params.push(`%${query}%`);
}
if (payer === 'lingniu') {
clauses.push(`COALESCE(b.customer_price, 0) <= 0 AND COALESCE(b.fee_total, 0) <= 0`);
} else if (payer === 'customer') {
clauses.push(`(COALESCE(b.customer_price, 0) > 0 OR COALESCE(b.fee_total, 0) > 0)`);
}
return { sql: clauses.join(' AND '), params };
}
async function loadStations(startDate: string, endDate: string, query: string, payer: Payer): Promise<StationRow[]> {
const where = buildWhere(startDate, endDate, query, payer);
const [rows] = await pool.execute<StationRow[]>(`
SELECT
CAST(b.station_id AS CHAR) AS station_id,
COALESCE(
NULLIF(MAX(s.station_short_name), ''),
NULLIF(MAX(s.station_name), ''),
NULLIF(MAX(b.station_name), ''),
NULLIF(MAX(o.fixed_station_name), ''),
CONCAT('未知站点 #', b.station_id)
) AS station_name,
COALESCE(NULLIF(MAX(o.station_address), ''), NULLIF(MAX(s.station_address), ''), '') AS address,
MAX(o.longitude) AS longitude,
MAX(o.latitude) AS latitude,
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS first_refuel,
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS last_refuel
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql}
GROUP BY b.station_id
`, where.params);
return rows;
}
function metricValue(row: StationRow, metric: HydrogenHeatmapMetric): number {
if (metric === 'refuels') return Number(row.refuel_count) || 0;
if (metric === 'vehicles') return Number(row.vehicle_count) || 0;
return Number(row.kg) || 0;
}
function serializeStation(row: StationRow, metric: HydrogenHeatmapMetric) {
return {
stationId: row.station_id,
stationName: row.station_name,
address: row.address,
lng: Number(row.longitude),
lat: Number(row.latitude),
kg: Number(row.kg) || 0,
refuelCount: Number(row.refuel_count) || 0,
vehicleCount: Number(row.vehicle_count) || 0,
firstRefuel: row.first_refuel,
lastRefuel: row.last_refuel,
metricValue: metricValue(row, metric),
};
}
function haversineKm(leftLng: number, leftLat: number, rightLng: number, rightLat: number): number {
const radians = (degrees: number) => degrees * Math.PI / 180;
const earthRadius = 6371;
const dLat = radians(rightLat - leftLat);
const dLng = radians(rightLng - leftLng);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2;
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
const router = new Hono();
router.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (user && !canAccessEnergy(user.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
});
router.get('/config', (c) => {
const key = process.env.AMAP_WEB_KEY;
const securityCode = process.env.AMAP_SECURITY_JS_CODE;
if (!key || !securityCode) return c.json({ error: '高德地图配置缺失' }, 503);
return c.json({ key, securityCode });
});
router.get('/meta', async (c) => {
const [profileRows, optionRows] = await Promise.all([
pool.query<RowDataPacket[]>(`
SELECT
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d') AS start_date,
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d') AS end_date,
COUNT(*) AS total_refuel_count,
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS total_kg,
COUNT(DISTINCT b.station_id) AS total_station_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
SUM(CASE WHEN ${VALID_COORDINATE} THEN 1 ELSE 0 END) AS eligible_refuel_count,
ROUND(SUM(CASE WHEN ${VALID_COORDINATE} THEN COALESCE(b.amount_kg, 0) ELSE 0 END), 2) AS eligible_kg,
COUNT(DISTINCT CASE WHEN ${VALID_COORDINATE} THEN b.station_id END) AS eligible_station_count
FROM hydrogen_fuel_ledger b
LEFT JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE b.del_flag = '0'
`),
pool.query<RowDataPacket[]>(`
SELECT
CAST(s.id AS CHAR) AS station_id,
COALESCE(NULLIF(s.station_short_name, ''), s.station_name) AS station_name
FROM hydrogen_station s
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = s.id
INNER JOIN hydrogen_fuel_ledger b ON b.station_id = s.id AND b.del_flag = '0'
WHERE s.del_flag = '0' AND ${VALID_COORDINATE}
GROUP BY s.id, s.station_short_name, s.station_name
ORDER BY station_name
`),
]);
const profile = profileRows[0][0] || {};
const totalRefuelCount = Number(profile.total_refuel_count) || 0;
const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0;
return c.json({
startDate: profile.start_date || DEFAULT_START,
endDate: profile.end_date || DEFAULT_END,
totalRefuelCount,
eligibleRefuelCount,
excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount),
gpsCoverageRate: totalRefuelCount ? eligibleRefuelCount / totalRefuelCount : 0,
totalKg: Number(profile.total_kg) || 0,
eligibleKg: Number(profile.eligible_kg) || 0,
vehicleCount: Number(profile.vehicle_count) || 0,
totalStationCount: Number(profile.total_station_count) || 0,
eligibleStationCount: Number(profile.eligible_station_count) || 0,
stations: optionRows[0].map((row) => ({
stationId: String(row.station_id),
stationName: String(row.station_name),
})),
});
});
router.get('/points', async (c) => {
const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : DEFAULT_START;
const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : DEFAULT_END;
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const query = (c.req.query('query') || '').trim();
const payer = parsePayer(c.req.query('payer'));
const metric = parseMetric(c.req.query('metric'));
const stations = await loadStations(startDate, endDate, query, payer);
const ranked = [...stations].sort((left, right) => metricValue(right, metric) - metricValue(left, metric));
const points = ranked.map((row) => ({
lng: Number(row.longitude),
lat: Number(row.latitude),
count: metricValue(row, metric),
}));
const where = buildWhere(startDate, endDate, query, payer);
const [summaryRows] = await pool.execute<RowDataPacket[]>(`
SELECT
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
COUNT(DISTINCT b.station_id) AS station_count,
COUNT(DISTINCT DATE(b.refuel_time)) AS day_count
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql}
`, where.params);
const summary = summaryRows[0] || {};
return c.json({
startDate,
endDate,
metric,
payer,
kg: Number(summary.kg) || 0,
refuelCount: Number(summary.refuel_count) || 0,
vehicleCount: Number(summary.vehicle_count) || 0,
stationCount: Number(summary.station_count) || 0,
dayCount: Number(summary.day_count) || 0,
points,
max: Math.max(1, ...points.map((point) => point.count)),
topStations: ranked.slice(0, 10).map((row) => serializeStation(row, metric)),
});
});
router.get('/nearby', async (c) => {
const longitude = Number(c.req.query('lng'));
const latitude = Number(c.req.query('lat'));
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return c.json({ error: '经纬度参数无效' }, 400);
const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : DEFAULT_START;
const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : DEFAULT_END;
const query = (c.req.query('query') || '').trim();
const payer = parsePayer(c.req.query('payer'));
const metric = parseMetric(c.req.query('metric'));
const radiusKm = Math.min(200, Math.max(5, Number(c.req.query('radiusKm')) || 50));
const stations = await loadStations(startDate, endDate, query, payer);
const nearby = stations.filter((row) => haversineKm(
longitude,
latitude,
Number(row.longitude),
Number(row.latitude),
) <= radiusKm);
const nearbyIds = nearby.map((row) => row.station_id);
let nearbySummary = { kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0 };
if (nearbyIds.length) {
const placeholders = nearbyIds.map(() => '?').join(',');
const where = buildWhere(startDate, endDate, query, payer);
const [rows] = await pool.execute<RowDataPacket[]>(`
SELECT
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
COUNT(DISTINCT b.station_id) AS station_count
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql} AND CAST(b.station_id AS CHAR) IN (${placeholders})
`, [...where.params, ...nearbyIds]);
nearbySummary = {
kg: Number(rows[0]?.kg) || 0,
refuelCount: Number(rows[0]?.refuel_count) || 0,
vehicleCount: Number(rows[0]?.vehicle_count) || 0,
stationCount: Number(rows[0]?.station_count) || 0,
};
}
const ranked = [...nearby].sort((left, right) => metricValue(right, metric) - metricValue(left, metric));
return c.json({
center: { lng: longitude, lat: latitude },
radiusKm,
kg: nearbySummary.kg,
refuelCount: nearbySummary.refuelCount,
vehicleCount: nearbySummary.vehicleCount,
stationCount: nearbySummary.stationCount,
topStations: ranked.slice(0, 10).map((row) => serializeStation(row, metric)),
});
});
export default router;

View File

@@ -0,0 +1,299 @@
import { Hono } from 'hono';
import pool from '../db.js';
import heatmapPool from '../heatmap-db.js';
import { getCache } from './mileage/cache.js';
type HeatmapRecord = {
date: string;
vin: string;
plate: string;
time: string;
longitude: number;
latitude: number;
source: string;
sourceRecordId: string;
};
type VehicleRank = {
vin: string;
plateNumber: string;
locationCount: number;
firstSeen: string;
lastSeen: string;
};
type MetaRow = {
start_date: string;
end_date: string;
location_count: string;
vehicle_count: string;
day_count: string;
total_location_count: string;
excluded_location_count: string;
outside_mainland_count: string;
tibet_count: string;
};
type VehicleOptionRow = {
vin: string;
plate_number: string;
};
type RecordRow = {
date: string;
vin: string;
plate: string;
time: string;
longitude: number;
latitude: number;
source: string;
source_record_id: string;
};
type BatchModelRow = {
target_name: string;
plate_number: string;
};
function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function daysInclusive(startDate: string, endDate: string): number {
const start = Date.parse(`${startDate}T00:00:00+08:00`);
const end = Date.parse(`${endDate}T00:00:00+08:00`);
return Math.max(0, Math.floor((end - start) / 86_400_000) + 1);
}
async function loadBatchModelPlates(): Promise<Map<string, Set<string>>> {
const cached = getCache()?.targetPlatesMap;
if (cached?.size) return cached;
try {
const [rows] = await pool.execute(`
select t.target_name, v.plate_number
from lingniu_prod.tab_mileage_assessment_target t
join lingniu_prod.tab_mileage_assessment_vehicle v
on v.target_id = t.id and v.is_deleted = 0
where t.is_deleted = 0
`) as [BatchModelRow[], unknown];
const result = new Map<string, Set<string>>();
for (const row of rows) {
const plates = result.get(row.target_name) || new Set<string>();
plates.add(row.plate_number);
result.set(row.target_name, plates);
}
return result;
} catch (error) {
console.error('[vehicle-heatmap] batch model lookup failed', error);
return new Map();
}
}
async function loadRecords(
startDate: string,
endDate: string,
query: string,
batchModel: string,
): Promise<HeatmapRecord[]> {
let batchPlates: string[] = [];
if (batchModel) {
const modelPlates = await loadBatchModelPlates();
batchPlates = [...(modelPlates.get(batchModel) || [])];
if (batchPlates.length === 0) return [];
}
const result = await heatmapPool.query<RecordRow>(`
select
to_char(stat_date, 'YYYY-MM-DD') as date,
vin,
plate_number as plate,
to_char(first_event_time at time zone 'Asia/Shanghai',
'YYYY-MM-DD HH24:MI:SS') as time,
longitude,
latitude,
source_name as source,
coalesce(source_record_id::text, '') as source_record_id
from analytics.vehicle_daily_first_location
where stat_date between $1::date and $2::date
and is_heatmap_eligible
and ($4::boolean = false or plate_number = any($5::text[]))
and (
$3 = ''
or upper(vin) like '%' || upper($3) || '%'
or upper(plate_number) like '%' || upper($3) || '%'
)
order by stat_date, vin
`, [startDate, endDate, query.trim(), Boolean(batchModel), batchPlates]);
return result.rows.map((row) => ({
...row,
longitude: Number(row.longitude),
latitude: Number(row.latitude),
sourceRecordId: row.source_record_id,
}));
}
function buildRanking(records: HeatmapRecord[], limit = 10): VehicleRank[] {
const vehicles = new Map<string, VehicleRank>();
for (const record of records) {
const current = vehicles.get(record.vin);
if (!current) {
vehicles.set(record.vin, {
vin: record.vin,
plateNumber: record.plate,
locationCount: 1,
firstSeen: record.time,
lastSeen: record.time,
});
continue;
}
current.locationCount += 1;
if (record.time < current.firstSeen) current.firstSeen = record.time;
if (record.time > current.lastSeen) current.lastSeen = record.time;
if (!current.plateNumber && record.plate) current.plateNumber = record.plate;
}
return [...vehicles.values()]
.sort((left, right) => right.locationCount - left.locationCount
|| left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'))
.slice(0, limit);
}
function haversineKm(leftLng: number, leftLat: number, rightLng: number, rightLat: number): number {
const radians = (degrees: number) => degrees * Math.PI / 180;
const earthRadius = 6371;
const dLat = radians(rightLat - leftLat);
const dLng = radians(rightLng - leftLng);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2;
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
const router = new Hono();
router.get('/config', (c) => {
const key = process.env.AMAP_WEB_KEY;
const securityCode = process.env.AMAP_SECURITY_JS_CODE;
if (!key || !securityCode) {
return c.json({ error: '高德地图配置缺失' }, 503);
}
return c.json({ key, securityCode });
});
router.get('/meta', async (c) => {
const [metaResult, vehiclesResult, batchModelPlates] = await Promise.all([
heatmapPool.query<MetaRow>(`
select
to_char(min(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as start_date,
to_char(max(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as end_date,
count(*) filter (where is_heatmap_eligible)::text as location_count,
count(distinct vin) filter (where is_heatmap_eligible)::text as vehicle_count,
count(distinct stat_date) filter (where is_heatmap_eligible)::text as day_count,
count(*)::text as total_location_count,
count(*) filter (where not is_heatmap_eligible)::text as excluded_location_count,
count(*) filter (where exclusion_reason = 'outside_mainland_china')::text as outside_mainland_count,
count(*) filter (where exclusion_reason = 'tibet')::text as tibet_count
from analytics.vehicle_daily_first_location
`),
heatmapPool.query<VehicleOptionRow>(`
select distinct on (vin) vin, plate_number
from analytics.vehicle_daily_first_location
where is_heatmap_eligible
order by vin, stat_date desc
`),
loadBatchModelPlates(),
]);
const meta = metaResult.rows[0];
const vehicles = vehiclesResult.rows
.map(({ vin, plate_number: plateNumber }) => ({ vin, plateNumber }))
.sort((left, right) => left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'));
return c.json({
startDate: meta.start_date,
endDate: meta.end_date,
locationCount: Number(meta.location_count),
vehicleCount: Number(meta.vehicle_count),
dayCount: Number(meta.day_count),
totalLocationCount: Number(meta.total_location_count),
excludedLocationCount: Number(meta.excluded_location_count),
outsideMainlandCount: Number(meta.outside_mainland_count),
tibetCount: Number(meta.tibet_count),
batchModels: [...batchModelPlates.keys()].sort((left, right) => left.localeCompare(right, 'zh-CN')),
vehicles,
});
});
router.get('/points', async (c) => {
const requestedStart = c.req.query('startDate');
const requestedEnd = c.req.query('endDate');
const startDate = isDate(requestedStart) ? requestedStart : '2026-01-01';
const endDate = isDate(requestedEnd) ? requestedEnd : '2026-07-13';
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const query = c.req.query('query') || '';
const batchModel = (c.req.query('batchModel') || '').trim();
const metric = c.req.query('metric') === 'vehicles' ? 'vehicles' : 'locations';
const records = await loadRecords(startDate, endDate, query, batchModel);
const precision = daysInclusive(startDate, endDate) <= 14 || Boolean(query || batchModel) ? 3 : 2;
const grid = new Map<string, { lng: number; lat: number; count: number; vins?: Set<string> }>();
for (const record of records) {
const lng = Number(record.longitude.toFixed(precision));
const lat = Number(record.latitude.toFixed(precision));
const key = `${lng},${lat}`;
let point = grid.get(key);
if (!point) {
point = { lng, lat, count: 0, vins: metric === 'vehicles' ? new Set<string>() : undefined };
grid.set(key, point);
}
if (point.vins) {
point.vins.add(record.vin);
point.count = point.vins.size;
} else {
point.count += 1;
}
}
const points = [...grid.values()].map(({ lng, lat, count }) => ({ lng, lat, count }));
let max = 1;
for (const point of points) max = Math.max(max, point.count);
return c.json({
startDate,
endDate,
metric,
locationCount: records.length,
vehicleCount: new Set(records.map((record) => record.vin)).size,
dayCount: new Set(records.map((record) => record.date)).size,
points,
max,
topVehicles: buildRanking(records),
});
});
router.get('/nearby', async (c) => {
const longitude = Number(c.req.query('lng'));
const latitude = Number(c.req.query('lat'));
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
return c.json({ error: '经纬度参数无效' }, 400);
}
const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : '2026-01-01';
const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : '2026-07-13';
const query = c.req.query('query') || '';
const batchModel = (c.req.query('batchModel') || '').trim();
const radiusKm = Math.min(200, Math.max(5, Number(c.req.query('radiusKm')) || 50));
const records = await loadRecords(startDate, endDate, query, batchModel);
const nearby = records.filter((record) => (
haversineKm(longitude, latitude, record.longitude, record.latitude) <= radiusKm
));
return c.json({
center: { lng: longitude, lat: latitude },
radiusKm,
locationCount: nearby.length,
vehicleCount: new Set(nearby.map((record) => record.vin)).size,
topVehicles: buildRanking(nearby),
});
});
export default router;