Compare commits
19 Commits
dev-ljyang
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c13f341d5e | ||
|
|
6962c4ff1c | ||
|
|
6b7f0eedd9 | ||
|
|
5bb3ceb47a | ||
|
|
cc778f3701 | ||
|
|
74d6efe261 | ||
|
|
a124e31fab | ||
|
|
a3dfe7ab8c | ||
|
|
3f0edfaaf5 | ||
|
|
feb950dd59 | ||
|
|
5e1c12eba2 | ||
|
|
ae24bc7647 | ||
|
|
0a372e4290 | ||
|
|
1e08d1ea62 | ||
|
|
2d82918d73 | ||
|
|
482243e052 | ||
|
|
f1a69c8271 | ||
|
|
1d2c3a0cd5 | ||
|
|
e7ba5315e1 |
@@ -10,6 +10,16 @@ services:
|
|||||||
DB_USER: "root"
|
DB_USER: "root"
|
||||||
DB_PASSWORD: "LN#Passw0rd@2026"
|
DB_PASSWORD: "LN#Passw0rd@2026"
|
||||||
DB_NAME: "lingniu_prod"
|
DB_NAME: "lingniu_prod"
|
||||||
|
HYDROGEN_DB_HOST: "47.99.185.173"
|
||||||
|
HYDROGEN_DB_PORT: "3306"
|
||||||
|
HYDROGEN_DB_USER: "root"
|
||||||
|
HYDROGEN_DB_PASSWORD: "lnMysql."
|
||||||
|
HYDROGEN_DB_NAME: "ln_asset_management"
|
||||||
|
MILEAGE_DB_HOST: "101.133.130.65"
|
||||||
|
MILEAGE_DB_PORT: "3306"
|
||||||
|
MILEAGE_DB_USER: "bi_reader_02"
|
||||||
|
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
|
||||||
|
MILEAGE_DB_NAME: "hydrogen_energy"
|
||||||
SERVER_PORT: "8111"
|
SERVER_PORT: "8111"
|
||||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>羚牛氢能车辆资产</title>
|
<title>羚牛氢能</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
165
src/App.tsx
165
src/App.tsx
@@ -1,60 +1,145 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt } from "lucide-react";
|
||||||
import { Shell, type ModuleConfig } from './components/Shell';
|
import { Shell, type ModuleConfig } from "./components/Shell";
|
||||||
import AssetsModule from './modules/assets/AssetsModule';
|
import AssetsModule from "./modules/assets/AssetsModule";
|
||||||
import MileageModule from './modules/mileage/MileageModule';
|
import MileageModule from "./modules/mileage/MileageModule";
|
||||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
import SchedulingModule from "./modules/scheduling/SchedulingModule";
|
||||||
import EnergyModule from './modules/energy/EnergyModule';
|
import HydrogenModule from "./modules/energy/HydrogenModule";
|
||||||
import EleImportPage from './modules/ele/EleImportPage';
|
import ElectricModule from "./modules/energy/ElectricModule";
|
||||||
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
|
import EtcModule from "./modules/energy/EtcModule";
|
||||||
import AuthProvider from './auth/AuthProvider';
|
import EleImportPage from "./modules/ele/EleImportPage";
|
||||||
import { useAuth } from './auth/useAuth';
|
import FeedbackAdminPage from "./modules/admin/FeedbackAdminPage";
|
||||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
import AuthProvider from "./auth/AuthProvider";
|
||||||
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
|
import { useAuth } from "./auth/useAuth";
|
||||||
|
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||||
|
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
|
||||||
|
|
||||||
const BASE_MODULES: ModuleConfig[] = [
|
const ASSETS_MODULE: ModuleConfig = {
|
||||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
id: "assets",
|
||||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
label: "资产管理",
|
||||||
];
|
icon: Truck,
|
||||||
|
component: AssetsModule,
|
||||||
|
};
|
||||||
|
|
||||||
const ENERGY_MODULE: ModuleConfig = {
|
const MILEAGE_MODULE: ModuleConfig = {
|
||||||
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
|
id: "mileage",
|
||||||
|
label: "里程管理",
|
||||||
|
icon: Route,
|
||||||
|
component: MileageModule,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCHEDULING_MODULE: ModuleConfig = {
|
const SCHEDULING_MODULE: ModuleConfig = {
|
||||||
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
|
id: "scheduling",
|
||||||
|
label: "智能调度",
|
||||||
|
icon: Activity,
|
||||||
|
component: SchedulingModule,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HYDROGEN_MODULE: ModuleConfig = {
|
||||||
|
id: "hydrogen",
|
||||||
|
label: "氢能",
|
||||||
|
icon: Fuel,
|
||||||
|
component: HydrogenModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ELECTRIC_MODULE: ModuleConfig = {
|
||||||
|
id: "electric",
|
||||||
|
label: "电能",
|
||||||
|
icon: BatteryCharging,
|
||||||
|
component: ElectricModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ETC_MODULE: ModuleConfig = {
|
||||||
|
id: "etc",
|
||||||
|
label: "ETC",
|
||||||
|
icon: Receipt,
|
||||||
|
component: EtcModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径,
|
||||||
|
* 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。
|
||||||
|
*/
|
||||||
|
function normalizePath() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const { pathname, hash, search } = window.location;
|
||||||
|
|
||||||
|
// 主路径 & 隐藏后台页保持不变
|
||||||
|
if (pathname === "/asset" || pathname === "/energy") return;
|
||||||
|
if (pathname === "/ele/import" || pathname === "/admin/feedback") return;
|
||||||
|
|
||||||
|
const legacyMap: Record<string, { path: string; hash?: string }> = {
|
||||||
|
"/": { path: "/asset" },
|
||||||
|
"/vehicle": { path: "/asset", hash: "assets" },
|
||||||
|
"/assets": { path: "/asset", hash: "assets" },
|
||||||
|
"/mileage": { path: "/asset", hash: "mileage" },
|
||||||
|
"/scheduling": { path: "/asset", hash: "scheduling" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 未知路径兜底到 /asset(保留原 hash 让 Shell 内部继续解析)
|
||||||
|
const target = legacyMap[pathname] ?? { path: "/asset" };
|
||||||
|
const finalHash = target.hash ? `#${target.hash}` : hash || "";
|
||||||
|
window.history.replaceState(null, "", `${target.path}${search}${finalHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePath();
|
||||||
|
|
||||||
|
type PathSet = "asset" | "energy";
|
||||||
|
|
||||||
|
function getPathSet(): PathSet {
|
||||||
|
return window.location.pathname === "/energy" ? "energy" : "asset";
|
||||||
|
}
|
||||||
|
|
||||||
function getRouteKey(): string {
|
function getRouteKey(): string {
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === "undefined") return "";
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
|
if (
|
||||||
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
|
path === "/ele/import" ||
|
||||||
return '';
|
hash === "#/ele/import" ||
|
||||||
|
hash === "#ele/import"
|
||||||
|
)
|
||||||
|
return "ele/import";
|
||||||
|
if (
|
||||||
|
path === "/admin/feedback" ||
|
||||||
|
hash === "#/admin/feedback" ||
|
||||||
|
hash === "#admin/feedback"
|
||||||
|
)
|
||||||
|
return "admin/feedback";
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuthGate() {
|
function AuthGate() {
|
||||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||||
const [routeKey, setRouteKey] = useState(getRouteKey);
|
const [routeKey, setRouteKey] = useState(getRouteKey);
|
||||||
|
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
|
||||||
|
|
||||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效
|
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const update = () => setRouteKey(getRouteKey());
|
const update = () => {
|
||||||
window.addEventListener('hashchange', update);
|
setRouteKey(getRouteKey());
|
||||||
window.addEventListener('popstate', update);
|
setPathSet(getPathSet());
|
||||||
|
};
|
||||||
|
window.addEventListener("hashchange", update);
|
||||||
|
window.addEventListener("popstate", update);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('hashchange', update);
|
window.removeEventListener("hashchange", update);
|
||||||
window.removeEventListener('popstate', update);
|
window.removeEventListener("popstate", update);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const modules = useMemo(() => {
|
useEffect(() => {
|
||||||
const result = [...BASE_MODULES];
|
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||||
if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
|
}, [pathSet]);
|
||||||
|
|
||||||
|
const modules = useMemo<ModuleConfig[]>(() => {
|
||||||
|
if (pathSet === "energy") {
|
||||||
|
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
|
||||||
|
}
|
||||||
|
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE];
|
||||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
||||||
return result;
|
return result;
|
||||||
}, [user?.roles]);
|
}, [pathSet, user?.roles]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -72,10 +157,16 @@ function AuthGate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||||
if (routeKey === 'ele/import') return <EleImportPage />;
|
if (routeKey === "ele/import") return <EleImportPage />;
|
||||||
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
|
if (routeKey === "admin/feedback") return <FeedbackAdminPage />;
|
||||||
|
|
||||||
return <Shell modules={modules} />;
|
// /energy 整组按能源权限控制
|
||||||
|
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
|
||||||
|
return <UnauthorizedPage message="无能源管理模块访问权限" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule
|
||||||
|
return <Shell key={pathSet} modules={modules} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|||||||
@@ -10,29 +10,20 @@ export interface ModuleConfig {
|
|||||||
component: ComponentType;
|
component: ComponentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** path 到模块 id 的映射 */
|
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||||||
const PATH_MAP: Record<string, string> = {
|
function getHashHead(): string {
|
||||||
'/vehicle': 'assets',
|
return window.location.hash.slice(1).split('/')[0];
|
||||||
'/assets': 'assets',
|
}
|
||||||
'/mileage': 'mileage',
|
|
||||||
'/scheduling': 'scheduling',
|
|
||||||
'/energy': 'energy',
|
|
||||||
};
|
|
||||||
|
|
||||||
function getInitialModule(modules: ModuleConfig[]): string {
|
function getInitialModule(modules: ModuleConfig[]): string {
|
||||||
// 优先看 hash
|
const head = getHashHead();
|
||||||
const hash = window.location.hash.slice(1);
|
if (modules.some((m) => m.id === head)) return head;
|
||||||
if (modules.some((m) => m.id === hash)) return hash;
|
|
||||||
// 再看 pathname
|
|
||||||
const pathModule = PATH_MAP[window.location.pathname];
|
|
||||||
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
|
|
||||||
// 默认第一个
|
|
||||||
return modules[0]?.id ?? '';
|
return modules[0]?.id ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHashModule(modules: ModuleConfig[]): string {
|
function getHashModule(modules: ModuleConfig[]): string {
|
||||||
const hash = window.location.hash.slice(1);
|
const head = getHashHead();
|
||||||
return modules.some((m) => m.id === hash) ? hash : '';
|
return modules.some((m) => m.id === head) ? head : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||||
@@ -48,16 +39,17 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
}, [modules]);
|
}, [modules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
|
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
|
||||||
if (window.location.hash.slice(1) !== activeModule) {
|
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
|
||||||
|
if (getHashHead() !== activeModule) {
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
||||||
}
|
}
|
||||||
}, [activeModule]);
|
}, [activeModule]);
|
||||||
|
|
||||||
const switchModule = (id: string) => {
|
const switchModule = (id: string) => {
|
||||||
if (window.location.hash.slice(1) === id) return;
|
if (getHashHead() === id) return;
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
||||||
setActiveModule(id);
|
setActiveModule(id);
|
||||||
|
|||||||
@@ -14,3 +14,12 @@ body {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes marquee {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-marquee {
|
||||||
|
animation: marquee 30s linear infinite;
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,42 @@ const TABS = [
|
|||||||
{ id: 'customer', label: '按客户' },
|
{ id: 'customer', label: '按客户' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function MarqueeBanner() {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const innerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [overflow, setOverflow] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => {
|
||||||
|
if (!trackRef.current || !innerRef.current) return;
|
||||||
|
setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth);
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
const ro = new ResizeObserver(check);
|
||||||
|
ro.observe(trackRef.current!);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const text = '⚠️ 因 OneOS 数据迁移未完成,当前状态暂不准确,预计下周完成对接后恢复正常。';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative -mx-6 mb-4 bg-amber-50 border-y border-amber-200">
|
||||||
|
<div ref={trackRef} className="overflow-hidden">
|
||||||
|
<div className={`flex w-max py-2 ${overflow ? 'animate-marquee' : 'w-full justify-center'}`}>
|
||||||
|
<span ref={innerRef} className="inline-block whitespace-nowrap px-6 text-xs text-amber-700 font-medium">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
{overflow && (
|
||||||
|
<span className="inline-block whitespace-nowrap px-6 text-xs text-amber-700 font-medium">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AssetsModule() {
|
export default function AssetsModule() {
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
|
||||||
const [tabReady, setTabReady] = useState(true);
|
const [tabReady, setTabReady] = useState(true);
|
||||||
@@ -92,7 +128,7 @@ export default function AssetsModule() {
|
|||||||
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
|
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lastUpdate, setLastUpdate] = useState<string>('');
|
const [lastUpdate] = useState<string>('2026-06-03 23:59:59');
|
||||||
const [modalLoading, setModalLoading] = useState(false);
|
const [modalLoading, setModalLoading] = useState(false);
|
||||||
|
|
||||||
// Dept/Region/Customer data
|
// Dept/Region/Customer data
|
||||||
@@ -162,7 +198,6 @@ export default function AssetsModule() {
|
|||||||
setRegionData(region);
|
setRegionData(region);
|
||||||
setCustomerData(cust);
|
setCustomerData(cust);
|
||||||
setInventoryData(inv);
|
setInventoryData(inv);
|
||||||
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : '数据加载失败');
|
setError(e instanceof Error ? e.message : '数据加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -526,7 +561,7 @@ export default function AssetsModule() {
|
|||||||
<div className="sticky top-0 z-40 -mx-6 -mt-6 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80">
|
<div className="sticky top-0 z-40 -mx-6 -mt-6 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80">
|
||||||
{/* Title row */}
|
{/* Title row */}
|
||||||
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
|
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
|
||||||
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能车辆资产</h1>
|
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能-资产BI</h1>
|
||||||
{/* Right: status + theme */}
|
{/* Right: status + theme */}
|
||||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||||
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
|
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
|
||||||
@@ -690,12 +725,15 @@ export default function AssetsModule() {
|
|||||||
最后更新: {lastUpdate}
|
最后更新: {lastUpdate}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="w-1 h-1 rounded-full bg-green-400 animate-pulse inline-block" />
|
<span className="w-1 h-1 rounded-full bg-amber-400 inline-block" />
|
||||||
每分钟更新一次
|
OneOS数据源接入中
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* OneOS 迁移提示滚动条 */}
|
||||||
|
<MarqueeBanner />
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
|
|||||||
23
src/modules/energy/ElectricModule.tsx
Normal file
23
src/modules/energy/ElectricModule.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||||
|
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
||||||
|
import SubTabs from './SubTabs';
|
||||||
|
import { useHashSubTab } from './useHashSubTab';
|
||||||
|
|
||||||
|
const SUB_TABS = [
|
||||||
|
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||||
|
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||||
|
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
|
||||||
|
|
||||||
|
const SUB_IDS: readonly ElectricSubTab[] = ['daily', 'overview'];
|
||||||
|
|
||||||
|
export default function ElectricModule() {
|
||||||
|
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||||
|
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
||||||
|
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||||
|
<ElectricView sub={sub} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
|
||||||
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
|
||||||
import ETCView from './ETCView';
|
|
||||||
|
|
||||||
type TopTab = 'hydrogen' | 'electric' | 'etc';
|
|
||||||
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
|
|
||||||
|
|
||||||
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
|
|
||||||
{ key: 'hydrogen', label: '氢能', icon: Fuel },
|
|
||||||
{ key: 'electric', label: '电能', icon: BatteryCharging },
|
|
||||||
{ key: 'etc', label: 'ETC', icon: Receipt },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
|
|
||||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
|
||||||
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EnergyModule() {
|
|
||||||
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
|
|
||||||
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
|
|
||||||
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
|
|
||||||
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
|
|
||||||
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
|
|
||||||
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
|
||||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
|
||||||
|
|
||||||
{/* 统一 sticky 头部:top tab + (氢能时) 子 tab;同一张卡片,无间隙 */}
|
|
||||||
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
|
|
||||||
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
|
||||||
{/* 顶部 tab:氢能 / 电能 / ETC */}
|
|
||||||
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
|
|
||||||
{TABS.map(tab => {
|
|
||||||
const Icon = tab.icon;
|
|
||||||
const active = activeTab === tab.key;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setActiveTab(tab.key)}
|
|
||||||
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span className="text-[11px] font-bold">{tab.label}</span>
|
|
||||||
{active && (
|
|
||||||
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* 子 tab:氢能 / 电能 都显示 每日 / 总览 */}
|
|
||||||
{showSubTabs && (
|
|
||||||
<div className="p-1 flex gap-1">
|
|
||||||
{SUB_TABS.map(({ id, label, icon: Icon }) => {
|
|
||||||
const active = currentSub === id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
onClick={() => setSub(id)}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
|
||||||
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
|
|
||||||
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
|
|
||||||
{activeTab === 'etc' && <ETCView />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
11
src/modules/energy/EtcModule.tsx
Normal file
11
src/modules/energy/EtcModule.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import ETCView from './ETCView';
|
||||||
|
|
||||||
|
export default function EtcModule() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||||
|
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
||||||
|
<ETCView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/modules/energy/HydrogenModule.tsx
Normal file
23
src/modules/energy/HydrogenModule.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||||
|
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
||||||
|
import SubTabs from './SubTabs';
|
||||||
|
import { useHashSubTab } from './useHashSubTab';
|
||||||
|
|
||||||
|
const SUB_TABS = [
|
||||||
|
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||||
|
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||||
|
] as const satisfies readonly { id: HydrogenSubTab; label: string; icon: typeof CalendarDays }[];
|
||||||
|
|
||||||
|
const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
|
||||||
|
|
||||||
|
export default function HydrogenModule() {
|
||||||
|
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||||
|
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
||||||
|
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||||
|
<HydrogenView sub={sub} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/modules/energy/SubTabs.tsx
Normal file
39
src/modules/energy/SubTabs.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
interface SubTab<T extends string> {
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
icon: ComponentType<{ size?: number; className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<T extends string> {
|
||||||
|
tabs: readonly SubTab<T>[];
|
||||||
|
active: T;
|
||||||
|
onChange: (id: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-1 flex gap-1">
|
||||||
|
{tabs.map(({ id, label, icon: Icon }) => {
|
||||||
|
const isActive = active === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => onChange(id)}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
||||||
|
isActive ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/modules/energy/useHashSubTab.ts
Normal file
38
src/modules/energy/useHashSubTab.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把模块内子 tab 状态同步到 URL hash 二级段。
|
||||||
|
* hash 形如 `#<moduleId>`(= 默认 sub)或 `#<moduleId>/<sub>`。
|
||||||
|
* 默认值不写入 hash,刷新页面可恢复。
|
||||||
|
*/
|
||||||
|
export function useHashSubTab<T extends string>(
|
||||||
|
moduleId: string,
|
||||||
|
subs: readonly T[],
|
||||||
|
): [T, (sub: T) => void] {
|
||||||
|
const defaultSub = subs[0];
|
||||||
|
|
||||||
|
const parse = (): T => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
const [first, second] = hash.split('/');
|
||||||
|
if (first !== moduleId) return defaultSub;
|
||||||
|
if (second && (subs as readonly string[]).includes(second)) return second as T;
|
||||||
|
return defaultSub;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [sub, setSubState] = useState<T>(parse);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setSubState(parse());
|
||||||
|
window.addEventListener('hashchange', onChange);
|
||||||
|
return () => window.removeEventListener('hashchange', onChange);
|
||||||
|
}, [moduleId]);
|
||||||
|
|
||||||
|
const setSub = (next: T) => {
|
||||||
|
const { pathname, search } = window.location;
|
||||||
|
const newHash = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
|
||||||
|
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
|
||||||
|
setSubState(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [sub, setSub];
|
||||||
|
}
|
||||||
@@ -12,6 +12,12 @@ import PlateMultiSelect from './PlateMultiSelect';
|
|||||||
import { exportMileageXlsx } from './xlsx-export';
|
import { exportMileageXlsx } from './xlsx-export';
|
||||||
import VehicleDetailModal from './VehicleDetailModal';
|
import VehicleDetailModal from './VehicleDetailModal';
|
||||||
|
|
||||||
|
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
||||||
|
'交投40辆4.5T普货',
|
||||||
|
'交投190辆4.5T冷链车',
|
||||||
|
]);
|
||||||
|
const HIGH_MILEAGE_ALERT_KM = 800;
|
||||||
|
|
||||||
const SearchableSelect = ({
|
const SearchableSelect = ({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -137,6 +143,12 @@ export default function MonitoringView() {
|
|||||||
const departments = filterOptions.departments;
|
const departments = filterOptions.departments;
|
||||||
const plateNumbers = filterOptions.plates;
|
const plateNumbers = filterOptions.plates;
|
||||||
|
|
||||||
|
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
|
||||||
|
const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name))
|
||||||
|
|| HIGH_MILEAGE_ALERT_TARGETS.has(filterTargetName);
|
||||||
|
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
|
||||||
|
}, [filterTargetName]);
|
||||||
|
|
||||||
// 加载首页数据
|
// 加载首页数据
|
||||||
const loadFirstPage = useCallback(() => {
|
const loadFirstPage = useCallback(() => {
|
||||||
setPageLoading(true);
|
setPageLoading(true);
|
||||||
@@ -525,7 +537,9 @@ export default function MonitoringView() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/30">
|
<tbody className="divide-y divide-slate-800/30">
|
||||||
{fullscreenVehicles.map((v) => (
|
{fullscreenVehicles.map((v) => {
|
||||||
|
const highMileageAlert = isHighMileageAlert(v);
|
||||||
|
return (
|
||||||
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
|
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : (v.isDataSynced || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
|
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : (v.isDataSynced || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
|
||||||
@@ -535,8 +549,8 @@ export default function MonitoringView() {
|
|||||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
|
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
|
||||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? 'text-blue-400' : 'text-amber-400'}`}>
|
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
|
||||||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
@@ -545,7 +559,8 @@ export default function MonitoringView() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -581,7 +596,7 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
||||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight">实时监控 • 每分钟更新</span>
|
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight">数据监控 • 每15分钟更新</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -896,7 +911,9 @@ export default function MonitoringView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-1.5">
|
<div className="grid grid-cols-1 gap-1.5">
|
||||||
{filteredVehicles.map((v) => (
|
{filteredVehicles.map((v) => {
|
||||||
|
const highMileageAlert = isHighMileageAlert(v);
|
||||||
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@@ -930,8 +947,8 @@ export default function MonitoringView() {
|
|||||||
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
|
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
|
||||||
)}
|
)}
|
||||||
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none">今</span>
|
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none">今</span>
|
||||||
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? 'text-blue-600' : 'text-amber-600'}`}>
|
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
|
||||||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>km</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -942,7 +959,8 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredVehicles.length === 0 && !loadingMore && (
|
{filteredVehicles.length === 0 && !loadingMore && (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Truck, ChevronDown, Maximize2, Minimize2,
|
Truck, ChevronDown, Maximize2, Minimize2,
|
||||||
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
|
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
||||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
|
||||||
@@ -19,11 +19,31 @@ function getDefaultDate(): string {
|
|||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCurrentDateLabel(): string {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
function fmtKm(value: number): string {
|
function fmtKm(value: number): string {
|
||||||
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
||||||
return value.toLocaleString();
|
return value.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtPercent(value: number): string {
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTargetAssessment(target: TargetSummary, selectedYear?: number): TargetYearlyAssessment | null {
|
||||||
|
if (target.yearlyAssessments.length === 0) return null;
|
||||||
|
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateLabel(date: string | null): string {
|
||||||
|
if (!date) return '';
|
||||||
|
const [year, month, day] = date.split('-');
|
||||||
|
return `${year}.${Number(month)}.${Number(day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function shortTargetName(name: string): string {
|
function shortTargetName(name: string): string {
|
||||||
// Extract the number and a short description
|
// Extract the number and a short description
|
||||||
const match = name.match(/(\d+)[辆台](.+)/);
|
const match = name.match(/(\d+)[辆台](.+)/);
|
||||||
@@ -39,6 +59,7 @@ function shortTargetName(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StatisticsView() {
|
export default function StatisticsView() {
|
||||||
|
const currentDateLabel = getCurrentDateLabel();
|
||||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||||
@@ -46,7 +67,8 @@ export default function StatisticsView() {
|
|||||||
|
|
||||||
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
||||||
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
||||||
const [expandedModel, setExpandedModel] = useState<string | null>(null);
|
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
|
||||||
|
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
|
||||||
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
|
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
|
||||||
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
||||||
const [viewAllSearch, setViewAllSearch] = useState('');
|
const [viewAllSearch, setViewAllSearch] = useState('');
|
||||||
@@ -54,12 +76,25 @@ export default function StatisticsView() {
|
|||||||
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
||||||
const [viewAllLoading, setViewAllLoading] = useState(false);
|
const [viewAllLoading, setViewAllLoading] = useState(false);
|
||||||
|
|
||||||
|
const selectedTarget = targets.find(t => t.id === selectedTargetId);
|
||||||
|
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
||||||
|
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
||||||
|
|
||||||
// Load targets on mount
|
// Load targets on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTargets().then(data => {
|
fetchTargets().then(data => {
|
||||||
setTargets(data);
|
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
||||||
if (data.length > 0 && !selectedTargetId) {
|
const ordered = focused
|
||||||
setSelectedTargetId(data[0].id);
|
? [focused, ...data.filter(item => item.id !== focused.id)]
|
||||||
|
: data;
|
||||||
|
setTargets(ordered);
|
||||||
|
if (ordered.length > 0 && !selectedTargetId) {
|
||||||
|
setSelectedTargetId(focused.id);
|
||||||
|
setExpandedTargetId(focused.id);
|
||||||
|
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1])));
|
||||||
|
fetchTargetVehicles(focused.id).then(vehicles => {
|
||||||
|
setTargetVehiclesMap(prev => ({ ...prev, [focused.id]: vehicles }));
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -80,7 +115,7 @@ export default function StatisticsView() {
|
|||||||
}, [viewAllTargetId, viewAllDate]);
|
}, [viewAllTargetId, viewAllDate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1" style={{ overflowX: 'clip' }}>
|
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
||||||
{/* Project Selector */}
|
{/* Project Selector */}
|
||||||
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
|
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
|
||||||
{targets.map(target => (
|
{targets.map(target => (
|
||||||
@@ -103,7 +138,7 @@ export default function StatisticsView() {
|
|||||||
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
|
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
|
||||||
{/* KPI Cards in Landscape — linked to selected target */}
|
{/* KPI Cards in Landscape — linked to selected target */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const sel = targets.find(t => t.id === selectedTargetId);
|
const sel = selectedTarget;
|
||||||
return (
|
return (
|
||||||
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
|
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
|
||||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||||
@@ -130,7 +165,7 @@ export default function StatisticsView() {
|
|||||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
|
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
|
||||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||||
{(sel?.avgCompletion ?? 0).toFixed(1)}
|
{selectedCompletion.toFixed(1)}
|
||||||
<span className="text-blue-500 text-[10px] ml-1">%</span>
|
<span className="text-blue-500 text-[10px] ml-1">%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,12 +259,17 @@ export default function StatisticsView() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
|
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
|
||||||
{targets.map((target, idx) => (
|
{targets.map((target, idx) => (
|
||||||
|
(() => {
|
||||||
|
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
|
||||||
|
const primaryCompletion = assessment?.completionRate ?? target.avgCompletion;
|
||||||
|
const primaryQualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
|
||||||
|
const primaryQualifiedLabel = assessment ? `${assessment.label}达标:` : '达标:';
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
|
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const name = target.targetName;
|
setExpandedTargetId(expandedTargetId === target.id ? null : target.id);
|
||||||
setExpandedModel(expandedModel === name ? null : name);
|
|
||||||
if (!targetVehiclesMap[target.id]) {
|
if (!targetVehiclesMap[target.id]) {
|
||||||
fetchTargetVehicles(target.id).then(data => {
|
fetchTargetVehicles(target.id).then(data => {
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
|
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
|
||||||
@@ -249,12 +289,12 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[9px] text-slate-400">完成率:</span>
|
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
|
||||||
<span className={`text-[9px] font-bold ${target.avgCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{target.avgCompletion.toFixed(1)}%</span>
|
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[9px] text-slate-400">达标:</span>
|
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
|
||||||
<span className="text-[9px] font-bold text-slate-600">{target.yearQualifiedCount}台</span>
|
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}台</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +309,7 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: expandedModel === target.targetName ? 180 : 0 }}
|
animate={{ rotate: expandedTargetId === target.id ? 180 : 0 }}
|
||||||
className="text-slate-300"
|
className="text-slate-300"
|
||||||
>
|
>
|
||||||
<ChevronDown size={14} />
|
<ChevronDown size={14} />
|
||||||
@@ -277,52 +317,103 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence initial={false} mode="wait">
|
||||||
{expandedModel === target.targetName && (
|
{expandedTargetId === target.id && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ height: 0, opacity: 0 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
|
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
|
||||||
<div className="space-y-0.5">
|
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">考核区间</p>
|
<span className="text-[10px] font-black text-blue-700">考核年度</span>
|
||||||
{target.periods.map((p, i) => (
|
<select
|
||||||
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
|
value={assessment?.yearNumber || ''}
|
||||||
))}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setAssessmentYearMap(prev => ({ ...prev, [target.id]: Number(e.target.value) }));
|
||||||
|
}}
|
||||||
|
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
|
||||||
|
>
|
||||||
|
{target.yearlyAssessments.map(item => (
|
||||||
|
<option key={item.yearNumber} value={item.yearNumber}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
|
<div className="space-y-0.5">
|
||||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核区间</p>
|
||||||
</div>
|
{target.periods.map((p, i) => (
|
||||||
<div className="space-y-0.5">
|
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">年考核任务/辆</p>
|
))}
|
||||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle)} km</p>
|
</div>
|
||||||
</div>
|
<div className="space-y-0.5">
|
||||||
<div className="space-y-0.5">
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%达标数</p>
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
||||||
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} 台</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">本年需完成</p>
|
|
||||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">已完成(截止3.31)</p>
|
|
||||||
<p className="text-[10px] font-black text-emerald-600">{fmtKm(target.currentYearCompleted)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">未完成总数</p>
|
|
||||||
<p className="text-[10px] font-black text-rose-500">{fmtKm(target.remaining)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">日均需完成</p>
|
|
||||||
<p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{assessment ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}区间</p>
|
||||||
|
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, i) => (
|
||||||
|
<p key={i} className="text-[10px] font-black text-slate-700">{period}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}任务/辆</p>
|
||||||
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已进入车辆</p>
|
||||||
|
<p className="text-[10px] font-black text-blue-600">{assessment.vehicleCount} 台</p>
|
||||||
|
{assessment.vehicleCount < target.vehicleCount && (
|
||||||
|
<p className="text-[8px] font-bold text-slate-400">其余 {target.vehicleCount - assessment.vehicleCount} 台尚未进入{assessment.label}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}需完成</p>
|
||||||
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} km</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已完成</p>
|
||||||
|
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
|
||||||
|
<p className="text-[8px] font-bold text-slate-300">
|
||||||
|
数据截至 {assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}完成率</p>
|
||||||
|
<p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}达标率</p>
|
||||||
|
<p className="text-[10px] font-black text-emerald-600">{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount}台)</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}剩余里程</p>
|
||||||
|
<p className="text-[10px] font-black text-rose-500">{fmtKm(assessment.remaining)} km</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}日均需完成</p>
|
||||||
|
<p className="text-[10px] font-black text-blue-500">
|
||||||
|
{assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '考核已到期'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="col-span-2 bg-slate-50 p-2 rounded-lg text-[10px] font-bold text-slate-400">
|
||||||
|
暂无第一年度车辆,当前车型车辆已进入后续考核年度。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
|
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
|
||||||
<span className="text-[9px] font-bold text-slate-500">剩余考核天数</span>
|
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
|
||||||
<span className="text-[10px] font-black text-slate-900">{target.daysLeft} 天</span>
|
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} 天</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vehicle List Detail */}
|
{/* Vehicle List Detail */}
|
||||||
@@ -363,6 +454,8 @@ export default function StatisticsView() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,7 +484,7 @@ export default function StatisticsView() {
|
|||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> 台</span>
|
<span className="text-slate-500">车辆 <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> 台</span>
|
||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">完成率 <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + t.avgCompletion, 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
|
<span className="text-slate-500">所选年度完成率 <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + (getTargetAssessment(t, assessmentYearMap[t.id])?.completionRate ?? t.avgCompletion), 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -417,12 +510,12 @@ export default function StatisticsView() {
|
|||||||
<tr className="border-b border-slate-800/60">
|
<tr className="border-b border-slate-800/60">
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">完成进度</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">年度进度</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年任务/辆</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度任务/辆</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">达标</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">年度达标</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%达标</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%达标</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right">今日里程</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right">今日里程</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">本年目标</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度目标</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">已完成</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">已完成</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right">未完成</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right">未完成</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
|
||||||
@@ -430,10 +523,22 @@ export default function StatisticsView() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/30">
|
<tbody className="divide-y divide-slate-800/30">
|
||||||
{targets.map((target, idx) => (
|
{targets.map((target, idx) => {
|
||||||
|
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
|
||||||
|
const completion = assessment?.completionRate ?? target.avgCompletion;
|
||||||
|
const qualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
|
||||||
|
const halfQualified = assessment?.halfQualifiedCount ?? target.halfQualifiedCount;
|
||||||
|
const goal = assessment?.target ?? target.currentYearTarget;
|
||||||
|
const completed = assessment?.completed ?? target.currentYearCompleted;
|
||||||
|
const remainingMileage = assessment?.remaining ?? target.remaining;
|
||||||
|
const days = assessment?.daysLeft ?? target.daysLeft;
|
||||||
|
const daily = assessment?.dailyTarget ?? target.dailyTarget;
|
||||||
|
const taskPerVehicle = target.annualMileagePerVehicle * (assessment?.yearNumber || 1);
|
||||||
|
return (
|
||||||
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
|
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
|
||||||
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
|
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
|
||||||
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
|
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
|
||||||
|
<div className="text-[9px] text-blue-400 font-bold mt-0.5">{assessment?.label || '当前年度'}</div>
|
||||||
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
|
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
|
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
|
||||||
@@ -441,28 +546,31 @@ export default function StatisticsView() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
className={`h-full rounded-full ${completion >= 90 ? 'bg-emerald-500' : completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
||||||
style={{ width: `${Math.min(target.avgCompletion, 100)}%` }}
|
style={{ width: `${Math.min(completion, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-black text-white w-10 text-right">{target.avgCompletion.toFixed(1)}%</span>
|
<span className="text-[10px] font-black text-white w-10 text-right">{completion.toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
|
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
|
||||||
<span>{fmtKm(target.cumulativeTotal)}</span>
|
<span>{fmtKm(completed)}</span>
|
||||||
<span>/ {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</span>
|
<span>/ {fmtKm(goal)} km</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(target.annualMileagePerVehicle)} km</td>
|
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(taskPerVehicle)} km</td>
|
||||||
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{target.yearQualifiedCount}</td>
|
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{qualified}</td>
|
||||||
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{target.halfQualifiedCount}</td>
|
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{halfQualified}</td>
|
||||||
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
|
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
|
||||||
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(target.currentYearTarget)} km</td>
|
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(goal)} km</td>
|
||||||
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(target.currentYearCompleted)} km</td>
|
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(completed)} km</td>
|
||||||
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(target.remaining)} km</td>
|
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(remainingMileage)} km</td>
|
||||||
<td className="px-3 py-3 text-xs text-slate-300 text-center">{target.daysLeft}</td>
|
<td className="px-3 py-3 text-xs text-slate-300 text-center">{days}</td>
|
||||||
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">{fmtKm(target.dailyTarget)} km</td>
|
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
|
||||||
|
{assessment && days === 0 ? '考核已到期' : `${fmtKm(daily)} km`}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface MonitoringVehicle {
|
|||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
|
targetNames: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringStats {
|
export interface MonitoringStats {
|
||||||
@@ -63,6 +64,37 @@ export interface TargetSummary {
|
|||||||
remaining: number;
|
remaining: number;
|
||||||
daysLeft: number;
|
daysLeft: number;
|
||||||
dailyTarget: number;
|
dailyTarget: number;
|
||||||
|
firstYearVehicleCount: number;
|
||||||
|
firstYearTarget: number;
|
||||||
|
firstYearCompleted: number;
|
||||||
|
firstYearRemaining: number;
|
||||||
|
firstYearCompletionRate: number;
|
||||||
|
firstYearQualifiedCount: number;
|
||||||
|
firstYearQualifiedRate: number;
|
||||||
|
firstYearHalfQualifiedCount: number;
|
||||||
|
firstYearDaysLeft: number;
|
||||||
|
firstYearDailyTarget: number;
|
||||||
|
firstYearStartDate: string | null;
|
||||||
|
firstYearEndDate: string | null;
|
||||||
|
yearlyAssessments: TargetYearlyAssessment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TargetYearlyAssessment {
|
||||||
|
yearNumber: number;
|
||||||
|
label: string;
|
||||||
|
vehicleCount: number;
|
||||||
|
target: number;
|
||||||
|
completed: number;
|
||||||
|
remaining: number;
|
||||||
|
completionRate: number;
|
||||||
|
qualifiedCount: number;
|
||||||
|
qualifiedRate: number;
|
||||||
|
halfQualifiedCount: number;
|
||||||
|
daysLeft: number;
|
||||||
|
dailyTarget: number;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
periods: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TargetVehicle {
|
export interface TargetVehicle {
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | nu
|
|||||||
if (kind === 'today') {
|
if (kind === 'today') {
|
||||||
// 当日未对接但有历史累计,视作今日 0;只有完全无数据才标「未对接」
|
// 当日未对接但有历史累计,视作今日 0;只有完全无数据才标「未对接」
|
||||||
if (!v.isDataSynced && v.totalKm == null) return '未对接';
|
if (!v.isDataSynced && v.totalKm == null) return '未对接';
|
||||||
return Math.max(0, Math.round(v.dailyKm || 0));
|
return Math.max(0, v.dailyKm || 0);
|
||||||
}
|
}
|
||||||
return v.totalKm != null ? Math.round(v.totalKm) : '未对接';
|
return v.totalKm != null ? v.totalKm : '未对接';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
||||||
@@ -57,6 +57,13 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
|
|
||||||
ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
|
ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
|
||||||
|
|
||||||
|
for (let r = 1; r < data.length; r++) {
|
||||||
|
for (const c of [7, 8]) {
|
||||||
|
const ref = XLSX.utils.encode_cell({ r, c });
|
||||||
|
if (ws[ref]?.t === 'n') ws[ref].z = '0.##########';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
|
// 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
|
||||||
for (let c = 0; c < HEADERS.length; c++) {
|
for (let c = 0; c < HEADERS.length; c++) {
|
||||||
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||||||
|
|||||||
17
src/server/hydrogen-db.ts
Normal file
17
src/server/hydrogen-db.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const hydrogenPool = mysql.createPool({
|
||||||
|
host: process.env.HYDROGEN_DB_HOST || '47.99.185.173',
|
||||||
|
port: Number(process.env.HYDROGEN_DB_PORT) || 3306,
|
||||||
|
user: process.env.HYDROGEN_DB_USER || 'root',
|
||||||
|
password: process.env.HYDROGEN_DB_PASSWORD || 'lnMysql.',
|
||||||
|
database: process.env.HYDROGEN_DB_NAME || 'ln_asset_management',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 5,
|
||||||
|
queueLimit: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default hydrogenPool;
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import mysql from 'mysql2/promise';
|
import mysql from 'mysql2/promise';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
const mileagePool = mysql.createPool({
|
const mileagePool = mysql.createPool({
|
||||||
host: '101.133.130.65',
|
host: process.env.MILEAGE_DB_HOST || '101.133.130.65',
|
||||||
port: 3306,
|
port: Number(process.env.MILEAGE_DB_PORT) || 3306,
|
||||||
user: 'bi_reader_02',
|
user: process.env.MILEAGE_DB_USER || 'bi_reader_02',
|
||||||
password: 'bi_reader_02_Pass',
|
password: process.env.MILEAGE_DB_PASSWORD || 'bi_reader_02_Pass',
|
||||||
database: 'hydrogen_energy',
|
database: process.env.MILEAGE_DB_NAME || 'hydrogen_energy',
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 5,
|
connectionLimit: 5,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { RowDataPacket } from 'mysql2';
|
import type { RowDataPacket } from 'mysql2';
|
||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
|
import hydrogenPool from '../../hydrogen-db.js';
|
||||||
import { cached } from './cache.js';
|
import { cached } from './cache.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import { canAccessEnergy } from '../../auth/types.js';
|
import { canAccessEnergy } from '../../auth/types.js';
|
||||||
@@ -19,16 +20,19 @@ app.use('*', async (c, next) => {
|
|||||||
|
|
||||||
const HYDROGEN_MIN_DATE = '2024-01-01';
|
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||||||
|
|
||||||
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
|
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
|
||||||
const HYDROGEN_LOCAL = `hydrogen_time`;
|
const HYDROGEN_TABLE = 'hydrogen_fuel_ledger';
|
||||||
|
const HYDROGEN_LOCAL = `refuel_time`;
|
||||||
|
const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
|
||||||
|
const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
|
||||||
const ELECTRIC_LOCAL = `charging_start_time`;
|
const ELECTRIC_LOCAL = `charging_start_time`;
|
||||||
|
|
||||||
type CustomerKind = 'external' | 'lingniu' | 'all';
|
type CustomerKind = 'external' | 'lingniu' | 'all';
|
||||||
|
|
||||||
// 外部/我司判定:truck_id 为空 = 外部;truck_id 非空 = 我司(羚牛车辆)
|
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
|
||||||
function customerClause(field: string, customer: CustomerKind): string {
|
function customerClause(customer: CustomerKind): string {
|
||||||
if (customer === 'external') return `${field} IS NULL`;
|
if (customer === 'external') return '1=0';
|
||||||
if (customer === 'lingniu') return `${field} IS NOT NULL`;
|
if (customer === 'lingniu') return '1=1';
|
||||||
return '1=1';
|
return '1=1';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +84,10 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
|
|
||||||
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
||||||
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
||||||
const [yearListRows] = await pool.query<RowDataPacket[]>(
|
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||||||
FROM tab_energy_hydrogen_bill
|
FROM ${HYDROGEN_TABLE}
|
||||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?
|
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
|
||||||
ORDER BY y DESC`,
|
ORDER BY y DESC`,
|
||||||
[HYDROGEN_MIN_DATE],
|
[HYDROGEN_MIN_DATE],
|
||||||
);
|
);
|
||||||
@@ -92,44 +96,46 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
const isCurrentYear = year === todayYear;
|
const isCurrentYear = year === todayYear;
|
||||||
|
|
||||||
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
||||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT
|
`SELECT
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
|
THEN amount_kg ELSE 0 END) AS yearKg,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
THEN cost_expense ELSE 0 END) AS yearFee,
|
THEN cost_total ELSE 0 END) AS yearFee,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||||
THEN cost_expense ELSE 0 END) AS yearCustomerCost,
|
THEN cost_total ELSE 0 END) AS yearCustomerCost,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
THEN customer_expense ELSE 0 END) AS yearRevenue,
|
THEN fee_total ELSE 0 END) AS yearRevenue,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
|
THEN amount_kg ELSE 0 END) AS ourYearKg,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
||||||
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
THEN cost_total ELSE 0 END) AS ourYearFee,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
THEN amount_kg ELSE 0 END) AS customerYearKg,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
|
THEN amount_kg ELSE 0 END) AS monthKg,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
THEN cost_expense ELSE 0 END) AS monthFee,
|
THEN cost_total ELSE 0 END) AS monthFee,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') AND cost_type = 2
|
|
||||||
THEN cost_expense ELSE 0 END) AS monthCustomerCost,
|
|
||||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
THEN customer_expense ELSE 0 END) AS monthRevenue,
|
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||||
|
THEN cost_total ELSE 0 END) AS monthCustomerCost,
|
||||||
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
|
THEN fee_total ELSE 0 END) AS monthRevenue,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS todayKg,
|
THEN amount_kg ELSE 0 END) AS todayKg,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
THEN cost_expense ELSE 0 END) AS todayFee,
|
THEN cost_total ELSE 0 END) AS todayFee,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() AND cost_type = 2
|
|
||||||
THEN cost_expense ELSE 0 END) AS todayCustomerCost,
|
|
||||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
THEN customer_expense ELSE 0 END) AS todayRevenue,
|
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||||
SUM(CASE WHEN truck_id IS NOT NULL
|
THEN cost_total ELSE 0 END) AS todayCustomerCost,
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
SUM(CASE WHEN truck_id IS NOT NULL
|
THEN fee_total ELSE 0 END) AS todayRevenue,
|
||||||
THEN cost_expense ELSE 0 END) AS lingniuBornFee
|
SUM(CASE WHEN vehicle_id IS NOT NULL
|
||||||
FROM tab_energy_hydrogen_bill
|
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
|
||||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
|
SUM(CASE WHEN vehicle_id IS NOT NULL
|
||||||
|
THEN cost_total ELSE 0 END) AS lingniuBornFee
|
||||||
|
FROM ${HYDROGEN_TABLE}
|
||||||
|
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
|
||||||
[year, year, year, year, year, year, year,
|
[year, year, year, year, year, year, year,
|
||||||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||||||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||||||
@@ -166,23 +172,19 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Top5 加氢站(指定年份)
|
// Top5 加氢站(指定年份)
|
||||||
const [top5Rows] = await pool.query<RowDataPacket[]>(
|
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT b.hydrogen_station_id AS id,
|
`SELECT b.station_id AS id,
|
||||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||||||
MAX(i.hydrogen_station_name),
|
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
SUM(b.amount_kg) AS kg,
|
||||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
SUM(b.cost_total) AS fee
|
||||||
SUM(b.hydrogen_quantity) AS kg,
|
FROM ${HYDROGEN_TABLE} b
|
||||||
SUM(b.cost_expense) AS fee
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
FROM tab_energy_hydrogen_bill b
|
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
|
||||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
|
||||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
|
||||||
WHERE b.is_deleted = 0
|
|
||||||
AND b.${HYDROGEN_LOCAL} >= ?
|
AND b.${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY b.hydrogen_station_id
|
GROUP BY b.station_id
|
||||||
ORDER BY kg DESC
|
ORDER BY kg DESC
|
||||||
LIMIT 5`,
|
LIMIT 5`,
|
||||||
[HYDROGEN_MIN_DATE, year],
|
[HYDROGEN_MIN_DATE, year],
|
||||||
@@ -197,23 +199,19 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
||||||
const [stationFullRows] = await pool.query<RowDataPacket[]>(
|
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT b.hydrogen_station_id AS id,
|
`SELECT b.station_id AS id,
|
||||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||||||
MAX(i.hydrogen_station_name),
|
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
SUM(b.amount_kg) AS kg,
|
||||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
SUM(b.fee_total) AS revenue
|
||||||
SUM(b.hydrogen_quantity) AS kg,
|
FROM ${HYDROGEN_TABLE} b
|
||||||
SUM(b.customer_expense) AS revenue
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
FROM tab_energy_hydrogen_bill b
|
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
|
||||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
|
||||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
|
||||||
WHERE b.is_deleted = 0
|
|
||||||
AND b.${HYDROGEN_LOCAL} >= ?
|
AND b.${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY b.hydrogen_station_id
|
GROUP BY b.station_id
|
||||||
ORDER BY kg DESC`,
|
ORDER BY kg DESC`,
|
||||||
[HYDROGEN_MIN_DATE, year],
|
[HYDROGEN_MIN_DATE, year],
|
||||||
);
|
);
|
||||||
@@ -228,14 +226,22 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
||||||
const [regionRows] = await pool.query<RowDataPacket[]>(
|
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT region, SUM(kg) AS kg FROM (
|
`SELECT region, SUM(kg) AS kg FROM (
|
||||||
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
|
SELECT CASE
|
||||||
b.hydrogen_quantity AS kg
|
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
|
||||||
FROM tab_energy_hydrogen_bill b
|
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
|
||||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
|
||||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都'
|
||||||
WHERE b.is_deleted = 0
|
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
|
||||||
|
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
|
||||||
|
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
|
||||||
|
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
|
||||||
|
END AS region,
|
||||||
|
b.amount_kg AS kg
|
||||||
|
FROM ${HYDROGEN_TABLE} b
|
||||||
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
|
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||||
AND b.${HYDROGEN_LOCAL} >= ?
|
AND b.${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||||
) r
|
) r
|
||||||
@@ -257,15 +263,15 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
||||||
// 利润 = 客户单收入 - 客户单成本(仅 cost_type = 2)
|
// 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担)
|
||||||
const [monthRows] = await pool.query<RowDataPacket[]>(
|
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
||||||
ROUND(SUM(hydrogen_quantity), 2) AS kg,
|
ROUND(SUM(amount_kg), 2) AS kg,
|
||||||
ROUND(SUM(cost_expense), 2) AS fee,
|
ROUND(SUM(cost_total), 2) AS fee,
|
||||||
ROUND(SUM(CASE WHEN cost_type = 2 THEN cost_expense ELSE 0 END), 2) AS customerCost,
|
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
|
||||||
ROUND(SUM(customer_expense), 2) AS revenue
|
ROUND(SUM(fee_total), 2) AS revenue
|
||||||
FROM tab_energy_hydrogen_bill
|
FROM ${HYDROGEN_TABLE}
|
||||||
WHERE is_deleted = 0
|
WHERE ${HYDROGEN_BASE_WHERE}
|
||||||
AND ${HYDROGEN_LOCAL} >= ?
|
AND ${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY m
|
GROUP BY m
|
||||||
@@ -290,16 +296,16 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
||||||
// payer:cost_type=2 → 客户承担;cost_type=3 → 羚牛承担;其他 → 客户(默认)
|
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
|
||||||
const [customerRows] = await pool.query<RowDataPacket[]>(
|
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
||||||
CASE WHEN MAX(cost_type) = 3 AND MIN(cost_type) = 3 THEN 'lingniu'
|
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
|
||||||
ELSE 'customer' END AS payer,
|
ELSE 'customer' END AS payer,
|
||||||
SUM(hydrogen_quantity) AS kg,
|
SUM(amount_kg) AS kg,
|
||||||
SUM(cost_expense) AS cost,
|
SUM(cost_total) AS cost,
|
||||||
SUM(customer_expense) AS revenue
|
SUM(fee_total) AS revenue
|
||||||
FROM tab_energy_hydrogen_bill
|
FROM ${HYDROGEN_TABLE}
|
||||||
WHERE is_deleted = 0
|
WHERE ${HYDROGEN_BASE_WHERE}
|
||||||
AND ${HYDROGEN_LOCAL} >= ?
|
AND ${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY name
|
GROUP BY name
|
||||||
@@ -331,32 +337,28 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
||||||
|
|
||||||
const where = [
|
const where = [
|
||||||
'b.is_deleted = 0',
|
HYDROGEN_BASE_WHERE_B,
|
||||||
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
||||||
rangeClause(`b.hydrogen_time`, range),
|
rangeClause(`b.${HYDROGEN_LOCAL}`, range),
|
||||||
customerClause('b.truck_id', customer),
|
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
||||||
].join(' AND ');
|
].join(' AND ');
|
||||||
|
|
||||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||||
// 站点名 fallback:内部站表 → 外部站表 → 导入订单表(tab_import_hydrogen_order,按 bill_code 关联)
|
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
||||||
// 单价不重算:同价组显示原价,混合价组返回 NULL,前端显示「—」
|
// 单价不重算:直接取账本成本价。
|
||||||
const [stationRows] = await pool.query<RowDataPacket[]>(
|
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(b.hydrogen_time, '%Y-%m-%d') AS d,
|
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
|
||||||
b.hydrogen_station_id AS stationId,
|
COALESCE(b.station_id, 0) AS stationId,
|
||||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
|
||||||
MAX(i.hydrogen_station_name),
|
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
|
||||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS stationName,
|
|
||||||
ROUND(SUM(b.hydrogen_quantity), 2) AS kg,
|
|
||||||
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
||||||
MAX(b.cost_price) AS pricePerKg
|
MAX(b.cost_price) AS pricePerKg
|
||||||
FROM tab_energy_hydrogen_bill b
|
FROM ${HYDROGEN_TABLE} b
|
||||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
|
||||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
|
||||||
WHERE ${where}
|
WHERE ${where}
|
||||||
GROUP BY d, b.hydrogen_station_id
|
GROUP BY d, COALESCE(b.station_id, 0)
|
||||||
ORDER BY d DESC, kg DESC`,
|
ORDER BY d DESC, kg DESC`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -414,7 +416,7 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
date,
|
date,
|
||||||
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
||||||
chainPct: dayChainPct.get(date) ?? 0,
|
chainPct: dayChainPct.get(date) ?? 0,
|
||||||
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
customerType: customer,
|
||||||
stations: info
|
stations: info
|
||||||
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
|
|||||||
@@ -65,6 +65,41 @@ interface MileageRow {
|
|||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TargetRow {
|
||||||
|
id: number;
|
||||||
|
target_name: string;
|
||||||
|
plate_number: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTargetRows(): Promise<TargetRow[]> {
|
||||||
|
return pool.execute(
|
||||||
|
`SELECT t.id, t.target_name, v.plate_number
|
||||||
|
FROM tab_mileage_assessment_target t
|
||||||
|
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
|
||||||
|
WHERE t.is_deleted = 0`
|
||||||
|
).then(([rows]) => rows as TargetRow[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTargetPlatesMap(targetRows: TargetRow[]): Map<string, Set<string>> {
|
||||||
|
const targetPlatesMap = new Map<string, Set<string>>();
|
||||||
|
for (const r of targetRows) {
|
||||||
|
const set = targetPlatesMap.get(r.target_name) || new Set();
|
||||||
|
set.add(r.plate_number);
|
||||||
|
targetPlatesMap.set(r.target_name, set);
|
||||||
|
}
|
||||||
|
return targetPlatesMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlateTargetNamesMap(targetRows: TargetRow[]): Map<string, string[]> {
|
||||||
|
const map = new Map<string, string[]>();
|
||||||
|
for (const r of targetRows) {
|
||||||
|
const list = map.get(r.plate_number) || [];
|
||||||
|
list.push(r.target_name);
|
||||||
|
map.set(r.plate_number, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
|
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
|
||||||
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULL(G7 只回传日增量),
|
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULL(G7 只回传日增量),
|
||||||
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
|
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
|
||||||
@@ -115,6 +150,7 @@ function mergeVehicles(
|
|||||||
yesterdayMap: Map<string, number>,
|
yesterdayMap: Map<string, number>,
|
||||||
bizTotalMap: Map<string, number>,
|
bizTotalMap: Map<string, number>,
|
||||||
latestPgTotalMap: Map<string, number>,
|
latestPgTotalMap: Map<string, number>,
|
||||||
|
targetNamesByPlate: Map<string, string[]>,
|
||||||
): CachedVehicle[] {
|
): CachedVehicle[] {
|
||||||
const mileageMap = new Map<string, MileageRow>();
|
const mileageMap = new Map<string, MileageRow>();
|
||||||
for (const row of mileageRows) {
|
for (const row of mileageRows) {
|
||||||
@@ -147,6 +183,7 @@ function mergeVehicles(
|
|||||||
entity: info?.entity || null,
|
entity: info?.entity || null,
|
||||||
project: info?.project || null,
|
project: info?.project || null,
|
||||||
region: regionMap[m.plate] || null,
|
region: regionMap[m.plate] || null,
|
||||||
|
targetNames: targetNamesByPlate.get(m.plate) || [],
|
||||||
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -184,25 +221,16 @@ export async function refreshMonitoringCache(): Promise<void> {
|
|||||||
return map;
|
return map;
|
||||||
})(),
|
})(),
|
||||||
fetchVehicleInfoMap(),
|
fetchVehicleInfoMap(),
|
||||||
pool.execute(
|
fetchTargetRows(),
|
||||||
`SELECT t.id, t.target_name, v.plate_number
|
|
||||||
FROM tab_mileage_assessment_target t
|
|
||||||
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
|
|
||||||
WHERE t.is_deleted = 0`
|
|
||||||
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
|
|
||||||
fetchBizTotalMileageMap(),
|
fetchBizTotalMileageMap(),
|
||||||
fetchLatestPgTotalMileageMap(),
|
fetchLatestPgTotalMileageMap(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const targetPlatesMap = new Map<string, Set<string>>();
|
const targetPlatesMap = buildTargetPlatesMap(targetRows);
|
||||||
for (const r of targetRows) {
|
const targetNamesByPlate = buildPlateTargetNamesMap(targetRows);
|
||||||
const set = targetPlatesMap.get(r.target_name) || new Set();
|
|
||||||
set.add(r.plate_number);
|
|
||||||
targetPlatesMap.set(r.target_name, set);
|
|
||||||
}
|
|
||||||
const targetNames = Array.from(targetPlatesMap.keys());
|
const targetNames = Array.from(targetPlatesMap.keys());
|
||||||
|
|
||||||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap);
|
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap, targetNamesByPlate);
|
||||||
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
|
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
|
||||||
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
||||||
|
|
||||||
@@ -221,7 +249,7 @@ export async function refreshMonitoringCache(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
|
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
|
||||||
const [mileageRows, yesterdayRows, infoMap, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
const [mileageRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
||||||
mileagePool.execute(
|
mileagePool.execute(
|
||||||
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
|
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
|
||||||
[dateStr]
|
[dateStr]
|
||||||
@@ -231,6 +259,7 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
|
|||||||
[dateStr]
|
[dateStr]
|
||||||
).then(([r]) => r as { plate: string; daily_km: string }[]),
|
).then(([r]) => r as { plate: string; daily_km: string }[]),
|
||||||
fetchVehicleInfoMap(),
|
fetchVehicleInfoMap(),
|
||||||
|
fetchTargetRows(),
|
||||||
fetchBizTotalMileageMap(),
|
fetchBizTotalMileageMap(),
|
||||||
fetchLatestPgTotalMileageMap(dateStr),
|
fetchLatestPgTotalMileageMap(dateStr),
|
||||||
]);
|
]);
|
||||||
@@ -242,7 +271,14 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
|
|||||||
if (km > existing) yesterdayMap.set(r.plate, km);
|
if (km > existing) yesterdayMap.set(r.plate, km);
|
||||||
}
|
}
|
||||||
|
|
||||||
return mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap);
|
return mergeVehicles(
|
||||||
|
mileageRows,
|
||||||
|
infoMap,
|
||||||
|
yesterdayMap,
|
||||||
|
bizTotalMap,
|
||||||
|
latestPgTotalMap,
|
||||||
|
buildPlateTargetNamesMap(targetRows),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
||||||
|
|||||||
@@ -32,6 +32,83 @@ app.get('/', async (c) => {
|
|||||||
const statsMap = new Map<number, any>();
|
const statsMap = new Map<number, any>();
|
||||||
for (const s of vehicleStats) statsMap.set(s.target_id, s);
|
for (const s of vehicleStats) statsMap.set(s.target_id, s);
|
||||||
|
|
||||||
|
const [firstYearRows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
v.target_id,
|
||||||
|
COUNT(*) as first_year_total,
|
||||||
|
SUM(t.annual_mileage_per_vehicle) as first_year_target,
|
||||||
|
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) as first_year_completed,
|
||||||
|
SUM(GREATEST(t.annual_mileage_per_vehicle - v.current_mileage, 0)) as first_year_remaining,
|
||||||
|
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) / NULLIF(SUM(t.annual_mileage_per_vehicle), 0) as first_year_completion_rate,
|
||||||
|
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle THEN 1 ELSE 0 END) as first_year_qualified_count,
|
||||||
|
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * 0.5 THEN 1 ELSE 0 END) as first_year_half_qualified_count,
|
||||||
|
DATE_FORMAT(MIN(v.assessment_start_date), '%Y-%m-%d') as first_year_start_date,
|
||||||
|
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL 1 YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as first_year_end_date
|
||||||
|
FROM tab_mileage_assessment_vehicle v
|
||||||
|
JOIN tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
|
||||||
|
WHERE v.is_deleted = 0
|
||||||
|
GROUP BY v.target_id
|
||||||
|
`) as [any[], unknown];
|
||||||
|
|
||||||
|
const firstYearMap = new Map<number, any>();
|
||||||
|
for (const s of firstYearRows) firstYearMap.set(s.target_id, s);
|
||||||
|
|
||||||
|
const [yearlyRows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
v.target_id,
|
||||||
|
y.year_number,
|
||||||
|
COUNT(*) as vehicle_count,
|
||||||
|
SUM(t.annual_mileage_per_vehicle * y.year_number) as target_mileage,
|
||||||
|
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) as completed_mileage,
|
||||||
|
SUM(GREATEST(t.annual_mileage_per_vehicle * y.year_number - v.current_mileage, 0)) as remaining_mileage,
|
||||||
|
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number))
|
||||||
|
/ NULLIF(SUM(t.annual_mileage_per_vehicle * y.year_number), 0) as completion_rate,
|
||||||
|
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number THEN 1 ELSE 0 END) as qualified_count,
|
||||||
|
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number * 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
|
||||||
|
DATE_FORMAT(MIN(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR)), '%Y-%m-%d') as start_date,
|
||||||
|
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as end_date
|
||||||
|
FROM tab_mileage_assessment_vehicle v
|
||||||
|
JOIN tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
|
||||||
|
JOIN (
|
||||||
|
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
|
||||||
|
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
|
||||||
|
WHERE v.is_deleted = 0
|
||||||
|
GROUP BY v.target_id, y.year_number
|
||||||
|
ORDER BY v.target_id, y.year_number
|
||||||
|
`) as [any[], unknown];
|
||||||
|
|
||||||
|
const yearlyMap = new Map<number, any[]>();
|
||||||
|
for (const row of yearlyRows) {
|
||||||
|
const list = yearlyMap.get(row.target_id) || [];
|
||||||
|
list.push(row);
|
||||||
|
yearlyMap.set(row.target_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [yearlyPeriodRows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
v.target_id,
|
||||||
|
y.year_number,
|
||||||
|
DATE_FORMAT(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR), '%Y-%m-%d') as start_date,
|
||||||
|
DATE_FORMAT(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY), '%Y-%m-%d') as end_date,
|
||||||
|
COUNT(*) as cnt
|
||||||
|
FROM tab_mileage_assessment_vehicle v
|
||||||
|
JOIN tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
|
||||||
|
JOIN (
|
||||||
|
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
|
||||||
|
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
|
||||||
|
WHERE v.is_deleted = 0
|
||||||
|
GROUP BY v.target_id, y.year_number, v.assessment_start_date
|
||||||
|
ORDER BY v.target_id, y.year_number, v.assessment_start_date
|
||||||
|
`) as [any[], unknown];
|
||||||
|
|
||||||
|
const yearlyPeriodsMap = new Map<string, string[]>();
|
||||||
|
for (const row of yearlyPeriodRows) {
|
||||||
|
const key = `${row.target_id}-${row.year_number}`;
|
||||||
|
const list = yearlyPeriodsMap.get(key) || [];
|
||||||
|
list.push(`${row.start_date} ~ ${row.end_date} (${row.cnt}台)`);
|
||||||
|
yearlyPeriodsMap.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
const [periodRows] = await pool.execute(`
|
const [periodRows] = await pool.execute(`
|
||||||
SELECT target_id,
|
SELECT target_id,
|
||||||
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
|
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
|
||||||
@@ -71,12 +148,44 @@ app.get('/', async (c) => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const result = targets.map((t: any) => {
|
const result = targets.map((t: any) => {
|
||||||
const s = statsMap.get(t.id) || {};
|
const s = statsMap.get(t.id) || {};
|
||||||
|
const fy = firstYearMap.get(t.id) || {};
|
||||||
const currentYearTarget = Number(s.current_year_target) || 0;
|
const currentYearTarget = Number(s.current_year_target) || 0;
|
||||||
const currentYearCompleted = Number(s.current_year_completed) || 0;
|
const currentYearCompleted = Number(s.current_year_completed) || 0;
|
||||||
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
|
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
|
||||||
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
|
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
|
||||||
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
|
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
|
||||||
const dailyTarget = remaining / daysLeft;
|
const dailyTarget = remaining / daysLeft;
|
||||||
|
const firstYearEnd = fy.first_year_end_date ? new Date(fy.first_year_end_date) : now;
|
||||||
|
const firstYearDaysLeft = Math.max(0, Math.ceil((firstYearEnd.getTime() - now.getTime()) / 86400000));
|
||||||
|
const firstYearRemaining = Number(fy.first_year_remaining) || 0;
|
||||||
|
const firstYearVehicleCount = Number(fy.first_year_total) || 0;
|
||||||
|
const firstYearQualifiedCount = Number(fy.first_year_qualified_count) || 0;
|
||||||
|
const yearlyAssessments = (yearlyMap.get(t.id) || []).map((row: any) => {
|
||||||
|
const vehicleCount = Number(row.vehicle_count) || 0;
|
||||||
|
const qualifiedCount = Number(row.qualified_count) || 0;
|
||||||
|
const remainingMileage = Number(row.remaining_mileage) || 0;
|
||||||
|
const endDate = row.end_date ? new Date(row.end_date) : now;
|
||||||
|
const assessmentDaysLeft = Math.max(0, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
|
||||||
|
const yearNumber = Number(row.year_number) || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
yearNumber,
|
||||||
|
label: `第${yearNumber}年`,
|
||||||
|
vehicleCount,
|
||||||
|
target: Number(row.target_mileage) || 0,
|
||||||
|
completed: Number(row.completed_mileage) || 0,
|
||||||
|
remaining: remainingMileage,
|
||||||
|
completionRate: (Number(row.completion_rate) || 0) * 100,
|
||||||
|
qualifiedCount,
|
||||||
|
qualifiedRate: vehicleCount > 0 ? (qualifiedCount / vehicleCount) * 100 : 0,
|
||||||
|
halfQualifiedCount: Number(row.half_qualified_count) || 0,
|
||||||
|
daysLeft: assessmentDaysLeft,
|
||||||
|
dailyTarget: assessmentDaysLeft > 0 ? Math.round((remainingMileage / assessmentDaysLeft) * 10) / 10 : 0,
|
||||||
|
startDate: row.start_date || null,
|
||||||
|
endDate: row.end_date || null,
|
||||||
|
periods: yearlyPeriodsMap.get(`${row.target_id}-${row.year_number}`) || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const periods = periodsMap.get(t.id) || [];
|
const periods = periodsMap.get(t.id) || [];
|
||||||
if (periods.length === 0) {
|
if (periods.length === 0) {
|
||||||
@@ -104,6 +213,19 @@ app.get('/', async (c) => {
|
|||||||
remaining,
|
remaining,
|
||||||
daysLeft,
|
daysLeft,
|
||||||
dailyTarget: Math.round(dailyTarget * 10) / 10,
|
dailyTarget: Math.round(dailyTarget * 10) / 10,
|
||||||
|
firstYearVehicleCount,
|
||||||
|
firstYearTarget: Number(fy.first_year_target) || 0,
|
||||||
|
firstYearCompleted: Number(fy.first_year_completed) || 0,
|
||||||
|
firstYearRemaining,
|
||||||
|
firstYearCompletionRate: (Number(fy.first_year_completion_rate) || 0) * 100,
|
||||||
|
firstYearQualifiedCount,
|
||||||
|
firstYearQualifiedRate: firstYearVehicleCount > 0 ? (firstYearQualifiedCount / firstYearVehicleCount) * 100 : 0,
|
||||||
|
firstYearHalfQualifiedCount: Number(fy.first_year_half_qualified_count) || 0,
|
||||||
|
firstYearDaysLeft,
|
||||||
|
firstYearDailyTarget: firstYearDaysLeft > 0 ? Math.round((firstYearRemaining / firstYearDaysLeft) * 10) / 10 : 0,
|
||||||
|
firstYearStartDate: fy.first_year_start_date || null,
|
||||||
|
firstYearEndDate: fy.first_year_end_date || null,
|
||||||
|
yearlyAssessments,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface CachedVehicle {
|
|||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
|
targetNames: string[];
|
||||||
yesterdayKm: number;
|
yesterdayKm: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user