Compare commits
1 Commits
main
...
dev/local-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dc45504f2 |
@@ -10,16 +10,6 @@ services:
|
||||
DB_USER: "root"
|
||||
DB_PASSWORD: "LN#Passw0rd@2026"
|
||||
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"
|
||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>羚牛氢能</title>
|
||||
<title>羚牛氢能车辆资产</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
165
src/App.tsx
165
src/App.tsx
@@ -1,145 +1,60 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt } from "lucide-react";
|
||||
import { Shell, type ModuleConfig } from "./components/Shell";
|
||||
import AssetsModule from "./modules/assets/AssetsModule";
|
||||
import MileageModule from "./modules/mileage/MileageModule";
|
||||
import SchedulingModule from "./modules/scheduling/SchedulingModule";
|
||||
import HydrogenModule from "./modules/energy/HydrogenModule";
|
||||
import ElectricModule from "./modules/energy/ElectricModule";
|
||||
import EtcModule from "./modules/energy/EtcModule";
|
||||
import EleImportPage from "./modules/ele/EleImportPage";
|
||||
import FeedbackAdminPage from "./modules/admin/FeedbackAdminPage";
|
||||
import AuthProvider from "./auth/AuthProvider";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
||||
import { Shell, type ModuleConfig } from './components/Shell';
|
||||
import AssetsModule from './modules/assets/AssetsModule';
|
||||
import MileageModule from './modules/mileage/MileageModule';
|
||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
||||
import EnergyModule from './modules/energy/EnergyModule';
|
||||
import EleImportPage from './modules/ele/EleImportPage';
|
||||
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
|
||||
import AuthProvider from './auth/AuthProvider';
|
||||
import { useAuth } from './auth/useAuth';
|
||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
||||
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
|
||||
|
||||
const ASSETS_MODULE: ModuleConfig = {
|
||||
id: "assets",
|
||||
label: "资产管理",
|
||||
icon: Truck,
|
||||
component: AssetsModule,
|
||||
};
|
||||
const BASE_MODULES: ModuleConfig[] = [
|
||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||
];
|
||||
|
||||
const MILEAGE_MODULE: ModuleConfig = {
|
||||
id: "mileage",
|
||||
label: "里程管理",
|
||||
icon: Route,
|
||||
component: MileageModule,
|
||||
const ENERGY_MODULE: ModuleConfig = {
|
||||
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
|
||||
};
|
||||
|
||||
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 {
|
||||
if (typeof window === "undefined") return "";
|
||||
if (typeof window === 'undefined') return '';
|
||||
const path = window.location.pathname;
|
||||
const hash = window.location.hash;
|
||||
if (
|
||||
path === "/ele/import" ||
|
||||
hash === "#/ele/import" ||
|
||||
hash === "#ele/import"
|
||||
)
|
||||
return "ele/import";
|
||||
if (
|
||||
path === "/admin/feedback" ||
|
||||
hash === "#/admin/feedback" ||
|
||||
hash === "#admin/feedback"
|
||||
)
|
||||
return "admin/feedback";
|
||||
return "";
|
||||
if (path === '/ele/import' || 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() {
|
||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||
const [routeKey, setRouteKey] = useState(getRouteKey);
|
||||
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
|
||||
|
||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
|
||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
setRouteKey(getRouteKey());
|
||||
setPathSet(getPathSet());
|
||||
};
|
||||
window.addEventListener("hashchange", update);
|
||||
window.addEventListener("popstate", update);
|
||||
const update = () => setRouteKey(getRouteKey());
|
||||
window.addEventListener('hashchange', update);
|
||||
window.addEventListener('popstate', update);
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", update);
|
||||
window.removeEventListener("popstate", update);
|
||||
window.removeEventListener('hashchange', update);
|
||||
window.removeEventListener('popstate', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||
}, [pathSet]);
|
||||
|
||||
const modules = useMemo<ModuleConfig[]>(() => {
|
||||
if (pathSet === "energy") {
|
||||
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
|
||||
}
|
||||
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE];
|
||||
const modules = useMemo(() => {
|
||||
const result = [...BASE_MODULES];
|
||||
if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
|
||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
||||
return result;
|
||||
}, [pathSet, user?.roles]);
|
||||
}, [user?.roles]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -157,16 +72,10 @@ function AuthGate() {
|
||||
}
|
||||
|
||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||
if (routeKey === "ele/import") return <EleImportPage />;
|
||||
if (routeKey === "admin/feedback") return <FeedbackAdminPage />;
|
||||
if (routeKey === 'ele/import') return <EleImportPage />;
|
||||
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
|
||||
|
||||
// /energy 整组按能源权限控制
|
||||
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
|
||||
return <UnauthorizedPage message="无能源管理模块访问权限" />;
|
||||
}
|
||||
|
||||
// key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule
|
||||
return <Shell key={pathSet} modules={modules} />;
|
||||
return <Shell modules={modules} />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
|
||||
@@ -82,7 +82,19 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const jumpToken = params.get('jumpToken');
|
||||
|
||||
if (!jumpToken) {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' });
|
||||
// 临时:本地开发免登录,含智能调度权限
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
userId: '1105261382487539712',
|
||||
userName: '本地调试',
|
||||
permissionLevel: 'full',
|
||||
depName: '',
|
||||
roles: ['BI-SCHEDULE-OPT'],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,20 +10,29 @@ export interface ModuleConfig {
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||||
function getHashHead(): string {
|
||||
return window.location.hash.slice(1).split('/')[0];
|
||||
}
|
||||
/** path 到模块 id 的映射 */
|
||||
const PATH_MAP: Record<string, string> = {
|
||||
'/vehicle': 'assets',
|
||||
'/assets': 'assets',
|
||||
'/mileage': 'mileage',
|
||||
'/scheduling': 'scheduling',
|
||||
'/energy': 'energy',
|
||||
};
|
||||
|
||||
function getInitialModule(modules: ModuleConfig[]): string {
|
||||
const head = getHashHead();
|
||||
if (modules.some((m) => m.id === head)) return head;
|
||||
// 优先看 hash
|
||||
const hash = window.location.hash.slice(1);
|
||||
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 ?? '';
|
||||
}
|
||||
|
||||
function getHashModule(modules: ModuleConfig[]): string {
|
||||
const head = getHashHead();
|
||||
return modules.some((m) => m.id === head) ? head : '';
|
||||
const hash = window.location.hash.slice(1);
|
||||
return modules.some((m) => m.id === hash) ? hash : '';
|
||||
}
|
||||
|
||||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
@@ -39,17 +48,16 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
}, [modules]);
|
||||
|
||||
useEffect(() => {
|
||||
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
|
||||
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
|
||||
if (getHashHead() !== activeModule) {
|
||||
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
|
||||
if (window.location.hash.slice(1) !== activeModule) {
|
||||
const { pathname, search } = window.location;
|
||||
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
||||
}
|
||||
}, [activeModule]);
|
||||
|
||||
const switchModule = (id: string) => {
|
||||
if (getHashHead() === id) return;
|
||||
if (window.location.hash.slice(1) === id) return;
|
||||
const { pathname, search } = window.location;
|
||||
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
||||
setActiveModule(id);
|
||||
|
||||
@@ -14,12 +14,3 @@ body {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@utility animate-marquee {
|
||||
animation: marquee 30s linear infinite;
|
||||
}
|
||||
|
||||
@@ -46,42 +46,6 @@ const TABS = [
|
||||
{ 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() {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
|
||||
const [tabReady, setTabReady] = useState(true);
|
||||
@@ -128,7 +92,7 @@ export default function AssetsModule() {
|
||||
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdate] = useState<string>('2026-06-03 23:59:59');
|
||||
const [lastUpdate, setLastUpdate] = useState<string>('');
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
|
||||
// Dept/Region/Customer data
|
||||
@@ -198,6 +162,7 @@ export default function AssetsModule() {
|
||||
setRegionData(region);
|
||||
setCustomerData(cust);
|
||||
setInventoryData(inv);
|
||||
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '数据加载失败');
|
||||
} finally {
|
||||
@@ -561,7 +526,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">
|
||||
{/* Title row */}
|
||||
<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">羚牛氢能-资产BI</h1>
|
||||
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能车辆资产</h1>
|
||||
{/* Right: status + theme */}
|
||||
<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">
|
||||
@@ -725,15 +690,12 @@ export default function AssetsModule() {
|
||||
最后更新: {lastUpdate}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-amber-400 inline-block" />
|
||||
OneOS数据源接入中
|
||||
<span className="w-1 h-1 rounded-full bg-green-400 animate-pulse inline-block" />
|
||||
每分钟更新一次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OneOS 迁移提示滚动条 */}
|
||||
<MarqueeBanner />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
86
src/modules/energy/EnergyModule.tsx
Normal file
86
src/modules/energy/EnergyModule.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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,12 +12,6 @@ import PlateMultiSelect from './PlateMultiSelect';
|
||||
import { exportMileageXlsx } from './xlsx-export';
|
||||
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 = ({
|
||||
options,
|
||||
value,
|
||||
@@ -143,12 +137,6 @@ export default function MonitoringView() {
|
||||
const departments = filterOptions.departments;
|
||||
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(() => {
|
||||
setPageLoading(true);
|
||||
@@ -537,9 +525,7 @@ export default function MonitoringView() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/30">
|
||||
{fullscreenVehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
return (
|
||||
{fullscreenVehicles.map((v) => (
|
||||
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
|
||||
<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>
|
||||
@@ -549,8 +535,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.department || '-'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<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] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? '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>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
@@ -559,8 +545,7 @@ export default function MonitoringView() {
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -596,7 +581,7 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
<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="text-[9px] font-bold text-slate-400 uppercase tracking-tight">数据监控 • 每15分钟更新</span>
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight">实时监控 • 每分钟更新</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -911,9 +896,7 @@ export default function MonitoringView() {
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
{filteredVehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
return (
|
||||
{filteredVehicles.map((v) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -947,8 +930,8 @@ export default function MonitoringView() {
|
||||
<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>
|
||||
<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] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>km</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
||||
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? '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>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -959,8 +942,7 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredVehicles.length === 0 && !loadingMore && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Truck, ChevronDown, Maximize2, Minimize2,
|
||||
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
||||
} from 'lucide-react';
|
||||
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
||||
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
@@ -19,31 +19,11 @@ function getDefaultDate(): string {
|
||||
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 {
|
||||
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
||||
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 {
|
||||
// Extract the number and a short description
|
||||
const match = name.match(/(\d+)[辆台](.+)/);
|
||||
@@ -59,7 +39,6 @@ function shortTargetName(name: string): string {
|
||||
}
|
||||
|
||||
export default function StatisticsView() {
|
||||
const currentDateLabel = getCurrentDateLabel();
|
||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||
@@ -67,8 +46,7 @@ export default function StatisticsView() {
|
||||
|
||||
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
||||
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
||||
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
|
||||
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
|
||||
const [expandedModel, setExpandedModel] = useState<string | null>(null);
|
||||
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
|
||||
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
||||
const [viewAllSearch, setViewAllSearch] = useState('');
|
||||
@@ -76,25 +54,12 @@ export default function StatisticsView() {
|
||||
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
||||
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
|
||||
useEffect(() => {
|
||||
fetchTargets().then(data => {
|
||||
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
||||
const ordered = focused
|
||||
? [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(() => {});
|
||||
setTargets(data);
|
||||
if (data.length > 0 && !selectedTargetId) {
|
||||
setSelectedTargetId(data[0].id);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
@@ -115,7 +80,7 @@ export default function StatisticsView() {
|
||||
}, [viewAllTargetId, viewAllDate]);
|
||||
|
||||
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 [overflow-anchor:none]" 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" style={{ overflowX: 'clip' }}>
|
||||
{/* 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">
|
||||
{targets.map(target => (
|
||||
@@ -138,7 +103,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">
|
||||
{/* KPI Cards in Landscape — linked to selected target */}
|
||||
{(() => {
|
||||
const sel = selectedTarget;
|
||||
const sel = targets.find(t => t.id === selectedTargetId);
|
||||
return (
|
||||
<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">
|
||||
@@ -165,7 +130,7 @@ export default function StatisticsView() {
|
||||
<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-lg font-black text-slate-900 tracking-tighter">
|
||||
{selectedCompletion.toFixed(1)}
|
||||
{(sel?.avgCompletion ?? 0).toFixed(1)}
|
||||
<span className="text-blue-500 text-[10px] ml-1">%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,17 +224,12 @@ export default function StatisticsView() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
|
||||
{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
|
||||
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"
|
||||
onClick={() => {
|
||||
setExpandedTargetId(expandedTargetId === target.id ? null : target.id);
|
||||
const name = target.targetName;
|
||||
setExpandedModel(expandedModel === name ? null : name);
|
||||
if (!targetVehiclesMap[target.id]) {
|
||||
fetchTargetVehicles(target.id).then(data => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
|
||||
@@ -289,12 +249,12 @@ export default function StatisticsView() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
|
||||
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
|
||||
<span className="text-[9px] text-slate-400">完成率:</span>
|
||||
<span className={`text-[9px] font-bold ${target.avgCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{target.avgCompletion.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}台</span>
|
||||
<span className="text-[9px] text-slate-400">达标:</span>
|
||||
<span className="text-[9px] font-bold text-slate-600">{target.yearQualifiedCount}台</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,7 +269,7 @@ export default function StatisticsView() {
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: expandedTargetId === target.id ? 180 : 0 }}
|
||||
animate={{ rotate: expandedModel === target.targetName ? 180 : 0 }}
|
||||
className="text-slate-300"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
@@ -317,103 +277,52 @@ export default function StatisticsView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{expandedTargetId === target.id && (
|
||||
<AnimatePresence>
|
||||
{expandedModel === target.targetName && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
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="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
|
||||
<span className="text-[10px] font-black text-blue-700">考核年度</span>
|
||||
<select
|
||||
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 className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">考核区间</p>
|
||||
{target.periods.map((p, i) => (
|
||||
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核区间</p>
|
||||
{target.periods.map((p, i) => (
|
||||
<p key={i} className="text-[10px] font-black text-slate-700">{p}</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-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} 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-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} 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-slate-700">{fmtKm(target.annualMileagePerVehicle)} km</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%达标数</p>
|
||||
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} 台</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-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>
|
||||
{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">
|
||||
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
|
||||
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} 天</span>
|
||||
<span className="text-[9px] font-bold text-slate-500">剩余考核天数</span>
|
||||
<span className="text-[10px] font-black text-slate-900">{target.daysLeft} 天</span>
|
||||
</div>
|
||||
|
||||
{/* Vehicle List Detail */}
|
||||
@@ -454,8 +363,6 @@ export default function StatisticsView() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -484,7 +391,7 @@ export default function StatisticsView() {
|
||||
<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-700">|</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -510,12 +417,12 @@ export default function StatisticsView() {
|
||||
<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 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 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-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-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-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-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>
|
||||
@@ -523,22 +430,10 @@ export default function StatisticsView() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/30">
|
||||
{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 (
|
||||
{targets.map((target, idx) => (
|
||||
<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">
|
||||
<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>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
|
||||
@@ -546,31 +441,28 @@ export default function StatisticsView() {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${completion >= 90 ? 'bg-emerald-500' : completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
||||
style={{ width: `${Math.min(completion, 100)}%` }}
|
||||
className={`h-full rounded-full ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
||||
style={{ width: `${Math.min(target.avgCompletion, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-white w-10 text-right">{completion.toFixed(1)}%</span>
|
||||
<span className="text-[10px] font-black text-white w-10 text-right">{target.avgCompletion.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
|
||||
<span>{fmtKm(completed)}</span>
|
||||
<span>/ {fmtKm(goal)} km</span>
|
||||
<span>{fmtKm(target.cumulativeTotal)}</span>
|
||||
<span>/ {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</span>
|
||||
</div>
|
||||
</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">{qualified}</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 text-slate-300 text-right">{fmtKm(target.annualMileagePerVehicle)} 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-blue-400 text-center">{target.halfQualifiedCount}</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(goal)} 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(remainingMileage)} km</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">
|
||||
{assessment && days === 0 ? '考核已到期' : `${fmtKm(daily)} 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-emerald-400/80 text-right">{fmtKm(target.currentYearCompleted)} 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 text-slate-300 text-center">{target.daysLeft}</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">{fmtKm(target.dailyTarget)} km</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface MonitoringVehicle {
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
region: string | null;
|
||||
targetNames: string[];
|
||||
}
|
||||
|
||||
export interface MonitoringStats {
|
||||
@@ -64,37 +63,6 @@ export interface TargetSummary {
|
||||
remaining: number;
|
||||
daysLeft: 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 {
|
||||
|
||||
@@ -20,9 +20,9 @@ function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | nu
|
||||
if (kind === 'today') {
|
||||
// 当日未对接但有历史累计,视作今日 0;只有完全无数据才标「未对接」
|
||||
if (!v.isDataSynced && v.totalKm == null) return '未对接';
|
||||
return Math.max(0, v.dailyKm || 0);
|
||||
return Math.max(0, Math.round(v.dailyKm || 0));
|
||||
}
|
||||
return v.totalKm != null ? v.totalKm : '未对接';
|
||||
return v.totalKm != null ? Math.round(v.totalKm) : '未对接';
|
||||
}
|
||||
|
||||
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
||||
@@ -57,13 +57,6 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
|
||||
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 社区版仅基本样式生效)
|
||||
for (let c = 0; c < HEADERS.length; c++) {
|
||||
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { JwtPayload, AuthUser } from './types.js';
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
||||
|
||||
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
|
||||
const BYPASS_AUTH = false;
|
||||
// 临时:本地开发跳过认证
|
||||
const BYPASS_AUTH = true;
|
||||
|
||||
export async function authMiddleware(c: Context, next: Next) {
|
||||
const path = c.req.path;
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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,14 +1,11 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const mileagePool = mysql.createPool({
|
||||
host: process.env.MILEAGE_DB_HOST || '101.133.130.65',
|
||||
port: Number(process.env.MILEAGE_DB_PORT) || 3306,
|
||||
user: process.env.MILEAGE_DB_USER || 'bi_reader_02',
|
||||
password: process.env.MILEAGE_DB_PASSWORD || 'bi_reader_02_Pass',
|
||||
database: process.env.MILEAGE_DB_NAME || 'hydrogen_energy',
|
||||
host: '101.133.130.65',
|
||||
port: 3306,
|
||||
user: 'bi_reader_02',
|
||||
password: 'bi_reader_02_Pass',
|
||||
database: 'hydrogen_energy',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
queueLimit: 0,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db.js';
|
||||
import hydrogenPool from '../../hydrogen-db.js';
|
||||
import { cached } from './cache.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { canAccessEnergy } from '../../auth/types.js';
|
||||
@@ -20,19 +19,16 @@ app.use('*', async (c, next) => {
|
||||
|
||||
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||||
|
||||
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
|
||||
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'`;
|
||||
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
|
||||
const HYDROGEN_LOCAL = `hydrogen_time`;
|
||||
const ELECTRIC_LOCAL = `charging_start_time`;
|
||||
|
||||
type CustomerKind = 'external' | 'lingniu' | 'all';
|
||||
|
||||
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
|
||||
function customerClause(customer: CustomerKind): string {
|
||||
if (customer === 'external') return '1=0';
|
||||
if (customer === 'lingniu') return '1=1';
|
||||
// 外部/我司判定:truck_id 为空 = 外部;truck_id 非空 = 我司(羚牛车辆)
|
||||
function customerClause(field: string, customer: CustomerKind): string {
|
||||
if (customer === 'external') return `${field} IS NULL`;
|
||||
if (customer === 'lingniu') return `${field} IS NOT NULL`;
|
||||
return '1=1';
|
||||
}
|
||||
|
||||
@@ -84,10 +80,10 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
|
||||
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
||||
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
||||
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
const [yearListRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?
|
||||
ORDER BY y DESC`,
|
||||
[HYDROGEN_MIN_DATE],
|
||||
);
|
||||
@@ -96,46 +92,44 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
const isCurrentYear = year === todayYear;
|
||||
|
||||
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
||||
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
THEN amount_kg ELSE 0 END) AS yearKg,
|
||||
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
THEN cost_total ELSE 0 END) AS yearFee,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||
THEN cost_total ELSE 0 END) AS yearCustomerCost,
|
||||
THEN cost_expense ELSE 0 END) AS yearFee,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||||
THEN cost_expense ELSE 0 END) AS yearCustomerCost,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
THEN fee_total ELSE 0 END) AS yearRevenue,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
||||
THEN amount_kg ELSE 0 END) AS ourYearKg,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
||||
THEN cost_total ELSE 0 END) AS ourYearFee,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||
THEN amount_kg ELSE 0 END) AS customerYearKg,
|
||||
THEN customer_expense ELSE 0 END) AS yearRevenue,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||||
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||||
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||||
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN amount_kg ELSE 0 END) AS monthKg,
|
||||
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
|
||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN cost_total ELSE 0 END) AS monthFee,
|
||||
THEN cost_expense 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')
|
||||
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,
|
||||
THEN customer_expense ELSE 0 END) AS monthRevenue,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||
THEN amount_kg ELSE 0 END) AS todayKg,
|
||||
THEN hydrogen_quantity ELSE 0 END) AS todayKg,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||
THEN cost_total ELSE 0 END) AS todayFee,
|
||||
THEN cost_expense 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()
|
||||
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||
THEN cost_total ELSE 0 END) AS todayCustomerCost,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||
THEN fee_total ELSE 0 END) AS todayRevenue,
|
||||
SUM(CASE WHEN vehicle_id IS NOT NULL
|
||||
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
|
||||
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} >= ?`,
|
||||
THEN customer_expense ELSE 0 END) AS todayRevenue,
|
||||
SUM(CASE WHEN truck_id IS NOT NULL
|
||||
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
||||
SUM(CASE WHEN truck_id IS NOT NULL
|
||||
THEN cost_expense ELSE 0 END) AS lingniuBornFee
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
|
||||
[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,
|
||||
@@ -172,19 +166,23 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
};
|
||||
|
||||
// Top5 加氢站(指定年份)
|
||||
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.station_id AS id,
|
||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||||
SUM(b.amount_kg) AS kg,
|
||||
SUM(b.cost_total) AS fee
|
||||
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}
|
||||
const [top5Rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT b.hydrogen_station_id AS id,
|
||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||
MAX(i.hydrogen_station_name),
|
||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
||||
SUM(b.hydrogen_quantity) AS kg,
|
||||
SUM(b.cost_expense) AS fee
|
||||
FROM tab_energy_hydrogen_bill 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 YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY b.station_id
|
||||
GROUP BY b.hydrogen_station_id
|
||||
ORDER BY kg DESC
|
||||
LIMIT 5`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
@@ -199,19 +197,23 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
}));
|
||||
|
||||
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
||||
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.station_id AS id,
|
||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||||
SUM(b.amount_kg) AS kg,
|
||||
SUM(b.fee_total) AS revenue
|
||||
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}
|
||||
const [stationFullRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT b.hydrogen_station_id AS id,
|
||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||
MAX(i.hydrogen_station_name),
|
||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
||||
SUM(b.hydrogen_quantity) AS kg,
|
||||
SUM(b.customer_expense) AS revenue
|
||||
FROM tab_energy_hydrogen_bill 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 YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY b.station_id
|
||||
GROUP BY b.hydrogen_station_id
|
||||
ORDER BY kg DESC`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
);
|
||||
@@ -226,22 +228,14 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
}));
|
||||
|
||||
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
||||
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
const [regionRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT region, SUM(kg) AS kg FROM (
|
||||
SELECT CASE
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR 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 '佛山'
|
||||
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 '乌鲁木齐'
|
||||
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}
|
||||
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
|
||||
b.hydrogen_quantity AS kg
|
||||
FROM tab_energy_hydrogen_bill 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
|
||||
WHERE b.is_deleted = 0
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
) r
|
||||
@@ -263,15 +257,15 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
];
|
||||
|
||||
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
||||
// 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担)
|
||||
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
// 利润 = 客户单收入 - 客户单成本(仅 cost_type = 2)
|
||||
const [monthRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
||||
ROUND(SUM(amount_kg), 2) AS kg,
|
||||
ROUND(SUM(cost_total), 2) AS fee,
|
||||
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(fee_total), 2) AS revenue
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE}
|
||||
ROUND(SUM(hydrogen_quantity), 2) AS kg,
|
||||
ROUND(SUM(cost_expense), 2) AS fee,
|
||||
ROUND(SUM(CASE WHEN cost_type = 2 THEN cost_expense ELSE 0 END), 2) AS customerCost,
|
||||
ROUND(SUM(customer_expense), 2) AS revenue
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY m
|
||||
@@ -296,16 +290,16 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
}
|
||||
|
||||
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
||||
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
|
||||
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
// payer:cost_type=2 → 客户承担;cost_type=3 → 羚牛承担;其他 → 客户(默认)
|
||||
const [customerRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
||||
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
|
||||
CASE WHEN MAX(cost_type) = 3 AND MIN(cost_type) = 3 THEN 'lingniu'
|
||||
ELSE 'customer' END AS payer,
|
||||
SUM(amount_kg) AS kg,
|
||||
SUM(cost_total) AS cost,
|
||||
SUM(fee_total) AS revenue
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE}
|
||||
SUM(hydrogen_quantity) AS kg,
|
||||
SUM(cost_expense) AS cost,
|
||||
SUM(customer_expense) AS revenue
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY name
|
||||
@@ -337,28 +331,32 @@ app.get('/hydrogen/daily', async (c) => {
|
||||
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
||||
|
||||
const where = [
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
||||
rangeClause(`b.${HYDROGEN_LOCAL}`, range),
|
||||
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
||||
'b.is_deleted = 0',
|
||||
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
||||
rangeClause(`b.hydrogen_time`, range),
|
||||
customerClause('b.truck_id', customer),
|
||||
].join(' AND ');
|
||||
|
||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
||||
// 单价不重算:直接取账本成本价。
|
||||
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
|
||||
COALESCE(b.station_id, 0) AS stationId,
|
||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||
// 站点名 fallback:内部站表 → 外部站表 → 导入订单表(tab_import_hydrogen_order,按 bill_code 关联)
|
||||
// 单价不重算:同价组显示原价,混合价组返回 NULL,前端显示「—」
|
||||
const [stationRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.hydrogen_time, '%Y-%m-%d') AS d,
|
||||
b.hydrogen_station_id AS stationId,
|
||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||
MAX(i.hydrogen_station_name),
|
||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS stationName,
|
||||
ROUND(SUM(b.hydrogen_quantity), 2) AS kg,
|
||||
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
||||
MAX(b.cost_price) AS pricePerKg
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
FROM tab_energy_hydrogen_bill 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 ${where}
|
||||
GROUP BY d, COALESCE(b.station_id, 0)
|
||||
GROUP BY d, b.hydrogen_station_id
|
||||
ORDER BY d DESC, kg DESC`,
|
||||
);
|
||||
|
||||
@@ -416,7 +414,7 @@ app.get('/hydrogen/daily', async (c) => {
|
||||
date,
|
||||
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
||||
chainPct: dayChainPct.get(date) ?? 0,
|
||||
customerType: customer,
|
||||
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
||||
stations: info
|
||||
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
||||
name: s.name,
|
||||
|
||||
@@ -65,41 +65,6 @@ interface MileageRow {
|
||||
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>> {
|
||||
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULL(G7 只回传日增量),
|
||||
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
|
||||
@@ -150,7 +115,6 @@ function mergeVehicles(
|
||||
yesterdayMap: Map<string, number>,
|
||||
bizTotalMap: Map<string, number>,
|
||||
latestPgTotalMap: Map<string, number>,
|
||||
targetNamesByPlate: Map<string, string[]>,
|
||||
): CachedVehicle[] {
|
||||
const mileageMap = new Map<string, MileageRow>();
|
||||
for (const row of mileageRows) {
|
||||
@@ -183,7 +147,6 @@ function mergeVehicles(
|
||||
entity: info?.entity || null,
|
||||
project: info?.project || null,
|
||||
region: regionMap[m.plate] || null,
|
||||
targetNames: targetNamesByPlate.get(m.plate) || [],
|
||||
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
||||
};
|
||||
});
|
||||
@@ -221,16 +184,25 @@ export async function refreshMonitoringCache(): Promise<void> {
|
||||
return map;
|
||||
})(),
|
||||
fetchVehicleInfoMap(),
|
||||
fetchTargetRows(),
|
||||
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 { id: number; target_name: string; plate_number: string }[]),
|
||||
fetchBizTotalMileageMap(),
|
||||
fetchLatestPgTotalMileageMap(),
|
||||
]);
|
||||
|
||||
const targetPlatesMap = buildTargetPlatesMap(targetRows);
|
||||
const targetNamesByPlate = buildPlateTargetNamesMap(targetRows);
|
||||
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);
|
||||
}
|
||||
const targetNames = Array.from(targetPlatesMap.keys());
|
||||
|
||||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap, targetNamesByPlate);
|
||||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap);
|
||||
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
|
||||
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
||||
|
||||
@@ -249,7 +221,7 @@ export async function refreshMonitoringCache(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
|
||||
const [mileageRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
||||
const [mileageRows, yesterdayRows, infoMap, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
||||
mileagePool.execute(
|
||||
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
|
||||
[dateStr]
|
||||
@@ -259,7 +231,6 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
|
||||
[dateStr]
|
||||
).then(([r]) => r as { plate: string; daily_km: string }[]),
|
||||
fetchVehicleInfoMap(),
|
||||
fetchTargetRows(),
|
||||
fetchBizTotalMileageMap(),
|
||||
fetchLatestPgTotalMileageMap(dateStr),
|
||||
]);
|
||||
@@ -271,14 +242,7 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
|
||||
if (km > existing) yesterdayMap.set(r.plate, km);
|
||||
}
|
||||
|
||||
return mergeVehicles(
|
||||
mileageRows,
|
||||
infoMap,
|
||||
yesterdayMap,
|
||||
bizTotalMap,
|
||||
latestPgTotalMap,
|
||||
buildPlateTargetNamesMap(targetRows),
|
||||
);
|
||||
return mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap);
|
||||
}
|
||||
|
||||
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
||||
|
||||
@@ -32,83 +32,6 @@ app.get('/', async (c) => {
|
||||
const statsMap = new Map<number, any>();
|
||||
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(`
|
||||
SELECT target_id,
|
||||
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
|
||||
@@ -148,44 +71,12 @@ app.get('/', async (c) => {
|
||||
const now = new Date();
|
||||
const result = targets.map((t: any) => {
|
||||
const s = statsMap.get(t.id) || {};
|
||||
const fy = firstYearMap.get(t.id) || {};
|
||||
const currentYearTarget = Number(s.current_year_target) || 0;
|
||||
const currentYearCompleted = Number(s.current_year_completed) || 0;
|
||||
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
|
||||
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 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) || [];
|
||||
if (periods.length === 0) {
|
||||
@@ -213,19 +104,6 @@ app.get('/', async (c) => {
|
||||
remaining,
|
||||
daysLeft,
|
||||
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,7 +15,6 @@ export interface CachedVehicle {
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
region: string | null;
|
||||
targetNames: string[];
|
||||
yesterdayKm: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user