28 Commits

Author SHA1 Message Date
lingniu
a558db5795 Polish mobile BI filters and summaries
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-27 22:43:43 +08:00
lingniu
b0caa5afcb feat: polish BI dashboards and bump version
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-27 21:59:33 +08:00
lingniu
5377d2c225 chore: ignore local worktrees 2026-06-23 16:09:54 +08:00
kkfluous
654573ac4b merge palladium 18t color variant
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-23 13:32:29 +08:00
kkfluous
43f94ed1b8 fix asset vehicle source grouping
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-23 13:16:32 +08:00
kkfluous
fb5789d705 update asset refresh status
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-18 14:18:40 +08:00
kkfluous
94a6e0a75e update asset validation banner
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-18 14:04:27 +08:00
kkfluous
67c5f9d281 support mileage batch multi-select
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-18 10:18:35 +08:00
kkfluous
91202bdf71 fix asset module database migration
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
2026-06-17 11:53:39 +08:00
kkfluous
c13f341d5e feat(ui): 页面标题区分能源BI与资产BI,资产页增加OneOS迁移提示滚动条
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-11 15:30:21 +08:00
kkfluous
6962c4ff1c feat(db): hydrogen 和 mileage 数据库连接支持 Docker 环境变量注入
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
mileage-db.ts 改为从环境变量读取,硬编码值作为 fallback;
docker-compose.yml 新增加氢站库和里程库的连接参数。
2026-06-09 17:18:32 +08:00
kkfluous
6b7f0eedd9 Revert "fix(mileage): 已到期年度按期末里程统计"
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This reverts commit feb950dd59.
2026-06-03 15:40:13 +08:00
kkfluous
5bb3ceb47a fix(mileage): 交投高里程车辆标红
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-03 15:10:36 +08:00
kkfluous
cc778f3701 fix(mileage): 导出保留里程小数
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-03 14:54:30 +08:00
kkfluous
74d6efe261 Revert "fix(mileage): 区分年度累计里程和计入完成"
This reverts commit 3f0edfaaf5.
2026-06-03 14:53:54 +08:00
kkfluous
a124e31fab Revert "fix(mileage): 未到期年度累计对齐实时监控"
This reverts commit a3dfe7ab8c.
2026-06-03 14:53:54 +08:00
kkfluous
a3dfe7ab8c fix(mileage): 未到期年度累计对齐实时监控
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-03 11:32:50 +08:00
kkfluous
3f0edfaaf5 fix(mileage): 区分年度累计里程和计入完成
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-03 11:18:48 +08:00
kkfluous
feb950dd59 fix(mileage): 已到期年度按期末里程统计
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-03 11:13:40 +08:00
kkfluous
5e1c12eba2 fix(mileage): 减少车型展开时页面抖动
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-02 16:01:47 +08:00
kkfluous
ae24bc7647 fix(mileage): 已到期年度显示考核期末日期
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-02 15:50:35 +08:00
kkfluous
0a372e4290 fix(mileage): 消除年度完成日期歧义
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-02 15:48:01 +08:00
kkfluous
1e08d1ea62 fix(mileage): 标注年度已进入车辆数
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-02 15:34:25 +08:00
kkfluous
2d82918d73 fix(mileage): 年度考核区间按批次展示
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-02 15:33:08 +08:00
kkfluous
482243e052 feat(mileage): 支持车型按考核年度查看
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-02 15:30:13 +08:00
kkfluous
f1a69c8271 Correct hydrogen daily vehicle split
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-28 22:19:08 +08:00
kkfluous
1d2c3a0cd5 Switch hydrogen BI to ledger data source
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-28 16:47:43 +08:00
lnljyang
e7ba5315e1 拆分菜单 通过url区分访问
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-14 17:44:04 +08:00
49 changed files with 3870 additions and 1187 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules node_modules
dist dist
.env .env
.worktrees

View File

@@ -5,11 +5,21 @@ services:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0 image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host network_mode: host
environment: environment:
DB_HOST: "47.101.148.99" DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
DB_PORT: "3306" DB_PORT: "3306"
DB_USER: "root" DB_USER: "oneos_db_prod"
DB_PASSWORD: "LN#Passw0rd@2026" DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
DB_NAME: "lingniu_prod" DB_NAME: "ln_asset_management"
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"

View File

@@ -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>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.5", "version": "1.1.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.5", "version": "1.1.6",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",

View File

@@ -1,7 +1,7 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"private": true, "private": true,
"version": "1.1.5", "version": "1.1.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",

View File

@@ -1,67 +1,165 @@
import { useEffect, useMemo, useState } from 'react'; import { lazy, Suspense, 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 AuthProvider from "./auth/AuthProvider";
import MileageModule from './modules/mileage/MileageModule'; import { useAuth } from "./auth/useAuth";
import SchedulingModule from './modules/scheduling/SchedulingModule'; import UnauthorizedPage from "./auth/UnauthorizedPage";
import EnergyModule from './modules/energy/EnergyModule'; import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
import EleImportPage from './modules/ele/EleImportPage'; import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
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 BASE_MODULES: ModuleConfig[] = [ const AssetsModule = lazy(() => import("./modules/assets/AssetsModule"));
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule }, const MileageModule = lazy(() => import("./modules/mileage/MileageModule"));
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule }, const SchedulingModule = lazy(() => import("./modules/scheduling/SchedulingModule"));
]; const HydrogenModule = lazy(() => import("./modules/energy/HydrogenModule"));
const ElectricModule = lazy(() => import("./modules/energy/ElectricModule"));
const EtcModule = lazy(() => import("./modules/energy/EtcModule"));
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
const ENERGY_MODULE: ModuleConfig = { const ASSETS_MODULE: ModuleConfig = {
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule, id: "assets",
label: "资产管理",
icon: Truck,
component: AssetsModule,
};
const MILEAGE_MODULE: ModuleConfig = {
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 (
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center"> <div className="min-h-screen bg-[var(--app-bg)] p-4 text-slate-800 md:p-8">
<div className="text-center"> <div className="mx-auto flex max-w-5xl flex-col gap-4">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div> <div className="rounded-2xl border border-white/70 bg-white/86 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
<p className="text-xs text-slate-400 font-bold">...</p> <div className="mb-3 text-[11px] font-black text-blue-600">LN BI ACCESS</div>
<div className="text-xl font-black text-slate-950"> BI</div>
<div className="mt-2 text-xs font-bold text-slate-400"></div>
</div>
<SurfaceCard className="p-4">
<div className="grid gap-3 md:grid-cols-4">
{[0, 1, 2, 3].map(item => <SkeletonBlock key={item} className="h-24" />)}
</div>
<div className="mt-4">
<LoadingState label="正在验证身份" />
</div>
</SurfaceCard>
</div> </div>
</div> </div>
); );
@@ -72,10 +170,28 @@ function AuthGate() {
} }
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现 // 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
if (routeKey === 'ele/import') return <EleImportPage />; if (routeKey === "ele/import") {
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />; return (
<Suspense fallback={<LoadingState label="正在加载导入工作台" />}>
<EleImportPage />
</Suspense>
);
}
if (routeKey === "admin/feedback") {
return (
<Suspense fallback={<LoadingState label="正在加载反馈后台" />}>
<FeedbackAdminPage />
</Suspense>
);
}
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() {

View File

@@ -1,19 +1,24 @@
import { ShieldX, Monitor, Smartphone } from 'lucide-react'; import { ShieldX, Monitor, Smartphone } from 'lucide-react';
import { PageFrame, SurfaceCard } from '../components/ui/surface';
export default function UnauthorizedPage({ message }: { message?: string }) { export default function UnauthorizedPage({ message }: { message?: string }) {
return ( return (
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6"> <PageFrame
<div className="text-center max-w-sm"> title="未授权访问"
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center"> subtitle={message || '获取用户认证信息失败,可能是跳转令牌已过期或无效。'}
<ShieldX size={36} className="text-slate-400" /> icon={ShieldX}
eyebrow="ACCESS CONTROL"
meta="请从授权入口重新进入"
maxWidth="max-w-xl"
>
<SurfaceCard className="p-4">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<ShieldX size={30} />
</div> </div>
<h1 className="text-lg font-black text-slate-800 mb-2">访</h1> <p className="mb-4 text-[10px] font-bold uppercase tracking-wider text-slate-400"></p>
<p className="text-xs text-slate-400 mb-4"> </div>
{message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'} <div className="space-y-3">
</p>
<div className="bg-white rounded-2xl border border-slate-100 p-4 text-left space-y-3">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-center"></p>
<div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50"> <div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50">
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" /> <Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
@@ -31,7 +36,7 @@ export default function UnauthorizedPage({ message }: { message?: string }) {
</div> </div>
</div> </div>
</div> </div>
</div> </SurfaceCard>
</div> </PageFrame>
); );
} }

View File

@@ -1,38 +1,33 @@
import { useState, useEffect, useMemo, type ComponentType } from 'react'; import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
import { motion } from 'motion/react';
import { Building2, ShieldCheck } from 'lucide-react';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur'; import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab'; import FeedbackFab from './FeedbackFab';
import { cn } from '../lib/cn';
import { LoadingState } from './ui/surface';
export interface ModuleConfig { export interface ModuleConfig {
id: string; id: string;
label: string; label: string;
icon: ComponentType<{ size?: number; className?: string }>; icon: ComponentType<{ size?: number; className?: string }>;
component: ComponentType; component: ElementType;
} }
/** 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 +43,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);
@@ -66,6 +62,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component; const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
const { user } = useAuth(); const { user } = useAuth();
const activeLabel = modules.find((m) => m.id === activeModule)?.label ?? '业务看板';
const watermarkText = useMemo(() => { const watermarkText = useMemo(() => {
const name = user?.userName || '未登录'; const name = user?.userName || '未登录';
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-'); const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
@@ -74,7 +71,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
return ( return (
<DemoModeProvider enabled={false}> <DemoModeProvider enabled={false}>
<div className="flex min-h-screen"> <div className="enterprise-grid-bg flex min-h-screen">
{/* 全局水印 */} {/* 全局水印 */}
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}> <div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
<div className="absolute inset-0" style={{ <div className="absolute inset-0" style={{
@@ -83,7 +80,11 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}} /> }} />
</div> </div>
{/* Web 侧边栏 (md 及以上) */} {/* Web 侧边栏 (md 及以上) */}
<nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2"> <nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
<Building2 size={22} />
</div>
<div className="flex w-full flex-1 flex-col items-center gap-2">
{modules.map((m) => { {modules.map((m) => {
const Icon = m.icon; const Icon = m.icon;
const isActive = m.id === activeModule; const isActive = m.id === activeModule;
@@ -91,27 +92,51 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
<button <button
key={m.id} key={m.id}
onClick={() => switchModule(m.id)} onClick={() => switchModule(m.id)}
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ className={cn(
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
isActive isActive
? 'bg-blue-50 text-blue-600' ? 'text-white'
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50' : 'text-slate-400 hover:bg-white/8 hover:text-white',
}`} )}
title={m.label}
> >
<Icon size={22} /> {isActive ? (
<span className="text-[10px] mt-1 leading-tight">{m.label}</span> <motion.span
layoutId="desktop-shell-active"
className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30"
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
/>
) : null}
<Icon size={21} className="relative mb-1" />
<span className="relative leading-tight">{m.label}</span>
</button> </button>
); );
})} })}
</div>
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
<ShieldCheck size={18} />
</div>
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
</div>
</nav> </nav>
{/* 内容区 */} {/* 内容区 */}
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}> <main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
<span className="text-slate-600"> BI</span>
<span className="mx-2 text-slate-300">/</span>
<span>{activeLabel}</span>
</div>
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
{ActiveComponent && <ActiveComponent />} {ActiveComponent && <ActiveComponent />}
</Suspense>
<FeedbackFab module={activeModule} /> <FeedbackFab module={activeModule} />
</main> </main>
{/* 移动端底部导航 (md 以下) */} {/* 移动端底部导航 (md 以下) */}
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50"> <nav className="fixed inset-x-0 bottom-0 z-50 border-t border-white/70 bg-white/92 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-18px_40px_rgba(15,23,42,0.08)] backdrop-blur-xl md:hidden">
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
{modules.map((m) => { {modules.map((m) => {
const Icon = m.icon; const Icon = m.icon;
const isActive = m.id === activeModule; const isActive = m.id === activeModule;
@@ -119,13 +144,24 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
<button <button
key={m.id} key={m.id}
onClick={() => switchModule(m.id)} onClick={() => switchModule(m.id)}
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`} className={cn(
'relative flex min-h-12 flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-black transition-colors',
isActive ? 'text-blue-700' : 'text-slate-400',
)}
> >
<Icon size={20} /> {isActive ? (
<span className="text-[10px] mt-1">{m.label}</span> <motion.span
layoutId="mobile-shell-active"
className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100"
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
/>
) : null}
<Icon size={20} className="relative" />
<span className="relative">{m.label}</span>
</button> </button>
); );
})} })}
</div>
</nav> </nav>
</div> </div>
</DemoModeProvider> </DemoModeProvider>

View File

@@ -0,0 +1,287 @@
import { useState, type ComponentType, type ReactNode } from 'react';
import { AlertCircle, Info, Loader2, SearchX, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { cn } from '../../lib/cn';
export type SurfaceIcon = ComponentType<{ size?: number; className?: string }>;
export function PageFrame({
title,
subtitle,
icon: Icon,
eyebrow,
meta,
actions,
children,
maxWidth = 'max-w-6xl',
compactInfo = false,
}: {
title: string;
subtitle?: string;
icon?: SurfaceIcon;
eyebrow?: string;
meta?: ReactNode;
actions?: ReactNode;
children: ReactNode;
maxWidth?: string;
compactInfo?: boolean;
}) {
const [infoOpen, setInfoOpen] = useState(false);
return (
<div className="min-h-screen bg-[var(--app-bg)] text-slate-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}>
<motion.header
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: 'easeOut' }}
className={cn(
'relative rounded-2xl border border-white/70 bg-white/86 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl',
compactInfo ? 'overflow-visible' : 'overflow-hidden',
)}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-600 via-cyan-400 to-emerald-400" />
{compactInfo ? (
<>
<div className="flex min-h-[48px] items-center gap-2 px-3 py-1.5 md:min-h-[54px] md:gap-3 md:px-4 md:py-2">
{Icon ? (
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100 md:h-9 md:w-9">
<Icon size={17} />
</span>
) : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
{eyebrow ? <span className="shrink-0 text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
{meta ? <span className="hidden truncate text-[11px] font-bold text-slate-400 sm:inline">{meta}</span> : null}
</div>
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
</div>
{actions ? <div className="hidden shrink-0 items-center gap-2 md:flex">{actions}</div> : null}
{subtitle ? (
<button
type="button"
onClick={() => setInfoOpen(open => !open)}
className={cn(
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-slate-400 transition-colors md:h-9 md:w-9',
infoOpen ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-slate-100 bg-slate-50 hover:bg-blue-50 hover:text-blue-600',
)}
aria-label={infoOpen ? '收起页面说明' : '展开页面说明'}
title={infoOpen ? '收起说明' : '页面说明'}
>
{infoOpen ? <X size={16} /> : <Info size={16} />}
</button>
) : null}
</div>
<AnimatePresence initial={false}>
{infoOpen && subtitle ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="absolute left-0 right-0 top-full z-40 mt-2 overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-xl md:static md:mt-0 md:rounded-none md:border-x-0 md:border-b-0 md:shadow-none"
>
<div className="px-4 py-3 text-xs font-bold leading-relaxed text-slate-500 md:text-sm">
{subtitle}
</div>
</motion.div>
) : null}
</AnimatePresence>
</>
) : (
<div className="flex flex-col gap-4 p-4 md:flex-row md:items-end md:justify-between md:p-5">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2">
{Icon ? (
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Icon size={17} />
</span>
) : null}
{eyebrow ? <span className="text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
{meta ? <span className="text-[11px] font-bold text-slate-400">{meta}</span> : null}
</div>
<h1 className="truncate text-xl font-black tracking-tight text-slate-950 md:text-2xl">{title}</h1>
{subtitle ? <p className="mt-2 max-w-3xl text-xs font-bold leading-relaxed text-slate-500 md:text-sm">{subtitle}</p> : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)}
</motion.header>
{children}
</div>
</div>
);
}
export function SurfaceCard({
title,
subtitle,
actions,
children,
className,
}: {
title?: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={cn('rounded-2xl border border-slate-100 bg-white shadow-sm', className)}>
{(title || subtitle || actions) && (
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 px-4 py-3">
<div>
{title ? <h2 className="text-sm font-black text-slate-900">{title}</h2> : null}
{subtitle ? <p className="mt-1 text-[11px] font-bold text-slate-400">{subtitle}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
)}
{children}
</section>
);
}
export function MetricTile({
label,
value,
unit,
helper,
icon: Icon,
tone = 'blue',
}: {
label: string;
value: ReactNode;
unit?: string;
helper?: string;
icon?: SurfaceIcon;
tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate';
}) {
const toneClass = {
blue: 'bg-blue-50 text-blue-600 ring-blue-100',
emerald: 'bg-emerald-50 text-emerald-600 ring-emerald-100',
amber: 'bg-amber-50 text-amber-600 ring-amber-100',
rose: 'bg-rose-50 text-rose-600 ring-rose-100',
slate: 'bg-slate-100 text-slate-600 ring-slate-200',
}[tone];
return (
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-[11px] font-black text-slate-400">{label}</div>
{Icon ? (
<span className={cn('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1', toneClass)}>
<Icon size={18} />
</span>
) : null}
</div>
<div className="mt-3 flex min-w-0 items-end gap-1">
<span className="min-w-0 whitespace-nowrap text-[clamp(1.65rem,6vw,2rem)] font-black leading-none tracking-tight text-slate-950 tabular-nums">
{value}
</span>
{unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null}
</div>
{helper ? <div className="mt-3 text-[11px] font-bold leading-relaxed text-slate-500">{helper}</div> : null}
</div>
);
}
export function SegmentedNav<T extends string>({
tabs,
active,
onChange,
className,
}: {
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
active: T;
onChange: (id: T) => void;
className?: string;
}) {
return (
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}>
{tabs.map(({ id, label, icon: Icon }) => {
const isActive = active === id;
return (
<button
key={id}
type="button"
onClick={() => onChange(id)}
className={cn(
'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors',
isActive ? 'text-blue-700' : 'text-slate-400 hover:bg-slate-50 hover:text-slate-600',
)}
>
{isActive ? (
<motion.span
layoutId="segmented-active-pill"
className="absolute inset-0 rounded-xl bg-blue-50 shadow-sm ring-1 ring-blue-100"
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
/>
) : null}
{Icon ? <Icon size={15} className="relative" /> : null}
<span className="relative truncate">{label}</span>
</button>
);
})}
</div>
</div>
);
}
export function SkeletonBlock({ className }: { className?: string }) {
return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />;
}
export function LoadingState({ label = '数据加载中' }: { label?: string }) {
return (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
<Loader2 className="mx-auto mb-3 animate-spin text-blue-500" size={22} />
<div className="text-xs font-black text-slate-500">{label}</div>
</div>
);
}
export function EmptyState({
title = '暂无数据',
description = '换个筛选条件或稍后再试',
}: {
title?: string;
description?: string;
}) {
return (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
<SearchX className="mx-auto mb-3 text-slate-300" size={26} />
<div className="text-sm font-black text-slate-700">{title}</div>
<div className="mt-1 text-xs font-bold text-slate-400">{description}</div>
</div>
);
}
export function ErrorState({ message }: { message: string }) {
return (
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-rose-700 shadow-sm">
<div className="flex items-start gap-2">
<AlertCircle size={18} className="mt-0.5 shrink-0" />
<div>
<div className="text-sm font-black"></div>
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</div>
</div>
</div>
</div>
);
}
export function FadeIn({ children, className }: { children: ReactNode; className?: string }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
className={cn('w-full min-w-0', className)}
>
{children}
</motion.div>
);
}

View File

@@ -1,8 +1,15 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--app-bg: #f4f7fb;
--panel-bg: rgba(255, 255, 255, 0.9);
--hairline: rgba(148, 163, 184, 0.18);
}
html, body { html, body {
overscroll-behavior: none; overscroll-behavior: none;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
background: var(--app-bg);
} }
html { html {
@@ -14,3 +21,52 @@ body {
overflow: auto; overflow: auto;
height: 100%; height: 100%;
} }
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes floatUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@utility animate-marquee {
animation: marquee 30s linear infinite;
}
@utility no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar { display: none; }
}
.shimmer {
position: relative;
}
.shimmer::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.72), transparent);
animation: shimmer 1.4s infinite;
}
.enterprise-grid-bg {
background:
radial-gradient(circle at 16% 0%, rgba(59, 130, 246, 0.08), transparent 28%),
radial-gradient(circle at 90% 12%, rgba(20, 184, 166, 0.08), transparent 26%),
linear-gradient(180deg, #f8fbff 0%, var(--app-bg) 42%, #f7f9fc 100%);
}
.asset-date-input::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0;
}

3
src/lib/cn.ts Normal file
View File

@@ -0,0 +1,3 @@
export function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ');
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react'; import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
import { fetchJson } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
interface FeedbackItem { interface FeedbackItem {
id: number; id: number;
@@ -20,10 +21,10 @@ interface FeedbackItem {
} }
const TYPE_LABEL: Record<string, string> = { const TYPE_LABEL: Record<string, string> = {
dimension: '💡 新维度', dimension: '新维度',
bug: '🐛 Bug', bug: 'Bug',
ux: '🎨 体验', ux: '体验',
other: '📝 其他', other: '其他',
}; };
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [ const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
@@ -126,36 +127,36 @@ export default function FeedbackAdminPage() {
}, {}); }, {});
return ( return (
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8"> <PageFrame
<div className="max-w-5xl mx-auto space-y-4"> title="用户反馈管理"
<header className="flex items-center justify-between"> subtitle="查看、回复、跟进用户提交的建议,形成从问题发现到处理闭环的运营后台。"
<div className="flex items-center gap-3 min-w-0"> icon={Inbox}
eyebrow="FEEDBACK OPS"
meta={`当前 ${items.length} 条反馈`}
actions={(
<div className="flex items-center gap-2">
<button <button
onClick={() => { onClick={() => {
// 优先 history.back来自 SPA 内部跳转);否则回到主页 // 优先 history.back来自 SPA 内部跳转);否则回到主页
if (window.history.length > 1) window.history.back(); if (window.history.length > 1) window.history.back();
else { window.location.hash = '#mileage'; } else { window.location.hash = '#mileage'; }
}} }}
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0" className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
title="返回" title="返回"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </button>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center flex-shrink-0"> <button onClick={reload} className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white shadow-sm transition-colors hover:bg-slate-800" title="刷新">
<Inbox size={18} className="text-white" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-black text-slate-900 leading-tight"></h1>
<p className="text-[11px] font-bold text-slate-400"></p>
</div>
</div>
<button onClick={reload} className="p-2 text-slate-400 hover:text-blue-500 flex-shrink-0" title="刷新">
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} /> <RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
</button> </button>
</header> </div>
)}
maxWidth="max-w-5xl"
>
{/* 状态过滤 */} {/* 状态过滤 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 flex items-center gap-1 overflow-x-auto"> <SurfaceCard className="p-2">
<div className="flex items-center gap-1 overflow-x-auto">
<button <button
onClick={() => setStatusFilter('')} onClick={() => setStatusFilter('')}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`} className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
@@ -170,6 +171,7 @@ export default function FeedbackAdminPage() {
</button> </button>
))} ))}
</div> </div>
</SurfaceCard>
{error && ( {error && (
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600"> <div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
@@ -192,9 +194,9 @@ export default function FeedbackAdminPage() {
{/* 列表 */} {/* 列表 */}
<div className="space-y-2"> <div className="space-y-2">
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold"></div> <LoadingState label="正在加载用户反馈" />
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold"></div> <EmptyState title="还没有反馈" description="新的用户反馈会出现在这里" />
) : items.map(it => { ) : items.map(it => {
const shots = parseScreenshots(it.screenshots); const shots = parseScreenshots(it.screenshots);
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status); const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
@@ -218,7 +220,7 @@ export default function FeedbackAdminPage() {
{(shots.length > 0 || it.contact) && ( {(shots.length > 0 || it.contact) && (
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold"> <div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} </span>} {shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} </span>}
{it.contact && <span>📞 {it.contact}</span>} {it.contact && <span> {it.contact}</span>}
</div> </div>
)} )}
{it.reply_content && ( {it.reply_content && (
@@ -236,8 +238,6 @@ export default function FeedbackAdminPage() {
); );
})} })}
</div> </div>
</div>
{/* 详情 / 回复弹窗 */} {/* 详情 / 回复弹窗 */}
<AnimatePresence> <AnimatePresence>
{active && ( {active && (
@@ -336,6 +336,6 @@ export default function FeedbackAdminPage() {
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </PageFrame>
); );
} }

View File

@@ -14,8 +14,12 @@ import {
Filter, Filter,
ArrowRightLeft, ArrowRightLeft,
MapPin, MapPin,
Download,
CalendarDays,
X,
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import * as XLSX from 'xlsx';
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -30,12 +34,13 @@ import {
LabelList, LabelList,
} from 'recharts'; } from 'recharts';
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types'; import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, type SubjectOption } from './api'; import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api';
import type { WeeklyDetailItem } from './api'; import type { FlowDetailItem, FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
import { SearchSelect } from '../../components/SearchSelect'; import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
// --- Constants --- // --- Constants ---
@@ -46,6 +51,79 @@ 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 = '车辆资产已于 2026 年 6 月 18 日完成“运营状态”与“业务关联”校验';
return (
<div className="relative -mx-6 mb-4 bg-green-50 border-y border-green-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-green-700 font-medium">
{text}
</span>
{overflow && (
<span className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
{text}
</span>
)}
</div>
</div>
</div>
);
}
function formatLocalDateTime(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}
function formatLocalDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function getWeeklyFlowRange() {
const now = new Date();
const day = now.getDay();
const end = day === 6 ? addDays(now, -1) : day === 0 ? addDays(now, -2) : addDays(now, 5 - day);
const start = addDays(end, -6);
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
};
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,8 +170,13 @@ 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, setLastUpdate] = useState<string>(() => formatLocalDateTime(new Date()));
const [modalLoading, setModalLoading] = useState(false); const [modalLoading, setModalLoading] = useState(false);
const [flowRange, setFlowRange] = useState(() => getWeeklyFlowRange());
const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(null);
const [flowLoading, setFlowLoading] = useState(false);
const [flowDailyExpanded, setFlowDailyExpanded] = useState(false);
const [selectedFlow, setSelectedFlow] = useState<{ date: string; type: FlowType } | null>(null);
// Dept/Region/Customer data // Dept/Region/Customer data
const [deptData, setDeptData] = useState<DeptGroup[]>([]); const [deptData, setDeptData] = useState<DeptGroup[]>([]);
@@ -162,7 +245,7 @@ export default function AssetsModule() {
setRegionData(region); setRegionData(region);
setCustomerData(cust); setCustomerData(cust);
setInventoryData(inv); setInventoryData(inv);
setLastUpdate(new Date().toLocaleString('zh-CN')); setLastUpdate(formatLocalDateTime(new Date()));
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : '数据加载失败'); setError(e instanceof Error ? e.message : '数据加载失败');
} finally { } finally {
@@ -176,6 +259,24 @@ export default function AssetsModule() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadData]); }, [loadData]);
useEffect(() => {
let cancelled = false;
setFlowLoading(true);
fetchFlowStats({ start: flowRange.start, end: flowRange.end, subject: selectedSubject })
.then((data) => {
if (!cancelled) setFlowStats(data);
})
.catch(() => {
if (!cancelled) setFlowStats(null);
})
.finally(() => {
if (!cancelled) setFlowLoading(false);
});
return () => {
cancelled = true;
};
}, [flowRange.start, flowRange.end, selectedSubject]);
// 归属公司列表(仅首次加载,公司集合相对稳定) // 归属公司列表(仅首次加载,公司集合相对稳定)
useEffect(() => { useEffect(() => {
fetchSubjects().then(setSubjects).catch(() => setSubjects([])); fetchSubjects().then(setSubjects).catch(() => setSubjects([]));
@@ -475,6 +576,40 @@ export default function AssetsModule() {
return mp; return mp;
}), [modalWeeklyDetail, modalFilters.plateNumber]); }), [modalWeeklyDetail, modalFilters.plateNumber]);
const selectedFlowDetails = useMemo(() => {
if (!flowStats || !selectedFlow) return [];
return flowStats.details.filter((item) => item.date === selectedFlow.date && item.type === selectedFlow.type);
}, [flowStats, selectedFlow]);
const exportFlowDetails = useCallback((rows?: FlowDetailItem[], title = '资产流转明细') => {
const source = rows ?? flowStats?.details ?? [];
if (source.length === 0) return;
const table = source.map((item) => ({
日期: item.date,
类型: item.typeLabel,
车牌: item.plateNumber,
流转时间: item.eventTime || '',
提交时间: item.submitTime || '',
部门: item.department || '',
业务负责人: item.manager || '',
客户: item.customerName || '',
}));
const ws = XLSX.utils.json_to_sheet(table);
ws['!cols'] = [
{ wch: 14 },
{ wch: 8 },
{ wch: 14 },
{ wch: 20 },
{ wch: 20 },
{ wch: 16 },
{ wch: 14 },
{ wch: 24 },
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '明细');
XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
}, [flowRange.end, flowRange.start, flowStats]);
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]); const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
useEffect(() => { useEffect(() => {
if (customerChartView === 'province') { if (customerChartView === 'province') {
@@ -495,38 +630,60 @@ export default function AssetsModule() {
if (loading && !summary) { if (loading && !summary) {
return ( return (
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center"> <PageFrame
<div className="flex flex-col items-center gap-3"> title="车辆资产中心"
<Loader2 className="animate-spin text-blue-500" size={32} /> subtitle="正在同步车辆、部门、区域与客户归属数据,加载完成后可继续穿透查看明细。"
<span className="text-sm text-gray-500">...</span> icon={Truck}
eyebrow="ASSET INTELLIGENCE"
meta="数据准备中"
>
<SurfaceCard className="min-h-[360px]">
<div className="grid gap-4 md:grid-cols-4">
{[0, 1, 2, 3].map(item => (
<SkeletonBlock key={item} className="h-28" />
))}
</div> </div>
<div className="mt-8">
<LoadingState label="正在加载车辆资产数据" />
</div> </div>
</SurfaceCard>
</PageFrame>
); );
} }
if (error && !summary) { if (error && !summary) {
return ( return (
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center"> <PageFrame
<div className="flex flex-col items-center gap-3 text-center"> title="车辆资产中心"
<div className="text-red-500 text-lg font-bold"></div> subtitle="车辆资产数据暂时没有返回,请重试或稍后再看。"
<div className="text-sm text-gray-500">{error}</div> icon={Truck}
<button onClick={loadData} className="mt-2 px-4 py-2 bg-blue-500 text-white rounded text-sm hover:bg-blue-600"> eyebrow="ASSET INTELLIGENCE"
meta="加载失败"
>
<SurfaceCard className="min-h-[360px]">
<div className="mt-6 flex flex-col items-center gap-4">
<ErrorState message={error} />
<button onClick={loadData} className="rounded-xl bg-slate-900 px-4 py-2 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800">
</button> </button>
</div> </div>
</div> </SurfaceCard>
</PageFrame>
); );
} }
const SUMMARY = summary!; const SUMMARY = summary!;
const operatingRate = SUMMARY.totalAssets > 0 ? SUMMARY.operating.total / SUMMARY.totalAssets * 100 : 0;
const inventoryRate = SUMMARY.totalAssets > 0 ? SUMMARY.inventory.total / SUMMARY.totalAssets * 100 : 0;
const pendingRate = SUMMARY.totalAssets > 0 ? SUMMARY.pendingDelivery / SUMMARY.totalAssets * 100 : 0;
return ( return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 relative"> <div className="min-h-screen bg-[var(--app-bg)] text-gray-800 font-sans p-3 md:p-6 relative">
{/* Compact Header Bar */} {/* Compact Header Bar */}
<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-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
{/* 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">
@@ -689,13 +846,12 @@ export default function AssetsModule() {
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" /> <span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
: {lastUpdate} : {lastUpdate}
</div> </div>
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-green-400 animate-pulse inline-block" />
</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">
@@ -708,11 +864,11 @@ export default function AssetsModule() {
{tabReady && activeTab === 'overview' && ( {tabReady && activeTab === 'overview' && (
<> <>
{/* Header Summary - Ultra Compact */} {/* Header Summary - Ultra Compact */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2 mb-2"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
{/* Total Assets */} {/* Total Assets */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-gray-50 transition-colors" <div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-400"> <div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
<Truck size={14} /> <Truck size={14} />
</div> </div>
<div> <div>
@@ -722,9 +878,9 @@ export default function AssetsModule() {
</div> </div>
{/* Operating */} {/* Operating */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors" <div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}>
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500"> <div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
<Activity size={14} /> <Activity size={14} />
</div> </div>
<div> <div>
@@ -737,9 +893,9 @@ export default function AssetsModule() {
</div> </div>
{/* Inventory */} {/* Inventory */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-gray-50 transition-colors" <div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-500"> <div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
<Warehouse size={14} /> <Warehouse size={14} />
</div> </div>
<div> <div>
@@ -752,9 +908,9 @@ export default function AssetsModule() {
</div> </div>
{/* Pending */} {/* Pending */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors" <div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500"> <div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
<PlusCircle size={14} /> <PlusCircle size={14} />
</div> </div>
<div> <div>
@@ -763,41 +919,163 @@ export default function AssetsModule() {
</div> </div>
</div> </div>
{/* Dynamics */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm col-span-2">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[9px] text-gray-400 font-bold uppercase tracking-tight"></div>
<div className="text-[7px] text-gray-300 font-normal italic">-</div>
</div> </div>
<div className="flex justify-between items-center gap-1">
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-green-50 py-1 rounded transition-all group"> <div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
<span className="text-xs font-bold text-gray-800 group-hover:text-green-600">{SUMMARY.weeklyNew}</span> <div className="flex items-center justify-between gap-3">
<span className="text-[8px] text-green-500/80 font-bold mt-0.5"></span> <div className="shrink-0 text-[11px] font-black text-slate-400"></div>
<div className="grid min-w-0 flex-1 grid-cols-3 divide-x divide-slate-100 text-center">
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
</div> </div>
<div className="w-[1px] h-3 bg-gray-100"></div> <div className="px-2">
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-blue-50 py-1 rounded transition-all group" <div className="text-[10px] font-black text-slate-400"></div>
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Delivered', source: 'asset', title: '本周交车' })}> <div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
<span className="text-xs font-bold text-gray-800 group-hover:text-blue-600">{SUMMARY.weeklyDelivered}</span>
<span className="text-[8px] text-blue-500/80 font-bold mt-0.5"></span>
</div> </div>
<div className="w-[1px] h-3 bg-gray-100"></div> <div className="px-2">
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-orange-50 py-1 rounded transition-all group" <div className="text-[10px] font-black text-slate-400"></div>
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Returned', source: 'asset', title: '本周还车' })}> <div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
<span className="text-xs font-bold text-gray-800 group-hover:text-orange-600">{SUMMARY.weeklyReturned}</span>
<span className="text-[8px] text-orange-500/80 font-bold mt-0.5"></span>
</div>
<div className="w-[1px] h-3 bg-gray-100"></div>
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-purple-50 py-1 rounded transition-all group"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Replaced', source: 'asset', title: '本周替换' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-purple-600">{SUMMARY.weeklyReplaced}</span>
<span className="text-[8px] text-purple-500/80 font-bold mt-0.5"></span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-3 mb-4">
<div data-testid="asset-flow-card" className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
<CalendarDays size={14} className="text-blue-500" />
<span></span>
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => exportFlowDetails()}
disabled={!flowStats?.details.length}
className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
</button>
</div>
</div>
<div className="mt-2 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5">
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
<CalendarDays size={12} className="text-slate-300" />
</span>
<input
type="date"
value={flowRange.start}
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
<div className="rounded-full bg-slate-200/70 px-2 py-0.5 text-[9px] font-black text-slate-400"></div>
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
<CalendarDays size={12} className="text-slate-300" />
</span>
<input
type="date"
value={flowRange.end}
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
</div>
</div>
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
<div className="grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-950">{flowStats?.totals.total ?? 0}</div>
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => (
<div key={type} className="border-l border-slate-200/70 px-2">
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
{flowStats?.totals[type] ?? 0}
</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</div>
))}
</div>
</div>
<button
type="button"
data-testid="asset-flow-daily-toggle"
onClick={() => setFlowDailyExpanded((prev) => !prev)}
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
>
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
<span className="flex items-center gap-2 text-[11px] text-slate-400">
{flowStats?.daily.length ?? 0}
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
</span>
</button>
<AnimatePresence initial={false}>
{flowDailyExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="mt-3 max-h-[280px] space-y-2 overflow-y-auto pr-1">
{flowLoading && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">...</div>
)}
{!flowLoading && flowStats?.daily.map((day) => (
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
<div className="text-[10px] font-bold italic text-slate-300"></div>
</div>
<div className="mt-2 grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-900">{day.total}</div>
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => {
const count = day[type];
return (
<button
key={type}
type="button"
data-testid={`asset-flow-cell-${day.date}-${type}`}
disabled={count === 0}
onClick={() => setSelectedFlow({ date: day.date, type })}
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
>
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</button>
);
})}
</div>
</div>
))}
{!flowLoading && !flowStats?.daily.length && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400"></div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Asset Summary Table */} {/* Asset Summary Table */}
<div className="bg-white rounded-sm border border-gray-100 shadow-sm overflow-hidden mb-6"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden mb-6">
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3"> <div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<div className="flex flex-wrap items-center gap-4 sm:gap-6"> <div className="flex flex-wrap items-center gap-4 sm:gap-6">
<h2 className="text-sm font-bold text-gray-700"></h2> <h2 className="text-sm font-bold text-gray-700"></h2>
@@ -2602,6 +2880,123 @@ export default function AssetsModule() {
)} )}
{/* Flow Detail Modal */}
<AnimatePresence>
{selectedFlow && (
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
<motion.div
data-testid="asset-flow-detail-modal"
initial={{ opacity: 0, y: 28, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
>
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400"></div>
<h3 className="mt-1 text-lg font-black">
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label}
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
{selectedFlowDetails.length}
</div>
</div>
<button
type="button"
onClick={() => setSelectedFlow(null)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
<X size={18} />
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
disabled={selectedFlowDetails.length === 0}
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
</button>
</div>
</div>
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
<table className="w-full table-fixed text-left">
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
<tr>
<th className="w-28 px-3 py-3"></th>
<th className="w-40 px-3 py-3">{FLOW_META[selectedFlow.type].label}</th>
<th className="w-40 px-3 py-3"></th>
<th className="w-36 px-3 py-3"></th>
<th className="w-28 px-3 py-3"></th>
<th className="px-3 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
{selectedFlowDetails.map((item) => (
<tr key={item.id} className="hover:bg-blue-50/40">
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}</td>
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
<td className="px-3 py-3">{item.department || '-'}</td>
<td className="px-3 py-3">{item.manager || '-'}</td>
<td className="px-3 py-3">{item.customerName || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="space-y-2 lg:hidden">
{selectedFlowDetails.map((item) => (
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
{item.typeLabel}
</div>
</div>
<div className="text-right text-[10px] font-bold text-slate-400">
<div></div>
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">{item.typeLabel}</div>
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
</div>
</div>
</div>
))}
</div>
{selectedFlowDetails.length === 0 && (
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400"></div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Vehicle Detail Modal */} {/* Vehicle Detail Modal */}
<AnimatePresence> <AnimatePresence>
{showPlateNumbers && ( {showPlateNumbers && (

View File

@@ -78,6 +78,43 @@ export interface WeeklyDetailItem {
customer_name: string | null; customer_name: string | null;
} }
export type FlowType = 'delivered' | 'returned' | 'replaced';
export interface FlowDailyPoint {
date: string;
delivered: number;
returned: number;
replaced: number;
total: number;
}
export interface FlowDetailItem {
id: string;
type: FlowType;
typeLabel: string;
date: string;
truckId: string;
plateNumber: string;
eventTime: string | null;
submitTime: string | null;
department: string;
manager: string;
customerName: string | null;
}
export interface FlowStatsResponse {
start: string;
end: string;
daily: FlowDailyPoint[];
totals: {
delivered: number;
returned: number;
replaced: number;
total: number;
};
details: FlowDetailItem[];
}
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> { export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject)); return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
} }
@@ -125,3 +162,13 @@ export async function fetchWeeklyDetail(
if (filters?.source) params.set('source', filters.source); if (filters?.source) params.set('source', filters.source);
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`); return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
} }
export async function fetchFlowStats(params: {
start: string;
end: string;
subject?: string | null;
}): Promise<FlowStatsResponse> {
const query = new URLSearchParams({ start: params.start, end: params.end });
if (params.subject) query.set('subject', params.subject);
return fetchJson<FlowStatsResponse>(`${BASE}/flow-stats?${query.toString()}`);
}

View File

@@ -106,6 +106,7 @@ export interface VehicleListItem {
city: string | null; city: string | null;
status: string; status: string;
ownership: string; ownership: string;
rentCompany?: string | null;
contractNo: string | null; contractNo: string | null;
customerName: string | null; customerName: string | null;
subjectOrg: string | null; subjectOrg: string | null;

View File

@@ -8,6 +8,7 @@ import { fetchJson } from '../../auth/api-client';
import { useAuth } from '../../auth/useAuth'; import { useAuth } from '../../auth/useAuth';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import FeedbackFab from '../../components/FeedbackFab'; import FeedbackFab from '../../components/FeedbackFab';
import { PageFrame } from '../../components/ui/surface';
function getJwt(): string | null { function getJwt(): string | null {
return sessionStorage.getItem('bi_jwt'); return sessionStorage.getItem('bi_jwt');
@@ -135,30 +136,34 @@ export default function EleImportPage() {
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0); const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
return ( return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8"> <PageFrame
<div className="max-w-6xl mx-auto space-y-4"> title="充电记录导入"
<header className="flex items-center justify-between"> subtitle="每日上传 xlsx按订单编号去重并自动匹配内部/外部车辆归属。"
<div className="flex items-center gap-3 min-w-0"> icon={Zap}
eyebrow="ELECTRIC IMPORT"
meta={user?.userName || '导入工作台'}
actions={(
<div className="flex items-center gap-2">
<button <button
onClick={() => { onClick={() => {
if (window.history.length > 1) window.history.back(); if (window.history.length > 1) window.history.back();
else { window.location.hash = '#mileage'; } else { window.location.hash = '#mileage'; }
}} }}
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0" className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
title="返回" title="返回"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </button>
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0"> <button
<Zap size={18} className="text-white" /> onClick={() => inputRef.current?.click()}
className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800"
>
<Upload size={14} />
</button>
</div> </div>
<div className="min-w-0"> )}
<h1 className="text-lg font-black text-slate-900 leading-tight"></h1> >
<p className="text-[11px] font-bold text-slate-400"> xlsx · · </p>
</div>
</div>
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
</header>
{/* 上传区 */} {/* 上传区 */}
<section <section
@@ -359,9 +364,8 @@ export default function EleImportPage() {
</section> </section>
<RotatingFooterHint className="pb-4" /> <RotatingFooterHint className="pb-4" />
</div>
<FeedbackFab module="ele" /> <FeedbackFab module="ele" />
</div> </PageFrame>
); );
} }

View File

@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Plug } from 'lucide-react'; import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge'; import TrendBadge from './TrendBadge';
import { fetchElectricMonthly } from './api'; import { fetchElectricMonthly } from './api';
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types'; import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' }, { id: 'thisWeek', label: '本周' },
@@ -12,26 +13,61 @@ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'last15', label: '近 15 天' }, { id: 'last15', label: '近 15 天' },
]; ];
type RangeMode = DateQuickPick | 'custom';
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (pick === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (pick === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
function normalizeRange(start: string, end: string): { start: string; end: string } {
return start <= end ? { start, end } : { start: end, end: start };
}
export default function ElectricDaily() { export default function ElectricDaily() {
const [customer, setCustomer] = useState<CustomerType>('lingniu'); const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [pick, setPick] = useState<DateQuickPick>('last15'); const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null); const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set()); const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
fetchElectricMonthly(customer, pick) const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchElectricMonthly(customer, query)
.then(m => { .then(m => {
if (cancelled) return; if (cancelled) return;
setMonths(m); setMonths(m);
// 默认展开最新一个月 // 默认展开最新一个月
if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month])); if (m.length > 0) setOpenMonths(new Set([m[0].month]));
}) })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [customer, pick]); }, [customer, pick, effectiveRange.start, effectiveRange.end]);
const toggleMonth = (m: string) => setOpenMonths(prev => { const toggleMonth = (m: string) => setOpenMonths(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -40,41 +76,109 @@ export default function ElectricDaily() {
}); });
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]); const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
const totalFee = useMemo(() => (months ?? []).reduce((s, m) => s + (m.fee || 0), 0), [months]);
const activeDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => r.kwh > 0).length, 0), [months]);
const abnormalDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => Math.abs(r.chainPct) >= 0.3).length, 0), [months]);
const avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
const scopeLabel = pick === 'custom'
? '自定义区间'
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const hasFeeDetail = totalFee > 0;
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0; const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick);
setDateRange(getQuickRange(nextPick));
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
setPick('custom');
setDateRange(prev => ({ ...prev, [field]: value }));
};
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* 日期速选 */} <SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x"> <div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => ( {QUICK_PICK_OPTIONS.map(opt => (
<button <button
key={opt.id} key={opt.id}
onClick={() => setPick(opt.id)} onClick={() => applyQuickPick(opt.id)}
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${ className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id pick === opt.id
? 'bg-blue-50 text-blue-600 border-blue-200' ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50' : 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`} }`}
> >
{opt.label} {opt.label}
</button> </button>
))} ))}
<button
onClick={() => setPick('custom')}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
</button>
</div> </div>
{/* 客户类型 */} <div className="mt-2 grid grid-cols-2 gap-2">
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1"> <label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)}
onInput={e => updateDateRange('start', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
onInput={e => updateDateRange('end', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => ( {(['lingniu', 'external'] as const).map(c => (
<button <button
key={c} key={c}
onClick={() => setCustomer(c)} onClick={() => setCustomer(c)}
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${ className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500' customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`} }`}
> >
<Truck size={14} />
{c === 'external' ? '外部车辆' : '羚牛车辆'} {c === 'external' ? '外部车辆' : '羚牛车辆'}
</button> </button>
))} ))}
</div> </div>
</SurfaceCard>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} />
<MetricTile
icon={Wallet}
label="充电费用"
value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'}
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'}
tone={hasFeeDetail ? 'emerald' : 'slate'}
/>
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
</div>
{/* 外部车辆 数据未就绪 */} {/* 外部车辆 数据未就绪 */}
{showExternalEmpty && ( {showExternalEmpty && (
@@ -106,11 +210,11 @@ export default function ElectricDaily() {
<span className="text-right"></span> <span className="text-right"></span>
</div> </div>
{error ? ( {error ? (
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div> <div className="p-3"><ErrorState message={error} /></div>
) : months === null ? ( ) : months === null ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div> <div className="p-3"><LoadingState label="正在加载充电明细" /></div>
) : months.length === 0 ? ( ) : months.length === 0 ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div> <div className="p-3"><EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" /></div>
) : months.map(m => { ) : months.map(m => {
const open = openMonths.has(m.month); const open = openMonths.has(m.month);
return ( return (

View File

@@ -0,0 +1,33 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import ElectricView, { type ElectricSubTab } from './ElectricView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
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 (
<PageFrame
title="电能成本看板"
subtitle="围绕充电量、费用、日趋势和车辆归属展示电能支出结构,辅助识别费用波动。"
icon={CalendarDays}
eyebrow="ELECTRIC BI"
meta="时间单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<ElectricView sub={sub} />
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Wallet, CalendarClock } from 'lucide-react'; import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import { fetchElectricOverview, type ElectricOverviewResponse } from './api'; import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
function fmtYuan(yuan: number) { function fmtYuan(yuan: number) {
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
@@ -24,10 +25,10 @@ export default function ElectricOverview() {
}, []); }, []);
if (error) { if (error) {
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>; return <ErrorState message={error} />;
} }
if (!data) { if (!data) {
return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm"></div>; return <LoadingState label="正在加载电能总览" />;
} }
const k = data.kpi; const k = data.kpi;
const trendData = data.trend; const trendData = data.trend;
@@ -37,6 +38,12 @@ export default function ElectricOverview() {
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
? `${trendMonthLabel} 每日充电` ? `${trendMonthLabel} 每日充电`
: '本月每日充电'; : '本月每日充电';
const activeDays = trendData.filter(item => item.kwh > 0).length;
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0;
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0;
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -44,28 +51,36 @@ export default function ElectricOverview() {
2025-01-01 2025-01-01
</div> </div>
{/* 横向 mini KPI 头 */} {/* 横向 mini KPI 头 */}
<div className="grid grid-cols-2 gap-2 md:gap-3"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4"> <MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1"> <MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
<Wallet size={11} className="text-blue-600" /> <MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
</div> <MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.totalFee)}</div>
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.totalKwh)}</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
<CalendarClock size={11} className="text-blue-600" />
</div>
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400"></span></div>
<div className="mt-1 text-[11px] font-bold text-slate-500"> {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} </div>
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-blue-600">{peakDay ? peakDay.date.slice(5) : '—'}</div>
<div className="mt-1 text-[11px] font-bold text-slate-500">{peakDay ? `${peakDay.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度 · ${fmtYuan(peakDay.fee)}` : '暂无数据'}</div>
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
<div className="mt-1 text-[11px] font-bold text-slate-500"> / </div>
</SurfaceCard>
</div> </div>
{/* 本月每日充电柱图 */} {/* 本月每日充电柱图 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4"> <SurfaceCard className="p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{chartTitle}</span> <span className="text-sm font-bold text-slate-700">{chartTitle}</span>
<span className="text-[11px] text-slate-400 font-bold"> </span> <span className="text-[11px] text-slate-400 font-bold"> · · 线</span>
</div> </div>
<ResponsiveContainer width="100%" height={160}> <ResponsiveContainer width="100%" height={160}>
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}> <BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
@@ -85,6 +100,14 @@ export default function ElectricOverview() {
contentStyle={{ borderRadius: 12, fontSize: 12 }} contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }} cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
/> />
{avgDailyFee > 0 && (
<ReferenceLine
y={avgDailyFee}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="fee" radius={[4, 4, 0, 0]}> <Bar dataKey="fee" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => ( {trendData.map((_, i) => (
<Cell key={i} fill="url(#electricBarGrad)" /> <Cell key={i} fill="url(#electricBarGrad)" />
@@ -98,7 +121,7 @@ export default function ElectricOverview() {
</defs> </defs>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </SurfaceCard>
<RotatingFooterHint /> <RotatingFooterHint />
</div> </div>
); );

View File

@@ -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>
);
}

View File

@@ -0,0 +1,17 @@
import { Receipt } from 'lucide-react';
import ETCView from './ETCView';
import { PageFrame } from '../../components/ui/surface';
export default function EtcModule() {
return (
<PageFrame
title="ETC 通行费看板"
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
icon={Receipt}
eyebrow="ETC BI"
meta="数据对接中 · 页面能力预留"
>
<ETCView />
</PageFrame>
);
}

View File

@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Plug } from 'lucide-react'; import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import TrendBadge from './TrendBadge'; import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api'; import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types'; import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' }, { id: 'thisWeek', label: '本周' },
@@ -13,25 +14,76 @@ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'last15', label: '近 15 天' }, { id: 'last15', label: '近 15 天' },
]; ];
type RangeMode = DateQuickPick | 'custom';
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (pick === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (pick === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
function normalizeRange(start: string, end: string): { start: string; end: string } {
return start <= end ? { start, end } : { start: end, end: start };
}
export default function HydrogenDaily() { export default function HydrogenDaily() {
const [pick, setPick] = useState<DateQuickPick>('last15'); const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [customer, setCustomer] = useState<CustomerType>('lingniu'); const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null); const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
fetchHydrogenDaily(pick, customer) const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchHydrogenDaily(query, customer)
.then(r => { if (!cancelled) setRows(r); }) .then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [pick, customer]); }, [pick, customer, effectiveRange.start, effectiveRange.end]);
// 柱图:按日期升序,用于"从左到右时间流" // 柱图:按日期升序,用于"从左到右时间流"
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]); const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0); const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
const stationCount = useMemo(() => {
const names = new Set<string>();
(rows ?? []).forEach(r => r.stations.forEach(s => names.add(s.name)));
return names.size;
}, [rows]);
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
const scopeLabel = pick === 'custom'
? '自定义区间'
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
const lowDay = trendData
.filter(item => item.totalKg > 0)
.reduce<HydrogenDailyRow | null>((low, item) => (!low || item.totalKg < low.totalKg ? item : low), null);
const zeroDays = (rows ?? []).filter(r => r.totalKg === 0).length;
const toggle = (date: string) => setExpanded(prev => { const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -39,39 +91,91 @@ export default function HydrogenDaily() {
return next; return next;
}); });
const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick);
setDateRange(getQuickRange(nextPick));
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
setPick('custom');
setDateRange(prev => ({ ...prev, [field]: value }));
};
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* 日期速选 */} <SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x"> <div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => ( {QUICK_PICK_OPTIONS.map(opt => (
<button <button
key={opt.id} key={opt.id}
onClick={() => setPick(opt.id)} onClick={() => applyQuickPick(opt.id)}
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${ className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id pick === opt.id
? 'bg-blue-50 text-blue-600 border-blue-200' ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50' : 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`} }`}
> >
{opt.label} {opt.label}
</button> </button>
))} ))}
<button
onClick={() => setPick('custom')}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
</button>
</div> </div>
{/* 客户类型 segmented */} <div className="mt-2 grid grid-cols-2 gap-2">
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1"> <label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)}
onInput={e => updateDateRange('start', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
onInput={e => updateDateRange('end', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => ( {(['lingniu', 'external'] as const).map(c => (
<button <button
key={c} key={c}
onClick={() => setCustomer(c)} onClick={() => setCustomer(c)}
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${ className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500' customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`} }`}
> >
<Truck size={14} />
{c === 'external' ? '外部车辆' : '羚牛车辆'} {c === 'external' ? '外部车辆' : '羚牛车辆'}
</button> </button>
))} ))}
</div> </div>
</SurfaceCard>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div>
{/* 外部车辆:新系统数据还没准备好 */} {/* 外部车辆:新系统数据还没准备好 */}
{customer === 'external' && rows !== null && totalKg === 0 && ( {customer === 'external' && rows !== null && totalKg === 0 && (
@@ -96,13 +200,34 @@ export default function HydrogenDaily() {
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */} {/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && ( {!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4"> <SurfaceCard>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700"></span> <span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span> <span className="text-[11px] text-slate-400 font-bold"> · Kg</span>
</div> </div>
<ResponsiveContainer width="100%" height={160}> <div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}> <div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{zeroDays}
</div>
</div>
</div>
<div className="h-[180px] min-w-0 px-2 pb-2">
<ResponsiveContainer width="100%" height={180} minWidth={0}>
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis <XAxis
dataKey="date" dataKey="date"
tickFormatter={(v: string) => v.slice(5)} tickFormatter={(v: string) => v.slice(5)}
@@ -112,13 +237,27 @@ export default function HydrogenDaily() {
interval="preserveStartEnd" interval="preserveStartEnd"
minTickGap={8} minTickGap={8}
/> />
<YAxis hide /> <YAxis
width={42}
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
/>
<Tooltip <Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']} formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
labelFormatter={(d) => `日期 ${d}`} labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }} contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }} cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/> />
{avgKg > 0 && (
<ReferenceLine
y={avgKg}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}> <Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => ( {trendData.map((_, i) => (
<Cell key={i} fill="url(#hydrogenBarGrad)" /> <Cell key={i} fill="url(#hydrogenBarGrad)" />
@@ -133,6 +272,7 @@ export default function HydrogenDaily() {
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</SurfaceCard>
)} )}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */} {/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
@@ -154,11 +294,11 @@ export default function HydrogenDaily() {
</div> </div>
{/* 主行 + 子行 */} {/* 主行 + 子行 */}
{error ? ( {error ? (
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div> <div className="p-3"><ErrorState message={error} /></div>
) : rows === null ? ( ) : rows === null ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div> <div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div> <div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
) : rows.map(r => { ) : rows.map(r => {
const open = expanded.has(r.date); const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3; const isAbnormal = Math.abs(r.chainPct) >= 0.3;

View File

@@ -0,0 +1,33 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
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 (
<PageFrame
title="氢能经营看板"
subtitle="按时间、车辆归属、加氢站和区域统一展示加氢量、费用、收入与异常波动。"
icon={CalendarDays}
eyebrow="ENERGY BI"
meta="数据单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<HydrogenView sub={sub} />
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend, BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
} from 'recharts'; } from 'recharts';
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react'; import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2 } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api'; import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
@@ -147,6 +147,14 @@ export default function HydrogenOverview() {
const yearRevenueFmt = fmtYuan(k.yearRevenue); const yearRevenueFmt = fmtYuan(k.yearRevenue);
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600'; const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
const monthAvgKg = monthly.length > 0 ? monthly.reduce((sum, m) => sum + m.kg, 0) / monthly.length : 0;
const bestMonth = monthly.reduce<typeof monthly[number] | null>((best, item) => (!best || item.kg > best.kg ? item : best), null);
const latestMonth = monthly[monthly.length - 1];
const prevMonth = monthly[monthly.length - 2];
const monthMomentum = latestMonth && prevMonth && prevMonth.kg > 0 ? (latestMonth.kg - prevMonth.kg) / prevMonth.kg * 100 : null;
const top5Share = top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, k.yearKg) * 100;
const profitYield = k.yearRevenue > 0 ? k.yearProfit / k.yearRevenue * 100 : 0;
const stationAvgKg = stations.length > 0 ? k.yearKg / stations.length : 0;
// 月度收支组合数据(推算"年内每月"图) // 月度收支组合数据(推算"年内每月"图)
const monthlyDual = monthly.map(m => ({ const monthlyDual = monthly.map(m => ({
@@ -158,7 +166,7 @@ export default function HydrogenOverview() {
<div className="flex flex-col gap-3 relative"> <div className="flex flex-col gap-3 relative">
{/* 顶部说明条 + 年份切换 + 刷新按钮 */} {/* 顶部说明条 + 年份切换 + 刷新按钮 */}
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2"> <div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span> <span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5"> <div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
{availableYears.map(y => { {availableYears.map(y => {
@@ -247,6 +255,59 @@ export default function HydrogenOverview() {
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Gauge size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
<Building2 size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{stations.length} · {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{profitYield.toFixed(1)}%
</div>
</div>
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
<AlertTriangle size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{yearProfitFmt.value}{yearProfitFmt.unit} · {yearRevenueFmt.value}{yearRevenueFmt.unit}
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
</div>
</div>
</div>
{/* 月度趋势:年内每月加氢量 */} {/* 月度趋势:年内每月加氢量 */}
{monthly.length > 0 && ( {monthly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
@@ -559,6 +620,18 @@ function formatRelative(ts: number): string {
return new Date(ts).toLocaleString('zh-CN', { hour12: false }); return new Date(ts).toLocaleString('zh-CN', { hour12: false });
} }
function formatRefreshTime(ts: number): string {
const exactTime = new Date(ts).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return `${formatRelative(ts)} · ${exactTime.replace(/\//g, '-')}`;
}
function HydrogenOverviewSkeleton() { function HydrogenOverviewSkeleton() {
return ( return (
<div className="flex flex-col gap-3 animate-pulse"> <div className="flex flex-col gap-3 animate-pulse">

View File

@@ -0,0 +1,22 @@
import type { ComponentType } from 'react';
import { SegmentedNav } from '../../components/ui/surface';
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 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
<SegmentedNav tabs={tabs} active={active} onChange={onChange} />
</div>
);
}

View File

@@ -27,8 +27,17 @@ export function fetchHydrogenOverview(year?: number, force = false): Promise<Hyd
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`); return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
} }
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> { export interface HydrogenDailyQuery {
const q = new URLSearchParams({ range, customer }); range?: DateQuickPick;
startDate?: string;
endDate?: string;
}
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
const q = new URLSearchParams({ customer });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`); return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
} }
@@ -41,7 +50,10 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`); return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
} }
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> { export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
const q = new URLSearchParams({ customer, range }); const q = new URLSearchParams({ customer });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`); return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
} }

View 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];
}

View File

@@ -1,13 +1,18 @@
import { FileText } from 'lucide-react'; import { FileText } from 'lucide-react';
import { SurfaceCard } from '../../components/ui/surface';
export default function DailyReportView() { export default function DailyReportView() {
return ( return (
<div className="flex items-center justify-center py-20"> <SurfaceCard className="min-h-[360px]">
<div className="text-center"> <div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-10 text-center">
<FileText size={48} className="mx-auto text-gray-300 mb-4" /> <div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<h2 className="text-lg font-semibold text-gray-500"></h2> <FileText size={26} />
<p className="text-sm text-gray-400 mt-2">...</p> </div>
<div className="mt-4 text-base font-black text-slate-800"></div>
<div className="mt-2 max-w-md text-xs font-bold leading-relaxed text-slate-400">
</div> </div>
</div> </div>
</SurfaceCard>
); );
} }

View File

@@ -1,51 +1,40 @@
import { useState } from 'react';
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react'; import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
import { motion } from 'motion/react'; import { AnimatePresence } from 'motion/react';
import MonitoringView from './MonitoringView'; import MonitoringView from './MonitoringView';
import StatisticsView from './StatisticsView'; import StatisticsView from './StatisticsView';
import DailyReportView from './DailyReportView'; import DailyReportView from './DailyReportView';
import { useHashSubTab } from '../energy/useHashSubTab';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
type MileageSubTab = 'monitoring' | 'statistics' | 'report';
const MILEAGE_TABS = [
{ id: 'monitoring', label: '实时监控', icon: LayoutDashboard },
{ id: 'statistics', label: '统计报表', icon: BarChart3 },
{ id: 'report', label: '每日汇报', icon: FileText },
] as const satisfies readonly { id: MileageSubTab; label: string; icon: typeof LayoutDashboard }[];
const MILEAGE_SUB_IDS: readonly MileageSubTab[] = ['monitoring', 'statistics', 'report'];
export default function MileageModule() { export default function MileageModule() {
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring'); const [activeSubTab, setActiveSubTab] = useHashSubTab<MileageSubTab>('mileage', MILEAGE_SUB_IDS);
return ( return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}> <PageFrame
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden"> title="车辆里程中心"
{/* Sub-navigation — sticky */} subtitle="统一监控车辆日里程、累计里程、考核进度与日报经营口径,突出异常车辆和任务压力。"
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-0 z-30"> icon={LayoutDashboard}
<button eyebrow="MILEAGE BI"
onClick={() => setActiveSubTab('monitoring')} meta="实时监控 · 统计报表 · 每日汇报"
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`} compactInfo
> >
<LayoutDashboard size={14} /> <div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
<span className="text-[11px] font-bold"></span> <SegmentedNav tabs={MILEAGE_TABS} active={activeSubTab} onChange={setActiveSubTab} />
{activeSubTab === 'monitoring' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('statistics')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
>
<BarChart3 size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'statistics' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('report')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
>
<FileText size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'report' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
</div> </div>
<AnimatePresence mode="wait">
<FadeIn key={activeSubTab}>
{activeSubTab === 'monitoring' ? ( {activeSubTab === 'monitoring' ? (
<MonitoringView /> <MonitoringView />
) : activeSubTab === 'statistics' ? ( ) : activeSubTab === 'statistics' ? (
@@ -53,8 +42,9 @@ export default function MileageModule() {
) : ( ) : (
<DailyReportView /> <DailyReportView />
)} )}
</FadeIn>
</AnimatePresence>
<RotatingFooterHint /> <RotatingFooterHint />
</div> </PageFrame>
</div>
); );
} }

View File

@@ -3,8 +3,9 @@ import { motion, AnimatePresence } from 'motion/react';
import { import {
Truck, Filter, ChevronDown, Truck, Filter, ChevronDown,
Maximize2, Minimize2, RotateCcw, Maximize2, Minimize2, RotateCcw,
ArrowUp, ArrowDown, ChevronsUp, Download, ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays,
} from 'lucide-react'; } from 'lucide-react';
import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine, XAxis } from 'recharts';
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types'; import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
import { fetchMonitoring } from './api'; import { fetchMonitoring } from './api';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
@@ -12,6 +13,46 @@ 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;
function defaultMileageDate(): string {
const now = new Date();
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
function normalizeRangeLabel(start: string, end: string): string {
if (!start && !end) return '最新数据';
if (start && end && start === end) return start;
return `${start || end}${end || start}`;
}
function fmtDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
type RangePreset = 'today' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15';
function getRangePreset(preset: RangePreset): { start: string; end: string } {
const end = new Date(`${defaultMileageDate()}T00:00:00`);
const start = new Date(end);
if (preset === 'thisWeek') {
const day = start.getDay() || 7;
start.setDate(start.getDate() - day + 1);
} else if (preset === 'thisMonth') {
start.setDate(1);
} else if (preset === 'last7') {
start.setDate(start.getDate() - 6);
} else if (preset === 'last15') {
start.setDate(start.getDate() - 14);
}
return { start: fmtDate(start), end: fmtDate(end) };
}
const SearchableSelect = ({ const SearchableSelect = ({
options, options,
value, value,
@@ -92,6 +133,129 @@ const SearchableSelect = ({
); );
}; };
const BatchMultiSelect = ({
options,
selected,
onChange,
placeholder,
}: {
options: string[],
selected: string[],
onChange: (val: string[]) => void,
placeholder: string
}) => {
const rootRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const selectedSet = useMemo(() => new Set(selected), [selected]);
const filtered = useMemo(() => {
if (!search) return options;
return options.filter(opt => opt.toLowerCase().includes(search.toLowerCase()));
}, [options, search]);
const label = selected.length === 0
? placeholder
: selected.length === options.length
? '全部批次'
: selected.length === 1
? selected[0]
: `已选 ${selected.length} 个批次`;
const toggle = (opt: string) => {
if (selectedSet.has(opt)) {
onChange(selected.filter(item => item !== opt));
} else {
onChange([...selected, opt]);
}
};
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && !rootRef.current?.contains(target)) {
setIsOpen(false);
setSearch('');
}
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [isOpen]);
return (
<div ref={rootRef} className="relative">
<button
type="button"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 pl-2 pr-6 text-left text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20"
onClick={() => setIsOpen(open => !open)}
>
<span className="block truncate">{label}</span>
<ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="absolute z-50 left-0 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl overflow-hidden"
>
<div className="p-2 border-b border-slate-50">
<input
type="text"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20 placeholder:text-slate-400"
placeholder="搜索批次"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-slate-50">
<button
type="button"
className="flex-1 px-2 py-1 text-[10px] font-bold text-blue-600 hover:bg-blue-50 rounded-lg"
onClick={() => onChange(options)}
>
</button>
<button
type="button"
className="flex-1 px-2 py-1 text-[10px] font-bold text-slate-400 hover:bg-slate-50 rounded-lg"
onClick={() => onChange([])}
>
</button>
</div>
<div className="max-h-44 overflow-y-auto">
{filtered.map((opt: string) => {
const checked = selectedSet.has(opt);
return (
<button
type="button"
key={opt}
className="w-full px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50 flex items-center justify-between gap-2 text-left"
onClick={() => toggle(opt)}
>
<span className="truncate">{opt}</span>
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0 ${checked ? 'bg-blue-600 border-blue-600 text-white' : 'border-slate-200 text-transparent'}`}>
<Check size={10} />
</span>
</button>
);
})}
{filtered.length === 0 && (
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default function MonitoringView() { export default function MonitoringView() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [filterDept, setFilterDept] = useState('All'); const [filterDept, setFilterDept] = useState('All');
@@ -111,21 +275,20 @@ export default function MonitoringView() {
const [filterEntity, setFilterEntity] = useState('All'); const [filterEntity, setFilterEntity] = useState('All');
const [filterRentStatus, setFilterRentStatus] = useState('All'); const [filterRentStatus, setFilterRentStatus] = useState('All');
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All'); const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
const [filterTargetName, setFilterTargetName] = useState('All'); const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
const [filterRegion, setFilterRegion] = useState('All'); const [filterRegion, setFilterRegion] = useState('All');
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' }); const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' }); const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null); const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
const [filterDate, setFilterDate] = useState(() => { const [rangeStart, setRangeStart] = useState(defaultMileageDate);
const now = new Date(); const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
});
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]); const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }); const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] }); const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] });
const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number }[]>([]);
const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() }));
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
@@ -136,6 +299,26 @@ export default function MonitoringView() {
const departments = filterOptions.departments; const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates; const plateNumbers = filterOptions.plates;
const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end);
const isRangeMode = !!effectiveRange.start && !!effectiveRange.end && effectiveRange.start !== effectiveRange.end;
const averageDailyKm = rangeDailyTotals.length > 0
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
: 0;
const topLoadedVehicle = useMemo(
() => vehicles.reduce<MonitoringVehicle | null>((best, vehicle) => (!best || vehicle.dailyKm > best.dailyKm ? vehicle : best), null),
[vehicles],
);
const applyRangePreset = useCallback((preset: RangePreset) => {
const range = getRangePreset(preset);
setRangeStart(range.start);
setRangeEnd(range.end);
}, []);
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name))
|| filterTargetNames.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name));
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
}, [filterTargetNames]);
// 加载首页数据 // 加载首页数据
const loadFirstPage = useCallback(() => { const loadFirstPage = useCallback(() => {
@@ -152,21 +335,24 @@ export default function MonitoringView() {
entity: filterEntity !== 'All' ? filterEntity : undefined, entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetName: filterTargetName !== 'All' ? filterTargetName : undefined, targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined, region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined, mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined, mileageMax: appliedMileageRange.max || undefined,
date: filterDate || undefined, startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
}).then(d => { }).then(d => {
setVehicles(d.vehicles); setVehicles(d.vehicles);
setStats(d.stats); setStats(d.stats);
setFilterOptions(d.filters); setFilterOptions(d.filters);
setRangeDailyTotals(d.rangeDailyTotals || []);
setEffectiveRange(d.dateRange || { start: rangeStart, end: rangeEnd });
setTotal(d.total); setTotal(d.total);
setPage(1); setPage(1);
setHasMore(d.page < d.totalPages); setHasMore(d.page < d.totalPages);
}).catch(() => {}).finally(() => setPageLoading(false)); }).catch(() => {}).finally(() => setPageLoading(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]); }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd]);
// 加载更多 // 加载更多
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
@@ -185,18 +371,19 @@ export default function MonitoringView() {
entity: filterEntity !== 'All' ? filterEntity : undefined, entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetName: filterTargetName !== 'All' ? filterTargetName : undefined, targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined, region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined, mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined, mileageMax: appliedMileageRange.max || undefined,
date: filterDate || undefined, startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
}).then(d => { }).then(d => {
setVehicles(prev => [...prev, ...d.vehicles]); setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage); setPage(nextPage);
setHasMore(nextPage < d.totalPages); setHasMore(nextPage < d.totalPages);
}).catch(() => {}).finally(() => setLoadingMore(false)); }).catch(() => {}).finally(() => setLoadingMore(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]); }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore]);
// 筛选/排序变化时重新加载 // 筛选/排序变化时重新加载
useEffect(() => { useEffect(() => {
@@ -228,20 +415,21 @@ export default function MonitoringView() {
entity: filterEntity !== 'All' ? filterEntity : undefined, entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetName: filterTargetName !== 'All' ? filterTargetName : undefined, targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined, region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined, mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined, mileageMax: appliedMileageRange.max || undefined,
date: filterDate || undefined, startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
}); });
exportMileageXlsx(d.vehicles, { date: filterDate, sortBy }); exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
} catch (err) { } catch (err) {
console.error('export failed', err); console.error('export failed', err);
} finally { } finally {
setExporting(false); setExporting(false);
} }
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]); }, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd]);
// 每分钟自动刷新 // 每分钟自动刷新
useEffect(() => { useEffect(() => {
@@ -307,16 +495,17 @@ export default function MonitoringView() {
customer: filterCustomer !== 'All' ? filterCustomer : undefined, customer: filterCustomer !== 'All' ? filterCustomer : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetName: filterTargetName !== 'All' ? filterTargetName : undefined, targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined, region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
date: filterDate || undefined, startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
}).then(d => { }).then(d => {
setFullscreenVehicles(d.vehicles); setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats); setFullscreenStats(d.stats);
setFilterOptions(d.filters); setFilterOptions(d.filters);
}).catch(() => {}).finally(() => setFullscreenLoading(false)); }).catch(() => {}).finally(() => setFullscreenLoading(false));
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, filterDate, fullscreenRefresh]); }, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh]);
// 全屏时禁止背景滚动 // 全屏时禁止背景滚动
useEffect(() => { useEffect(() => {
@@ -377,7 +566,7 @@ export default function MonitoringView() {
<h2 className="text-white font-bold text-xs"></h2> <h2 className="text-white font-bold text-xs"></h2>
</div> </div>
<div className="flex items-center gap-3 text-[10px]"> <div className="flex items-center gap-3 text-[10px]">
<span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span> <span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</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">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span> <span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span> <span className="text-slate-700">|</span>
@@ -403,14 +592,14 @@ export default function MonitoringView() {
<div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between"> <div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar"> <div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
<button <button
onClick={() => setFilterTargetName('All')} onClick={() => setFilterTargetNames([])}
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetName === 'All' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`} className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.length === 0 ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
></button> ></button>
{filterOptions.targetNames.map(n => ( {filterOptions.targetNames.map(n => (
<button <button
key={n} key={n}
onClick={() => setFilterTargetName(filterTargetName === n ? 'All' : n)} onClick={() => setFilterTargetNames(prev => prev.includes(n) ? prev.filter(item => item !== n) : [...prev, n])}
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetName === n ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`} className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.includes(n) ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
>{n.replace(/交投|羚牛|恒运/, '').replace(/辆/, '台')}</button> >{n.replace(/交投|羚牛|恒运/, '').replace(/辆/, '台')}</button>
))} ))}
</div> </div>
@@ -498,7 +687,7 @@ export default function MonitoringView() {
}} }}
> >
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<span></span> <span></span>
{sortBy === 'today' && ( {sortBy === 'today' && (
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} /> sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
)} )}
@@ -525,7 +714,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 +726,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 +736,8 @@ export default function MonitoringView() {
</span> </span>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -581,7 +773,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>
@@ -592,7 +784,7 @@ export default function MonitoringView() {
onClick={() => setSortBy('today')} onClick={() => setSortBy('today')}
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`} className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
> >
</button> </button>
<button <button
onClick={() => setSortBy('total')} onClick={() => setSortBy('total')}
@@ -614,10 +806,10 @@ export default function MonitoringView() {
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */} {/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 grid grid-cols-3 gap-1.5"> <div className="flex-1 grid grid-cols-3 gap-1.5">
<SearchableSelect <BatchMultiSelect
options={filterOptions.targetNames} options={filterOptions.targetNames}
value={filterTargetName} selected={filterTargetNames}
onChange={setFilterTargetName} onChange={setFilterTargetNames}
placeholder="批次型号" placeholder="批次型号"
/> />
<SearchableSelect <SearchableSelect
@@ -636,11 +828,40 @@ export default function MonitoringView() {
<button <button
onClick={() => setIsFilterOpen(!isFilterOpen)} onClick={() => setIsFilterOpen(!isFilterOpen)}
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`} className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
> >
<Filter size={16} /> <Filter size={16} />
</button> </button>
</div> </div>
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
{([
['today', '今天'],
['thisWeek', '本周'],
['thisMonth', '本月'],
['last15', '近15天'],
] as Array<[RangePreset, string]>).map(([preset, label]) => {
const range = getRangePreset(preset);
const active = rangeStart === range.start && rangeEnd === range.end;
return (
<button
key={preset}
type="button"
onClick={() => applyRangePreset(preset)}
className={`shrink-0 rounded-lg border px-2.5 py-1.5 text-[10px] font-black transition-all ${
active
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-slate-50 text-slate-500 hover:border-blue-100 hover:bg-blue-50 hover:text-blue-600'
}`}
>
{label}
</button>
);
})}
<span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">
{rangeLabel}
</span>
</div>
</div> </div>
{/* Expandable Filter Panel */} {/* Expandable Filter Panel */}
@@ -653,15 +874,42 @@ export default function MonitoringView() {
className="overflow-hidden" className="overflow-hidden"
> >
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-2 space-y-4"> <div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-2 space-y-4">
{/* Date */} {/* Date range */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label> <label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<input <input
type="date" type="date"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none" className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterDate} value={rangeStart}
onChange={(e) => setFilterDate(e.target.value)} onChange={(e) => setRangeStart(e.target.value)}
/> />
<span className="text-[10px] font-bold text-slate-300"></span>
<input
type="date"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={rangeEnd}
onChange={(e) => setRangeEnd(e.target.value)}
/>
</div>
<div className="flex items-center gap-1.5 pt-1">
{([
['today', '今天'],
['thisWeek', '本周'],
['thisMonth', '本月'],
['last7', '近7天'],
['last15', '近15天'],
] as Array<[RangePreset, string]>).map(([preset, label]) => (
<button
key={preset}
type="button"
className="rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
onClick={() => applyRangePreset(preset)}
>
{label}
</button>
))}
</div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -784,10 +1032,13 @@ export default function MonitoringView() {
setFilterProject('All'); setFilterProject('All');
setFilterEntity('All'); setFilterEntity('All');
setFilterPlatePrefix('All'); setFilterPlatePrefix('All');
setFilterTargetName('All'); setFilterTargetNames([]);
setFilterRegion('All'); setFilterRegion('All');
setFilterMileageRange({ min: '', max: '' }); setFilterMileageRange({ min: '', max: '' });
setAppliedMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
const today = defaultMileageDate();
setRangeStart(today);
setRangeEnd(today);
}} }}
className="text-[10px] font-bold text-slate-400 hover:text-slate-600" className="text-[10px] font-bold text-slate-400 hover:text-slate-600"
> >
@@ -811,7 +1062,10 @@ export default function MonitoringView() {
{/* Active Filter Tags */} {/* Active Filter Tags */}
{(() => { {(() => {
const tags: { label: string; onClear: () => void }[] = []; const tags: { label: string; onClear: () => void }[] = [];
if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') }); if (filterTargetNames.length > 0) tags.push({
label: filterTargetNames.length === filterOptions.targetNames.length ? '批次: 全部批次' : `批次: ${filterTargetNames.length === 1 ? filterTargetNames[0] : `${filterTargetNames[0]}${filterTargetNames.length}`}`,
onClear: () => setFilterTargetNames([])
});
if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => setFilterRegion('All') }); if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => setFilterRegion('All') });
if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]}${filterPlates.length}`}`, onClear: () => setFilterPlates([]) }); if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]}${filterPlates.length}`}`, onClear: () => setFilterPlates([]) });
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') }); if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
@@ -823,13 +1077,21 @@ export default function MonitoringView() {
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, min: '' })); setAppliedMileageRange(prev => ({ ...prev, min: '' })); } }); if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, min: '' })); setAppliedMileageRange(prev => ({ ...prev, min: '' })); } });
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } }); if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } });
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') }); if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') });
if (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') }); if (rangeStart || rangeEnd) tags.push({
label: `区间: ${normalizeRangeLabel(rangeStart, rangeEnd)}`,
onClear: () => {
const today = defaultMileageDate();
setRangeStart(today);
setRangeEnd(today);
}
});
if (tags.length === 0) return null; if (tags.length === 0) return null;
const clearAll = () => { const clearAll = () => {
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All'); setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All'); setFilterRegion('All'); setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetNames([]); setFilterRegion('All');
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' }); setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
setFilterDate(''); const today = defaultMileageDate();
setRangeStart(today); setRangeEnd(today);
}; };
return ( return (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -847,13 +1109,14 @@ export default function MonitoringView() {
})()} })()}
{/* Sticky header: KPI + 清单标题 */} {/* Sticky header: KPI + 清单标题 */}
<div className="sticky top-[44px] z-20 bg-[#F8F9FB] pt-1 pb-1 space-y-2"> <div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}> <div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}>
<div className="col-span-2 bg-slate-900 p-2.5 rounded-xl text-white relative overflow-hidden"> <div className="col-span-2 bg-slate-900 p-2.5 rounded-xl text-white relative overflow-hidden">
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'today' ? '今日' : '累计'}</div> <div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'today' ? (isRangeMode ? '区间' : '当日') : '累计'}</div>
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1"> <div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>} {pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
</div> </div>
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
</div> </div>
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm"> <div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
<div className="text-[7px] font-bold text-slate-400 uppercase"></div> <div className="text-[7px] font-bold text-slate-400 uppercase"></div>
@@ -866,6 +1129,76 @@ export default function MonitoringView() {
<div className="text-[7px] text-slate-400"></div> <div className="text-[7px] text-slate-400"></div>
</div> </div>
</div> </div>
<div className="rounded-xl border border-slate-100 bg-white shadow-sm overflow-hidden">
{pageLoading ? (
<div className="h-[74px] bg-slate-50 animate-pulse" />
) : isRangeMode ? (
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
<div className="min-w-0">
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
<CalendarDays size={12} className="text-blue-500" />
</div>
<div className="mt-1 text-[9px] font-bold text-slate-400">{rangeDailyTotals.length} · km</div>
</div>
<div className="h-[58px] min-w-0">
{rangeDailyTotals.length === 0 ? (
<div className="flex h-full items-center justify-center rounded-lg bg-slate-50 text-[10px] font-bold text-slate-300"></div>
) : (
<ResponsiveContainer width="100%" height={58} minWidth={0}>
<BarChart data={rangeDailyTotals} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}>
<XAxis dataKey="date" hide />
<Tooltip
formatter={(value) => [`${Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} km`, '当日里程']}
labelFormatter={(label) => `日期 ${String(label)}`}
contentStyle={{ borderRadius: 10, borderColor: '#e2e8f0', fontSize: 11 }}
cursor={{ fill: 'rgba(37, 99, 235, 0.06)' }}
/>
{averageDailyKm > 0 && <ReferenceLine y={averageDailyKm} stroke="#f59e0b" strokeDasharray="3 3" />}
<Bar dataKey="totalKm" radius={[3, 3, 0, 0]} fill="#38bdf8" />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="text-right">
<div className="text-[9px] font-black text-slate-400"></div>
<div className="text-xs font-black text-slate-800">{Math.round(averageDailyKm).toLocaleString()}</div>
<div className="text-[9px] font-bold text-slate-400">km</div>
</div>
</div>
) : (
<div className="grid grid-cols-[minmax(0,1fr)_76px_76px] items-center gap-2 px-3 py-2 md:grid-cols-[minmax(0,1fr)_96px_96px]">
<div className="min-w-0">
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
<CalendarDays size={12} className="text-blue-500" />
</div>
<div className="mt-1 truncate text-[9px] font-bold text-slate-400">{rangeLabel} · km</div>
<div className="mt-1.5 rounded-lg bg-slate-50 px-2 py-1 ring-1 ring-slate-100">
<div className="text-[8px] font-black text-slate-400"></div>
{topLoadedVehicle ? (
<div className="mt-0.5 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
<span className="whitespace-nowrap text-[10px] font-black text-slate-700">{topLoadedVehicle.plate}</span>
<span className="whitespace-nowrap text-[10px] font-black text-blue-600 tabular-nums">
{topLoadedVehicle.dailyKm.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} km
</span>
</div>
) : (
<div className="mt-0.5 text-[10px] font-black text-slate-300">-</div>
)}
</div>
</div>
<div className="rounded-lg bg-blue-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-blue-400"></div>
<div className="text-xs font-black text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
</div>
<div className="rounded-lg bg-slate-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-slate-400"></div>
<div className="text-xs font-black text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></span> <span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></span>
<span className="text-[9px] font-bold text-slate-300">{total} </span> <span className="text-[9px] font-bold text-slate-300">{total} </span>
@@ -896,7 +1229,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 }}
@@ -929,9 +1264,9 @@ export default function MonitoringView() {
{!v.isDataSynced && v.totalKm == null && ( {!v.isDataSynced && v.totalKm == null && (
<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/50 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none" title={isRangeMode ? '区间内里程' : '当日里程'}></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 +1277,8 @@ export default function MonitoringView() {
</div> </div>
</div> </div>
</motion.div> </motion.div>
))} );
})}
</div> </div>
{filteredVehicles.length === 0 && !loadingMore && ( {filteredVehicles.length === 0 && !loadingMore && (

View File

@@ -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,33 @@ 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;
const selectedRemaining = selectedAssessment?.remaining ?? 0;
const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0;
const selectedDailyTarget = selectedAssessment?.dailyTarget ?? (selectedDaysLeft > 0 ? selectedRemaining / selectedDaysLeft : 0);
const selectedQualifiedRate = selectedAssessment?.qualifiedRate ?? (selectedTarget?.vehicleCount ? selectedTarget.yearQualifiedCount / selectedTarget.vehicleCount * 100 : 0);
const latestTrend = trendData[trendData.length - 1];
const previousTrend = trendData[trendData.length - 2];
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
const pressureLevel = selectedCompletion >= 90 ? '健康' : selectedCompletion >= 70 ? '关注' : '高压';
// 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(() => {});
}, []); }, []);
@@ -67,6 +110,12 @@ export default function StatisticsView() {
// Load trend when selectedTargetId changes // Load trend when selectedTargetId changes
useEffect(() => { useEffect(() => {
if (selectedTargetId === null) return; if (selectedTargetId === null) return;
setExpandedTargetId(selectedTargetId);
if (!targetVehiclesMap[selectedTargetId]) {
fetchTargetVehicles(selectedTargetId).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [selectedTargetId]: vehicles }));
}).catch(() => {});
}
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([])); fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
}, [selectedTargetId]); }, [selectedTargetId]);
@@ -80,13 +129,16 @@ 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 => (
<button <button
key={target.id} key={target.id}
onClick={() => setSelectedTargetId(target.id)} onClick={() => {
setSelectedTargetId(target.id);
setExpandedTargetId(target.id);
}}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${ className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedTargetId === target.id selectedTargetId === target.id
? 'bg-blue-600 text-white shadow-md shadow-blue-200' ? 'bg-blue-600 text-white shadow-md shadow-blue-200'
@@ -98,12 +150,51 @@ export default function StatisticsView() {
))} ))}
</div> </div>
{selectedTarget && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-2 gap-2 md:grid-cols-4"
>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${pressureLevel === '健康' ? 'text-emerald-600' : pressureLevel === '关注' ? 'text-amber-600' : 'text-rose-600'}`}>
{pressureLevel}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400"> {fmtPercent(selectedCompletion)}</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">{fmtKm(Math.max(0, selectedRemaining))}<span className="ml-1 text-[10px] text-slate-400">km</span></div>
<div className="mt-1 text-[10px] font-bold text-slate-400"> {selectedDaysLeft} </div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className="mt-1 text-lg font-black text-blue-600">
{selectedDaysLeft > 0 ? (
<>
{fmtKm(selectedDailyTarget)}<span className="ml-1 text-[10px] text-slate-400">km</span>
</>
) : '已到期'}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400"></div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400"> {fmtPercent(selectedQualifiedRate)}</div>
</div>
</motion.div>
)}
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden"> <div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
{/* Left Side: Trend Chart / Dashboard Sidebar */} {/* Left Side: Trend Chart / Dashboard Sidebar */}
<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 +221,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>
@@ -158,9 +249,8 @@ export default function StatisticsView() {
))} ))}
</div> </div>
</div> </div>
<div className="flex-1 w-full min-h-[250px] relative"> <div className="h-[280px] w-full min-w-0">
<div className="absolute inset-0"> <ResponsiveContainer width="100%" height={280} minWidth={0}>
<ResponsiveContainer width="100%" height="100%">
{chartType === 'bar' ? ( {chartType === 'bar' ? (
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}> <BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
@@ -205,14 +295,16 @@ export default function StatisticsView() {
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Right Side: Summary Section */} {/* Right Side: Summary Section */}
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden"> <div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
<div className="flex items-center justify-between px-2 flex-shrink-0"> <div className="flex items-center justify-between px-2 flex-shrink-0">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" /> <div className="w-1 h-4 bg-blue-600 rounded-full" />
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest"></h3> <div className="min-w-0">
<h3 className="truncate text-xs font-black text-slate-700"></h3>
<p className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{selectedTarget?.targetName || '请选择车型'}</p>
</div>
</div> </div>
<button <button
onClick={() => setIsTableFullscreen(true)} onClick={() => setIsTableFullscreen(true)}
@@ -223,13 +315,18 @@ export default function StatisticsView() {
</div> </div>
<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) => ( {(selectedTarget ? [selectedTarget] : []).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(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 +346,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 +366,7 @@ export default function StatisticsView() {
</div> </div>
</div> </div>
<motion.div <motion.div
animate={{ rotate: expandedModel === target.targetName ? 180 : 0 }} animate={{ rotate: 180 }}
className="text-slate-300" className="text-slate-300"
> >
<ChevronDown size={14} /> <ChevronDown size={14} />
@@ -277,17 +374,37 @@ 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="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>
<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"> <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"></p>
{target.periods.map((p, i) => ( {target.periods.map((p, i) => (
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p> <p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
))} ))}
@@ -296,33 +413,64 @@ export default function StatisticsView() {
<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"></p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p> <p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
</div> </div>
</div>
{assessment ? (
<>
<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">{assessment.label}</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle)} km</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>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%</p> <p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}/</p>
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} </p> <p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} 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">{assessment.label}</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</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>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">(3.31)</p> <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(target.currentYearCompleted)} km</p> <p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} 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">{assessment.label}</p>
<p className="text-[10px] font-black text-rose-500">{fmtKm(target.remaining)} km</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>
<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">{assessment.label}</p>
<p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</p> <p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p>
</div> </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 +511,8 @@ export default function StatisticsView() {
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
);
})()
))} ))}
</div> </div>
</div> </div>
@@ -391,7 +541,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 +567,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 +580,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 +603,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>

View File

@@ -16,11 +16,14 @@ export async function fetchMonitoring(params?: {
rentStatus?: string; rentStatus?: string;
platePrefix?: string; platePrefix?: string;
targetName?: string; targetName?: string;
targetNames?: string[];
region?: string; region?: string;
plate?: string; plate?: string;
mileageMin?: string; mileageMin?: string;
mileageMax?: string; mileageMax?: string;
date?: string; date?: string;
startDate?: string;
endDate?: string;
}): Promise<MonitoringData> { }): Promise<MonitoringData> {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.sortBy) query.set('sortBy', params.sortBy); if (params?.sortBy) query.set('sortBy', params.sortBy);
@@ -35,11 +38,18 @@ export async function fetchMonitoring(params?: {
if (params?.rentStatus) query.set('rentStatus', params.rentStatus); if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
if (params?.platePrefix) query.set('platePrefix', params.platePrefix); if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
if (params?.targetName) query.set('targetName', params.targetName); if (params?.targetName) query.set('targetName', params.targetName);
if (params?.targetNames) {
params.targetNames.forEach(name => {
if (name) query.append('targetName', name);
});
}
if (params?.region) query.set('region', params.region); if (params?.region) query.set('region', params.region);
if (params?.plate) query.set('plate', params.plate); if (params?.plate) query.set('plate', params.plate);
if (params?.mileageMin) query.set('mileageMin', params.mileageMin); if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
if (params?.mileageMax) query.set('mileageMax', params.mileageMax); if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
if (params?.date) query.set('date', params.date); if (params?.date) query.set('date', params.date);
if (params?.startDate) query.set('startDate', params.startDate);
if (params?.endDate) query.set('endDate', params.endDate);
const qs = query.toString(); const qs = query.toString();
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`); return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
} }
@@ -87,3 +97,135 @@ export async function fetchVehicleRecent(
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}` `${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
); );
} }
export interface DailyReportModel {
id: number;
name: string;
count: number;
today: number;
total: number;
completion: number;
active: number;
zero: number;
dailyNeed: number;
}
export interface DailyReportVehicle {
plate: string;
model: string;
status: string;
customer: string;
today?: number;
completion?: number;
}
export interface DailyReportData {
reportDate: string;
updatedAt: string;
models: DailyReportModel[];
trend: { date: string; value: number }[];
topVehicles: DailyReportVehicle[];
zeroRisk: DailyReportVehicle[];
qualifiedCount: number;
halfQualifiedCount: number;
}
function reportDateFromUpdatedAt(updatedAt: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(updatedAt)) return updatedAt;
const d = new Date(updatedAt);
if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10);
return new Date().toISOString().slice(0, 10);
}
function compactTargetName(name: string): string {
return name
.replace(/^羚牛/, '')
.replace(/辆/g, '台')
.replace(/4\.5T普货/g, '普货')
.replace(/4\.5T冷链车/g, '冷链车')
.replace(/4\.5T冷链/g, '冷链车');
}
function normalizeStatus(status: string | null): string {
if (!status) return '未标注';
if (status === '自营' || status === '租赁') return status;
if (/租/.test(status)) return '租赁';
if (/自/.test(status)) return '自营';
if (/库|Inventory/i.test(status)) return '在库';
return status;
}
export async function fetchDailyReport(): Promise<DailyReportData> {
const [targets, trend, monitoring, topMonitoring] = await Promise.all([
fetchTargets(),
fetchTrend(undefined, 7),
fetchMonitoring({ limit: 1 }),
fetchMonitoring({ sortBy: 'today', sortOrder: 'desc', limit: 5 }),
]);
const targetVehiclesEntries = await Promise.all(
targets.map(async target => {
const vehicles = await fetchTargetVehicles(target.id);
return [target.id, vehicles] as const;
}),
);
const targetVehiclesMap = new Map(targetVehiclesEntries);
const models: DailyReportModel[] = targets.map(target => {
const vehicles = targetVehiclesMap.get(target.id) ?? [];
const active = vehicles.filter(vehicle => vehicle.todayMileage > 0).length;
return {
id: target.id,
name: compactTargetName(target.targetName),
count: target.vehicleCount,
today: target.todayTotal,
total: target.cumulativeTotal,
completion: target.avgCompletion,
active,
zero: Math.max(0, target.vehicleCount - active),
dailyNeed: target.dailyTarget,
};
});
const targetNameByPlate = new Map<string, string>();
for (const target of targets) {
const vehicles = targetVehiclesMap.get(target.id) ?? [];
for (const vehicle of vehicles) targetNameByPlate.set(vehicle.plateNumber, compactTargetName(target.targetName));
}
const topVehicles: DailyReportVehicle[] = topMonitoring.vehicles.map(vehicle => ({
plate: vehicle.plate,
model: targetNameByPlate.get(vehicle.plate) || vehicle.project || '未归入考核',
status: normalizeStatus(vehicle.rentStatus),
today: vehicle.dailyKm,
customer: vehicle.customer || '未绑定客户',
}));
const zeroRisk = targetVehiclesEntries
.flatMap(([targetId, vehicles]) => {
const target = targets.find(item => item.id === targetId);
const model = target ? compactTargetName(target.targetName) : '未归入考核';
return vehicles
.filter(vehicle => vehicle.todayMileage <= 0 && ['自营', '租赁'].includes(normalizeStatus(vehicle.rentStatus)))
.map(vehicle => ({
plate: vehicle.plateNumber,
model,
status: normalizeStatus(vehicle.rentStatus),
customer: vehicle.customer || '未绑定客户',
completion: vehicle.completionRate,
}));
})
.sort((a, b) => (b.completion ?? 0) - (a.completion ?? 0))
.slice(0, 5);
return {
reportDate: reportDateFromUpdatedAt(monitoring.updatedAt),
updatedAt: monitoring.updatedAt,
models,
trend: trend.map(item => ({ date: item.date, value: item.mileage })),
topVehicles,
zeroRisk,
qualifiedCount: targets.reduce((sum, target) => sum + target.yearQualifiedCount, 0),
halfQualifiedCount: targets.reduce((sum, target) => sum + target.halfQualifiedCount, 0),
};
}

View File

@@ -2,6 +2,7 @@ export interface MonitoringVehicle {
plate: string; plate: string;
vin: string; vin: string;
dailyKm: number; dailyKm: number;
dailyMileage?: Record<string, number>;
totalKm: number | null; totalKm: number | null;
source: string; source: string;
isOnline: boolean; isOnline: boolean;
@@ -13,6 +14,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 {
@@ -38,6 +40,8 @@ export interface MonitoringData {
vehicles: MonitoringVehicle[]; vehicles: MonitoringVehicle[];
stats: MonitoringStats; stats: MonitoringStats;
filters: MonitoringFilters; filters: MonitoringFilters;
rangeDailyTotals?: { date: string; totalKm: number }[];
dateRange?: { start: string; end: string };
total: number; total: number;
page: number; page: number;
totalPages: number; totalPages: number;
@@ -63,6 +67,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 {

View File

@@ -2,13 +2,15 @@ import * as XLSX from 'xlsx';
import type { MonitoringVehicle } from './types'; import type { MonitoringVehicle } from './types';
interface ExportContext { interface ExportContext {
date: string; date?: string;
startDate?: string;
endDate?: string;
sortBy: 'today' | 'total'; sortBy: 'today' | 'total';
} }
const HEADERS = [ const HEADERS = [
'状态', '车牌号', '客户', '业务部门', '项目', '租赁状态', '状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
'运营区域', '今日里程(km)', '累计里程(km)', '运营区域', '区间里程(km)', '累计里程(km)',
] as const; ] as const;
function statusLabel(v: MonitoringVehicle): string { function statusLabel(v: MonitoringVehicle): string {
@@ -18,15 +20,18 @@ function statusLabel(v: MonitoringVehicle): string {
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number { function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
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 {
const data: (string | number)[][] = [ const start = ctx.startDate || ctx.date || '';
const end = ctx.endDate || ctx.date || '';
const isRange = !!start && !!end && start !== end;
const summaryData: (string | number)[][] = [
[...HEADERS], [...HEADERS],
...vehicles.map(v => [ ...vehicles.map(v => [
statusLabel(v), statusLabel(v),
@@ -41,7 +46,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
]), ]),
]; ];
const ws = XLSX.utils.aoa_to_sheet(data); const ws = XLSX.utils.aoa_to_sheet(summaryData);
ws['!cols'] = [ ws['!cols'] = [
{ wch: 8 }, // 状态 { wch: 8 }, // 状态
@@ -51,12 +56,19 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
{ wch: 16 }, // 项目 { wch: 16 }, // 项目
{ wch: 10 }, // 租赁状态 { wch: 10 }, // 租赁状态
{ wch: 12 }, // 运营区域 { wch: 12 }, // 运营区域
{ wch: 14 }, // 今日里程 { wch: 14 }, // 区间里程
{ wch: 14 }, // 累计里程 { wch: 14 }, // 累计里程
]; ];
ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never; ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
for (let r = 1; r < summaryData.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 });
@@ -70,7 +82,53 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
} }
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '里程明细'); XLSX.utils.book_append_sheet(wb, ws, '车辆汇总');
const dayKeys = Array.from(
new Set(vehicles.flatMap(v => Object.keys(v.dailyMileage || {})))
).sort();
if (dayKeys.length > 0) {
const detailHeaders = [
'车牌号', '客户', '业务部门', '项目', '租赁状态', '运营区域',
...dayKeys.map(day => `${day}里程(km)`),
'区间合计(km)',
'累计里程(km)',
];
const detailData: (string | number)[][] = [
detailHeaders,
...vehicles.map(v => [
v.plate,
v.customer || '',
v.department || '',
v.project || '',
v.rentStatus || '',
v.region || '',
...dayKeys.map(day => v.dailyMileage?.[day] || 0),
Math.max(0, v.dailyKm || 0),
v.totalKm != null ? v.totalKm : '',
]),
];
const detailWs = XLSX.utils.aoa_to_sheet(detailData);
detailWs['!cols'] = [
{ wch: 12 },
{ wch: 28 },
{ wch: 14 },
{ wch: 16 },
{ wch: 10 },
{ wch: 12 },
...dayKeys.map(() => ({ wch: 14 })),
{ wch: 14 },
{ wch: 14 },
];
detailWs['!freeze'] = { xSplit: 6, ySplit: 1 } as never;
for (let r = 1; r < detailData.length; r++) {
for (let c = 6; c < detailHeaders.length; c++) {
const ref = XLSX.utils.encode_cell({ r, c });
if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########';
}
}
XLSX.utils.book_append_sheet(wb, detailWs, '每日明细');
}
const now = new Date(); const now = new Date();
const y = now.getFullYear(); const y = now.getFullYear();
@@ -78,7 +136,9 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
const d = String(now.getDate()).padStart(2, '0'); const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0'); const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0');
const dateTag = ctx.date ? ctx.date.replace(/-/g, '') : `${y}${m}${d}`; const dateTag = start && end
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? '今日' : '累计'}.xlsx`; ? `${start.replace(/-/g, '')}-${end.replace(/-/g, '')}`
: `${y}${m}${d}`;
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? (isRange ? '区间' : '今日') : '累计'}.xlsx`;
XLSX.writeFile(wb, filename); XLSX.writeFile(wb, filename);
} }

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download } from 'lucide-react'; import { Activity, AlertTriangle, CheckCircle2, Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download, SendHorizonal } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { fetchSuggestions, sendNotifyBatch } from './api'; import { fetchSuggestions, sendNotifyBatch } from './api';
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types'; import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
@@ -9,6 +9,7 @@ import NotificationHistory from './NotificationHistory';
import { exportSuggestionsCsv } from './csv-export'; import { exportSuggestionsCsv } from './csv-export';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { MetricTile, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
type TypeFilter = 'all' | 'qualified' | 'hopeless'; type TypeFilter = 'all' | 'qualified' | 'hopeless';
@@ -87,63 +88,43 @@ function FilterSelect({ label, options, value, onChange, placeholder }: {
); );
} }
/** Skeleton pulse block */
function Sk({ className }: { className?: string }) { function Sk({ className }: { className?: string }) {
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />; return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
} }
function SkeletonPage() { function SkeletonPage() {
return ( return (
<div className="min-h-screen bg-[#F0F4F8] font-sans p-3 md:p-6"> <PageFrame
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0"> title="智能调度工作台"
{/* Cards skeleton */} subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
<div className="grid grid-cols-3 gap-2.5"> icon={Activity}
{[0, 1, 2].map(i => ( eyebrow="SCHEDULING OPS"
<div key={i} className="p-4 rounded-2xl bg-white border border-slate-100 space-y-2.5"> meta="建议生成中 · 正在计算候选车辆"
<Sk className="h-3 w-16" /> >
<Sk className="h-7 w-12" /> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Sk className="h-2.5 w-24" /> {Array.from({ length: 4 }).map((_, i) => <SkeletonBlock key={i} className="h-28" />)}
</div>
))}
</div>
{/* List card skeleton */}
<div className="bg-white rounded-2xl border border-slate-200/60 overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100 space-y-3">
<div className="flex items-center justify-between">
<Sk className="h-4 w-28" />
<div className="flex gap-2"><Sk className="h-6 w-6 rounded-lg" /><Sk className="h-6 w-6 rounded-lg" /></div>
</div> </div>
<SurfaceCard>
<div className="space-y-3 p-4">
<SkeletonBlock className="h-5 w-40" />
<div className="flex gap-2"> <div className="flex gap-2">
{[0, 1, 2, 3].map(i => <Sk key={i} className="h-7 w-20 rounded-full" />)} {[0, 1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-24 rounded-full" />)}
</div> </div>
</div>
{/* Rows */}
<div className="divide-y divide-slate-50"> <div className="divide-y divide-slate-50">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="px-4 py-3 flex items-center gap-3"> <div key={i} className="flex items-center gap-3 py-3">
<Sk className="w-1 h-10 rounded-full" /> <SkeletonBlock className="h-10 w-1 rounded-full" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="flex items-center gap-2"> <SkeletonBlock className="h-3.5 w-48" />
<Sk className="h-3.5 w-20" /> <SkeletonBlock className="h-2.5 w-72 max-w-full" />
<Sk className="h-3 w-10 rounded-full" />
<Sk className="h-3 w-14" />
</div> </div>
<div className="flex items-center gap-3"> <SkeletonBlock className="h-6 w-16" />
<Sk className="h-2.5 w-28" />
<Sk className="h-2.5 w-16" />
<Sk className="h-2.5 w-14" />
</div>
</div>
<Sk className="h-4 w-8" />
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </SurfaceCard>
</div> </PageFrame>
); );
} }
@@ -275,89 +256,33 @@ export default function SchedulingModule() {
if (loading && !data) return <SkeletonPage />; if (loading && !data) return <SkeletonPage />;
return ( return (
<div className="min-h-screen bg-[#F0F4F8] text-slate-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}> <PageFrame
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0"> title="智能调度工作台"
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
icon={Activity}
eyebrow="SCHEDULING OPS"
meta={`当前 ${filteredSuggestions.length} 条建议 · ${activeFilterCount} 个筛选条件`}
actions={(
<button onClick={loadData} disabled={loading} className="inline-flex items-center gap-1.5 rounded-xl bg-slate-900 px-3 py-2 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800 disabled:opacity-60">
<RotateCcw size={14} className={loading ? 'animate-spin' : ''} />
</button>
)}
>
{/* ===== Summary Cards ===== */} {/* ===== Summary Cards ===== */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{/* 里程高·换下 — warm orange */} <button type="button" onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')} className={typeFilter === 'qualified' ? 'rounded-2xl ring-2 ring-orange-400 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
<button <MetricTile icon={CheckCircle2} label="已完成考核目标" value={summary?.qualifiedCount ?? 0} unit="台" helper="换下,腾位给待达标车" tone="amber" />
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'qualified'
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
: 'bg-gradient-to-br from-orange-50 to-amber-50 border border-orange-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'qualified' ? 'text-orange-100' : 'text-orange-600'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'qualified' ? 'text-white' : 'text-orange-700'}`}>
{loading && !data ? '-' : summary?.qualifiedCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}>
</div>
</button> </button>
<button type="button" onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')} className={typeFilter === 'hopeless' ? 'rounded-2xl ring-2 ring-blue-500 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
{/* 里程低·换走 — cool blue */} <MetricTile icon={AlertTriangle} label="预估无法达标" value={summary?.hopelessCount ?? 0} unit="台" helper="换走,换上快达标的车" tone="blue" />
<button
onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'hopeless'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/25'
: 'bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'hopeless' ? 'text-blue-100' : 'text-blue-600'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'hopeless' ? 'text-white' : 'text-blue-700'}`}>
{loading && !data ? '-' : summary?.hopelessCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}>
</div>
</button> </button>
<button type="button" onClick={() => setTypeFilter('all')} className={typeFilter === 'all' ? 'rounded-2xl ring-2 ring-slate-700 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
{/* 替换建议 — neutral dark */} <MetricTile icon={Activity} label="替换建议" value={summary?.suggestionCount ?? 0} unit="条" helper={`执行后预计 +${summary?.estimatedGain ?? 0} 台达标`} tone="slate" />
<button
onClick={() => setTypeFilter('all')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'all'
? 'bg-slate-800 text-white shadow-lg shadow-slate-800/25'
: 'bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'all' ? 'text-slate-300' : 'text-slate-500'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'all' ? 'text-white' : 'text-slate-800'}`}>
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}>
+{summary?.estimatedGain ?? 0}
</div>
</button> </button>
<button type="button" onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }} className="rounded-2xl">
{/* 近期已干预 — emerald */} <MetricTile icon={SendHorizonal} label="近期已干预" value={summary?.recentInterventionCount ?? 0} unit="条" helper="最近 7 天 · 点击查看" tone="emerald" />
<button
onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
className="p-3.5 rounded-2xl text-left transition-all cursor-pointer bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200/60"
>
<div className="text-[10px] font-bold mb-1 text-emerald-600">
</div>
<div className="text-2xl font-black text-emerald-700">
{loading && !data ? '-' : summary?.recentInterventionCount ?? 0}
<span className="text-[10px] font-normal ml-1 text-emerald-400"></span>
</div>
<div className="text-[9px] mt-0.5 text-emerald-400">
7 ·
</div>
</button> </button>
</div> </div>
@@ -632,8 +557,7 @@ export default function SchedulingModule() {
</motion.div> </motion.div>
</div> </div>
)} )}
</div>
<RotatingFooterHint className="pb-4" /> <RotatingFooterHint className="pb-4" />
</div> </PageFrame>
); );
} }

View File

@@ -52,11 +52,15 @@ app.get('/exchange', async (c) => {
// 查询 depCode 对应的部门名称 // 查询 depCode 对应的部门名称
let depName = ''; let depName = '';
if (userInfo.depCode) { if (userInfo.depCode) {
try {
const [rows] = await pool.execute( const [rows] = await pool.execute(
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1', 'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
[userInfo.depCode] [userInfo.depCode]
) as [{ dep_name: string }[], unknown]; ) as [{ dep_name: string }[], unknown];
depName = rows[0]?.dep_name || ''; depName = rows[0]?.dep_name || '';
} catch (e: any) {
if (e?.code !== 'ER_NO_SUCH_TABLE') throw e;
}
} }
const payload: JwtPayload = { const payload: JwtPayload = {

17
src/server/hydrogen-db.ts Normal file
View 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;

View File

@@ -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,

View File

@@ -149,8 +149,8 @@ async function buildPlateLookup(plates: Set<string>): Promise<Map<string, string
const placeholders = arr.map(() => '?').join(','); const placeholders = arr.map(() => '?').join(',');
const [rows] = await pool.query<RowDataPacket[]>( const [rows] = await pool.query<RowDataPacket[]>(
`SELECT plate_number, CAST(id AS CHAR) AS truck_id `SELECT plate_number, CAST(id AS CHAR) AS truck_id
FROM tab_truck FROM vehicle_info
WHERE is_deleted = 0 AND plate_number IN (${placeholders})`, WHERE del_flag = '0' AND plate_number IN (${placeholders})`,
arr, arr,
); );
const map = new Map<string, string>(); const map = new Map<string, string>();

View File

@@ -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,20 +20,70 @@ 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';
} }
type Range = 'thisWeek' | 'thisMonth' | 'last15'; type Range = 'thisWeek' | 'thisMonth' | 'last15';
interface DateRange {
start: string;
end: string;
}
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function parseYmd(value: string | undefined): string | null {
if (!value || !YMD_RE.test(value)) return null;
const d = new Date(`${value}T00:00:00`);
return Number.isNaN(d.getTime()) ? null : value;
}
function resolveDateRange(range: Range, startParam?: string, endParam?: string): DateRange {
const customStart = parseYmd(startParam);
const customEnd = parseYmd(endParam);
if (customStart && customEnd) {
return customStart <= customEnd
? { start: customStart, end: customEnd }
: { start: customEnd, end: customStart };
}
const today = new Date();
today.setHours(0, 0, 0, 0);
if (range === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (range === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
function dateRangeClause(localExpr: string): string {
return `${localExpr} >= ? AND ${localExpr} < DATE_ADD(?, INTERVAL 1 DAY)`;
}
function rangeClause(localExpr: string, range: Range): string { function rangeClause(localExpr: string, range: Range): string {
switch (range) { switch (range) {
@@ -44,25 +95,16 @@ function rangeClause(localExpr: string, range: Range): string {
/** 列出某 range 在当前时点下的全部日期YYYY-MM-DD用于补零 */ /** 列出某 range 在当前时点下的全部日期YYYY-MM-DD用于补零 */
function enumerateDates(range: Range): string[] { function enumerateDates(range: Range): string[] {
const today = new Date(); const { start, end } = resolveDateRange(range);
today.setHours(0, 0, 0, 0); return enumerateDateRange(start, end);
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
let start: Date;
if (range === 'thisWeek') { function enumerateDateRange(startYmd: string, endYmd: string): string[] {
// 周一为一周开始(与 YEARWEEK(?, 1) 一致)
const day = today.getDay() || 7; // 周日 7
start = new Date(today);
start.setDate(today.getDate() - (day - 1));
} else if (range === 'thisMonth') {
start = new Date(today.getFullYear(), today.getMonth(), 1);
} else {
start = new Date(today);
start.setDate(today.getDate() - 14);
}
const result: string[] = []; const result: string[] = [];
const cur = new Date(start); const cur = new Date(`${startYmd}T00:00:00`);
while (cur <= today) { const end = new Date(`${endYmd}T00:00:00`);
result.push(fmt(cur)); while (cur <= end) {
result.push(fmtYmd(cur));
cur.setDate(cur.getDate() + 1); cur.setDate(cur.getDate() + 1);
} }
return result; return result;
@@ -80,10 +122,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 +134,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 +210,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 +237,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 +264,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 +301,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 +334,16 @@ app.get('/hydrogen/overview', async (c) => {
} }
// 客户账单 Top指定年份按加氢量降序前 30 // 客户账单 Top指定年份按加氢量降序前 30
// payercost_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
@@ -325,39 +369,37 @@ app.get('/hydrogen/overview', async (c) => {
// ========================================================= // =========================================================
app.get('/hydrogen/daily', async (c) => { app.get('/hydrogen/daily', async (c) => {
const range = (c.req.query('range') || 'last15') as Range; const range = (c.req.query('range') || 'last15') as Range;
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
const customer = (c.req.query('customer') || 'external') as CustomerKind; const customer = (c.req.query('customer') || 'external') as CustomerKind;
const force = c.req.query('force') === '1'; const force = c.req.query('force') === '1';
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => { const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&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), dateRangeClause(`b.${HYDROGEN_LOCAL}`),
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`,
[dateRange.start, dateRange.end],
); );
// 站点环比:同站点上一条记录的 kg // 站点环比:同站点上一条记录的 kg
@@ -407,14 +449,14 @@ app.get('/hydrogen/daily', async (c) => {
} }
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[] // 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
const allDates = enumerateDates(range); const allDates = enumerateDateRange(dateRange.start, dateRange.end);
const fullDays = allDates.map(date => { const fullDays = allDates.map(date => {
const info = dayMap.get(date); const info = dayMap.get(date);
return { return {
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,
@@ -531,9 +573,10 @@ app.get('/electric/overview', async (c) => {
app.get('/electric/monthly', async (c) => { app.get('/electric/monthly', async (c) => {
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind; const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
const range = (c.req.query('range') || 'last15') as Range; const range = (c.req.query('range') || 'last15') as Range;
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
const force = c.req.query('force') === '1'; const force = c.req.query('force') === '1';
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => { const data = await cached(`electric/monthly?customer=${customer}&start=${dateRange.start}&end=${dateRange.end}`, async () => {
// bi_ele_charge_record 用 vehicle_kind 区分internal=我司external=外部 // bi_ele_charge_record 用 vehicle_kind 区分internal=我司external=外部
let kindClause = '1=1'; let kindClause = '1=1';
@@ -546,8 +589,9 @@ app.get('/electric/monthly', async (c) => {
SUM(fee) AS fee SUM(fee) AS fee
FROM bi_ele_charge_record FROM bi_ele_charge_record
WHERE ${kindClause} WHERE ${kindClause}
AND ${rangeClause('start_time', range)} AND ${dateRangeClause('start_time')}
GROUP BY date`, GROUP BY date`,
[dateRange.start, dateRange.end],
); );
// 实际数据 map // 实际数据 map
@@ -560,7 +604,7 @@ app.get('/electric/monthly', async (c) => {
} }
// 补零:枚举 range 全部日期 // 补零:枚举 range 全部日期
const allDates = enumerateDates(range); const allDates = enumerateDateRange(dateRange.start, dateRange.end);
const fullDays = allDates.map(date => { const fullDays = allDates.map(date => {
const d = dataMap.get(date); const d = dataMap.get(date);
return { return {

View File

@@ -65,12 +65,62 @@ interface MileageRow {
source: string; source: string;
} }
interface DailyMileageRow {
plate: string;
vin: string | null;
date: string;
daily_km: string | number | null;
source: string | null;
}
export interface RangeMileageResult {
vehicles: CachedVehicle[];
dailyTotals: { date: string; totalKm: number }[];
start: string;
end: 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 lingniu_prod.tab_mileage_assessment_target t
JOIN lingniu_prod.tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`
).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 数据源常为 NULLG7 只回传日增量), // v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULLG7 只回传日增量),
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值, // 业务库 lingniu_prod.tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
// 用它兜底保证 totalKm 汇总完整。 // 用它兜底保证 totalKm 汇总完整。
const [rows] = await pool.execute( const [rows] = await pool.execute(
'SELECT plate_number, vehicle_total_mileage FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0' 'SELECT plate_number, vehicle_total_mileage FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ plate_number: string; vehicle_total_mileage: string | number | null }[], unknown]; ) as [{ plate_number: string; vehicle_total_mileage: string | number | null }[], unknown];
const map = new Map<string, number>(); const map = new Map<string, number>();
for (const r of rows) { for (const r of rows) {
@@ -115,6 +165,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) {
@@ -124,30 +175,32 @@ function mergeVehicles(
} }
} }
return Array.from(mileageMap.values()).map(m => { return Array.from(infoMap.values()).map(info => {
const info = infoMap.get(m.plate); const m = mileageMap.get(info.plate);
const dailyKm = Number(m.daily_km) || 0; const plate = info.plate;
const source = m.source || 'NONE'; const dailyKm = Number(m?.daily_km) || 0;
const gpsTotal = m.total_km !== null ? Number(m.total_km) : null; const source = m?.source || 'NONE';
const latestPgTotal = latestPgTotalMap.get(m.plate); const gpsTotal = m?.total_km != null ? Number(m.total_km) : null;
const bizTotal = bizTotalMap.get(m.plate); const latestPgTotal = latestPgTotalMap.get(plate);
const bizTotal = bizTotalMap.get(plate);
return { return {
plate: m.plate, plate,
vin: m.vin, vin: m?.vin || info.vin || '',
dailyKm, dailyKm,
totalKm: gpsTotal !== null ? gpsTotal : (latestPgTotal ?? bizTotal ?? null), totalKm: gpsTotal !== null ? gpsTotal : (latestPgTotal ?? bizTotal ?? null),
source, source,
isOnline: source !== 'NONE' && dailyKm > 0, isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE', isDataSynced: source !== 'NONE',
customer: info?.customer || null, customer: info.customer || null,
department: info?.department || null, department: info.department || null,
manager: info?.manager || null, manager: info.manager || null,
managerId: info?.manager_id || null, managerId: info.manager_id || null,
rentStatus: info?.rent_status || null, rentStatus: info.rent_status || null,
entity: info?.entity || null, entity: info.entity || null,
project: info?.project || null, project: info.project || null,
region: regionMap[m.plate] || null, region: regionMap[plate] || null,
yesterdayKm: yesterdayMap.get(m.plate) || 0, targetNames: targetNamesByPlate.get(plate) || [],
yesterdayKm: yesterdayMap.get(plate) || 0,
}; };
}); });
} }
@@ -184,25 +237,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 +265,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 +275,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 +287,118 @@ 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),
);
}
function datesBetween(start: string, end: string): string[] {
const result: string[] = [];
const [sy, sm, sd] = start.split('-').map(Number);
const [ey, em, ed] = end.split('-').map(Number);
const cursor = new Date(sy, sm - 1, sd);
const last = new Date(ey, em - 1, ed);
cursor.setHours(0, 0, 0, 0);
last.setHours(0, 0, 0, 0);
while (cursor <= last) {
result.push(`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`);
cursor.setDate(cursor.getDate() + 1);
}
return result;
}
export async function queryRangeMileage(startDate: string, endDate: string): Promise<RangeMileageResult> {
const days = datesBetween(startDate, endDate);
const [dailyRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
mileagePool.execute(
`SELECT plate,
DATE_FORMAT(stat_date, '%Y-%m-%d') AS date,
vin,
daily_km,
source
FROM v_vehicle_daily_stats
WHERE stat_date >= ? AND stat_date <= ?
ORDER BY stat_date, plate`,
[startDate, endDate]
).then(([r]) => r as DailyMileageRow[]),
mileagePool.execute(
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
[startDate]
).then(([r]) => r as { plate: string; daily_km: string }[]),
fetchVehicleInfoMap(),
fetchTargetRows(),
fetchBizTotalMileageMap(),
fetchLatestPgTotalMileageMap(endDate),
]);
const perVehicleDaily = new Map<string, Record<string, number>>();
const perVehicleSum = new Map<string, { plate: string; vin: string; daily_km: string; total_km: null; source: string }>();
const dailyTotals = new Map<string, number>();
const bestDailyRows = new Map<string, DailyMileageRow>();
for (const day of days) dailyTotals.set(day, 0);
for (const row of dailyRows) {
const key = `${row.plate}\u0000${row.date}`;
const km = Math.max(0, Number(row.daily_km) || 0);
const existing = bestDailyRows.get(key);
if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) {
bestDailyRows.set(key, row);
}
}
for (const row of bestDailyRows.values()) {
const km = Math.max(0, Number(row.daily_km) || 0);
const date = row.date;
const plate = row.plate;
dailyTotals.set(date, (dailyTotals.get(date) || 0) + km);
const daily = perVehicleDaily.get(plate) || {};
daily[date] = km;
perVehicleDaily.set(plate, daily);
const existing = perVehicleSum.get(plate);
perVehicleSum.set(plate, {
plate,
vin: existing?.vin || row.vin || '',
daily_km: String((Number(existing?.daily_km) || 0) + km),
total_km: null,
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
});
}
const yesterdayMap = new Map<string, number>();
for (const r of yesterdayRows) {
const km = Number(r.daily_km) || 0;
const existing = yesterdayMap.get(r.plate) || 0;
if (km > existing) yesterdayMap.set(r.plate, km);
}
const vehicles = mergeVehicles(
Array.from(perVehicleSum.values()),
infoMap,
yesterdayMap,
bizTotalMap,
latestPgTotalMap,
buildPlateTargetNamesMap(targetRows),
).map(vehicle => {
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
const completedDailyMileage: Record<string, number> = {};
for (const day of days) completedDailyMileage[day] = dailyMileage[day] || 0;
return { ...vehicle, dailyMileage: completedDailyMileage };
});
return {
vehicles,
dailyTotals: days.map(date => ({ date, totalKm: dailyTotals.get(date) || 0 })),
start: startDate,
end: endDate,
};
} }
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters { export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { getCache, queryDateMileage, buildDateFilters } from './cache.js'; import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from './cache.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js'; import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import type { AuthUser } from '../../auth/types.js'; import type { AuthUser } from '../../auth/types.js';
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js'; import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
@@ -19,7 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
function applyFilters(vehicles: CachedVehicle[], params: { function applyFilters(vehicles: CachedVehicle[], params: {
search: string; dept: string; customer: string; project: string; search: string; dept: string; customer: string; project: string;
entity: string; rentStatus: string; plate: string; platePrefix: string; entity: string; rentStatus: string; plate: string; platePrefix: string;
targetName: string; region: string; mileageMin: string; mileageMax: string; targetNames: string[]; region: string; mileageMin: string; mileageMax: string;
}): CachedVehicle[] { }): CachedVehicle[] {
let result = vehicles; let result = vehicles;
@@ -42,10 +42,9 @@ function applyFilters(vehicles: CachedVehicle[], params: {
} }
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix)); if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
if (params.region) result = result.filter(v => v.region === params.region); if (params.region) result = result.filter(v => v.region === params.region);
if (params.targetName) { if (params.targetNames.length > 0) {
const cache = getCache(); const selectedTargets = new Set(params.targetNames);
const tPlates = cache?.targetPlatesMap.get(params.targetName); result = result.filter(v => v.targetNames.some(targetName => selectedTargets.has(targetName)));
result = tPlates ? result.filter(v => tPlates.has(v.plate)) : [];
} }
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin)); if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax)); if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
@@ -53,12 +52,52 @@ function applyFilters(vehicles: CachedVehicle[], params: {
return result; return result;
} }
function parseTargetNames(reqUrl: string): string[] {
const params = new URL(reqUrl).searchParams;
const raw = [
...params.getAll('targetName'),
...params.getAll('targetNames'),
];
const names = raw.flatMap(item => item.split(','))
.map(item => item.trim())
.filter(Boolean);
return Array.from(new Set(names));
}
function parseYmd(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return null;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
date.setHours(0, 0, 0, 0);
return Number.isFinite(date.getTime()) ? date : null;
}
function fmtYmd(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function normalizeRange(startQuery: string, endQuery: string): { start: string; end: string } | null {
if (!startQuery && !endQuery) return null;
const start = parseYmd(startQuery || endQuery);
const end = parseYmd(endQuery || startQuery);
if (!start || !end) return null;
const a = start <= end ? start : end;
let b = start <= end ? end : start;
const span = Math.round((b.getTime() - a.getTime()) / 86400000) + 1;
if (span > 366) {
b = new Date(a);
b.setDate(a.getDate() + 365);
}
return { start: fmtYmd(a), end: fmtYmd(b) };
}
app.get('/', async (c) => { app.get('/', async (c) => {
const sortBy = c.req.query('sortBy') || 'today'; const sortBy = c.req.query('sortBy') || 'today';
const sortOrder = c.req.query('sortOrder') || 'desc'; const sortOrder = c.req.query('sortOrder') || 'desc';
const limit = Number(c.req.query('limit')) || 50; const limit = Number(c.req.query('limit')) || 50;
const page = Number(c.req.query('page')) || 1; const page = Number(c.req.query('page')) || 1;
const date = c.req.query('date') || ''; const date = c.req.query('date') || '';
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
const filterParams = { const filterParams = {
search: c.req.query('search') || '', search: c.req.query('search') || '',
@@ -69,7 +108,7 @@ app.get('/', async (c) => {
rentStatus: c.req.query('rentStatus') || '', rentStatus: c.req.query('rentStatus') || '',
plate: c.req.query('plate') || '', plate: c.req.query('plate') || '',
platePrefix: c.req.query('platePrefix') || '', platePrefix: c.req.query('platePrefix') || '',
targetName: c.req.query('targetName') || '', targetNames: parseTargetNames(c.req.url),
region: c.req.query('region') || '', region: c.req.query('region') || '',
mileageMin: c.req.query('mileageMin') || '', mileageMin: c.req.query('mileageMin') || '',
mileageMax: c.req.query('mileageMax') || '', mileageMax: c.req.query('mileageMax') || '',
@@ -77,8 +116,21 @@ app.get('/', async (c) => {
let allVehicles: CachedVehicle[]; let allVehicles: CachedVehicle[];
let filters: MonitoringFilters; let filters: MonitoringFilters;
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
let dateRange: { start: string; end: string } | undefined;
if (date) { if (range) {
try {
const result = await queryRangeMileage(range.start, range.end);
allVehicles = result.vehicles;
rangeDailyTotals = result.dailyTotals;
dateRange = { start: result.start, end: result.end };
filters = buildDateFilters(allVehicles);
} catch (e: unknown) {
console.error('monitoring range query error:', e);
return c.json(EMPTY_RESPONSE, 500);
}
} else if (date) {
try { try {
allVehicles = await queryDateMileage(date); allVehicles = await queryDateMileage(date);
filters = buildDateFilters(allVehicles); filters = buildDateFilters(allVehicles);
@@ -107,6 +159,12 @@ app.get('/', async (c) => {
} }
const filtered = applyFilters(allVehicles, filterParams); const filtered = applyFilters(allVehicles, filterParams);
if (rangeDailyTotals && filtered.length !== allVehicles.length) {
rangeDailyTotals = rangeDailyTotals.map(item => ({
...item,
totalKm: filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0),
}));
}
const stats = { const stats = {
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0), totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
@@ -129,10 +187,12 @@ app.get('/', async (c) => {
vehicles: maskCustomerNames(paged), vehicles: maskCustomerNames(paged),
stats, stats,
filters, filters,
rangeDailyTotals,
dateRange,
total, total,
page, page,
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / limit),
updatedAt: date || getCache()?.updatedAt || new Date().toISOString(), updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
}); });
}); });

View File

@@ -10,7 +10,7 @@ const app = new Hono();
app.get('/', async (c) => { app.get('/', async (c) => {
try { try {
const [targets] = await pool.execute( const [targets] = await pool.execute(
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id' 'SELECT * FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as [any[], unknown]; ) as [any[], unknown];
const [vehicleStats] = await pool.execute(` const [vehicleStats] = await pool.execute(`
@@ -25,19 +25,96 @@ app.get('/', async (c) => {
SUM(current_year_mileage_task) as current_year_target, SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed, SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date MAX(current_year_assessment_end_date) as year_end_date
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0 FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id GROUP BY target_id
`) as [any[], unknown]; `) as [any[], unknown];
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 lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.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 lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.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 lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.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,
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date, DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
COUNT(*) as cnt COUNT(*) as cnt
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0 FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id, assessment_start_date, assessment_end_date GROUP BY target_id, assessment_start_date, assessment_end_date
ORDER BY target_id, assessment_start_date ORDER BY target_id, assessment_start_date
`) as [any[], unknown]; `) as [any[], unknown];
@@ -58,7 +135,7 @@ app.get('/', async (c) => {
} }
const [targetVehicleRows] = await pool.execute( const [targetVehicleRows] = await pool.execute(
'SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0' 'SELECT target_id, plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ target_id: number; plate_number: string }[], unknown]; ) as [{ target_id: number; plate_number: string }[], unknown];
const targetIdPlatesMap = new Map<number, string[]>(); const targetIdPlatesMap = new Map<number, string[]>();
@@ -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,
}; };
}); });
@@ -123,7 +245,7 @@ app.get('/:id/vehicles', async (c) => {
`SELECT plate_number, today_mileage, vehicle_total_mileage, `SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified, completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage daily_required_mileage
FROM tab_mileage_assessment_vehicle FROM lingniu_prod.tab_mileage_assessment_vehicle
WHERE target_id = ? AND is_deleted = 0 WHERE target_id = ? AND is_deleted = 0
ORDER BY today_mileage DESC`, ORDER BY today_mileage DESC`,
[targetId] [targetId]

View File

@@ -12,7 +12,7 @@ app.get('/', async (c) => {
let plates: string[] = []; let plates: string[] = [];
if (targetId) { if (targetId) {
const [vehicleRows] = await pool.execute( const [vehicleRows] = await pool.execute(
'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0', 'SELECT plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
[targetId] [targetId]
) as [{ plate_number: string }[], unknown]; ) as [{ plate_number: string }[], unknown];
plates = vehicleRows.map(r => r.plate_number); plates = vehicleRows.map(r => r.plate_number);

View File

@@ -3,6 +3,7 @@ export interface CachedVehicle {
plate: string; plate: string;
vin: string; vin: string;
dailyKm: number; dailyKm: number;
dailyMileage?: Record<string, number>;
totalKm: number | null; totalKm: number | null;
source: string; source: string;
isOnline: boolean; isOnline: boolean;
@@ -15,6 +16,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;
} }
@@ -59,6 +61,8 @@ export interface MonitoringResponse {
vehicles: CachedVehicle[]; vehicles: CachedVehicle[];
stats: MonitoringStats; stats: MonitoringStats;
filters: MonitoringFilters; filters: MonitoringFilters;
rangeDailyTotals?: { date: string; totalKm: number }[];
dateRange?: { start: string; end: string };
total: number; total: number;
page: number; page: number;
totalPages: number; totalPages: number;
@@ -68,6 +72,7 @@ export interface MonitoringResponse {
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */ /** 车辆关联信息(从 lingniu_prod 查出的原始行) */
export interface VehicleInfoRow { export interface VehicleInfoRow {
plate: string; plate: string;
vin: string | null;
customer: string | null; customer: string | null;
department: string | null; department: string | null;
manager: string | null; manager: string | null;

View File

@@ -3,24 +3,43 @@ import type { VehicleInfoRow } from './types.js';
/** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */ /** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */
export const VEHICLE_INFO_SQL = `SELECT export const VEHICLE_INFO_SQL = `SELECT
truck.plate_number AS plate, vi.plate_number AS plate,
cus.customer_name AS customer, vi.vin AS vin,
dep.dep_name AS department, COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer,
u.user_name AS manager, COALESCE(c.business_department_name, vor.business_dept) AS department,
CAST(c.bd AS CHAR) AS manager_id, COALESCE(c.business_manager_name, vor.business_manager) AS manager,
dic_status.dic_name AS rent_status, CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS manager_id,
org_truck.org_name AS entity, CASE vs.operation_status
c.project_name AS project WHEN '1' THEN '租赁'
FROM tab_truck truck WHEN '2' THEN '自营'
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0 WHEN '3' THEN '可运营'
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0 WHEN '4' THEN '待运营'
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0 WHEN '5' THEN '退出运营'
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0 ELSE vs.operation_status
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0 END AS rent_status,
LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status' NULLIF(vi.registered_ownership, '') AS entity,
AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0 COALESCE(c.project_name, vor.project_name) AS project
LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0 FROM vehicle_info vi
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`; LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id
AND vs.del_flag = 0
LEFT JOIN vehicle_lease_order_record vor
ON vor.vehicle_id = vi.id
AND vor.del_flag = '0'
AND vor.id = (
SELECT MAX(vor2.id)
FROM vehicle_lease_order_record vor2
WHERE vor2.vehicle_id = vi.id
AND vor2.del_flag = '0'
)
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = vor.contract_id
AND c.del_flag = '0'
LEFT JOIN customer_info ci
ON ci.id = vi.customer_id
AND ci.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`;
/** 查询所有车辆关联信息,返回 plate→info 的 Map */ /** 查询所有车辆关联信息,返回 plate→info 的 Map */
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> { export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
@@ -36,7 +55,7 @@ export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> { export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
if (plates.length === 0) return new Map(); if (plates.length === 0) return new Map();
const [rows] = await pool.execute( const [rows] = await pool.execute(
`${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`, `${VEHICLE_INFO_SQL} AND vi.plate_number IN (${plates.map(() => '?').join(',')})`,
plates plates
) as [VehicleInfoRow[], unknown]; ) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>(); const map = new Map<string, VehicleInfoRow>();

View File

@@ -28,16 +28,17 @@ function inferTypeFromTargetName(targetName: string): string {
} }
/** /**
* Classify vehicle type from dic_type.dic_name (e.g. "4.5吨冷链车", "4.5吨货车", "18吨双飞翼货车"). * Classify vehicle type from ln_asset_management.vehicle_model.
* The typeName is the full label from the dictionary, modelRaw is the numeric dic_code. * modelRaw is vehicle_model.vehicle_type, which is not the old dic_truck_type code.
*/ */
function classifyVehicleType(typeName: string, _modelRaw: string): string { function classifyVehicleType(typeName: string, _modelRaw: string): string {
const t = (typeName || '').trim(); const t = (typeName || '').trim();
if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链'; if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链';
if (t.includes('4.5')) return '4.5T普货'; if (t.includes('4.5')) return '4.5T普货';
if (t.includes('18')) return '18T'; if (t.includes('18')) return '18T';
if (t.includes('49') || t.includes('牵引')) return '49T';
if (t.includes('挂车')) return '挂车'; if (t.includes('挂车')) return '挂车';
if (t.includes('49')) return '49T';
if (t.includes('35')) return '35T';
return t || '其他'; return t || '其他';
} }
@@ -54,7 +55,7 @@ app.get('/', async (c) => {
// ---- Query 1: Assessment targets ---- // ---- Query 1: Assessment targets ----
const [targets] = await pool.execute( const [targets] = await pool.execute(
'SELECT id, target_name, annual_mileage_per_vehicle FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id', 'SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
) as [any[], unknown]; ) as [any[], unknown];
const targetMap = new Map<number, { targetName: string; annualMileage: number }>(); const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
@@ -71,21 +72,20 @@ app.get('/', async (c) => {
current_mileage, current_year_mileage, current_year_mileage_task, current_mileage, current_year_mileage, current_year_mileage_task,
completion_rate, is_qualified, current_year_is_qualified, completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage, current_year_assessment_end_date daily_required_mileage, current_year_assessment_end_date
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0 FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
`) as [any[], unknown]; `) as [any[], unknown];
// ---- Query 3: Vehicle info (customer, dept, manager) ---- // ---- Query 3: Vehicle info (customer, dept, manager) ----
const vehicleInfoMap = await fetchVehicleInfoMap(); const vehicleInfoMap = await fetchVehicleInfoMap();
// ---- Query 4: Vehicle types from tab_truck ---- // ---- Query 4: Vehicle types from vehicle_info ----
// Include soft-deleted trucks: many assessment vehicles have is_deleted=1 in tab_truck
// but are still active in the assessment. We need their type info.
const [truckTypeRows] = await pool.execute(` const [truckTypeRows] = await pool.execute(`
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM tab_truck truck FROM vehicle_info vi
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type' LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0 LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE truck.is_operation = 1 WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'
`) as [any[], unknown]; `) as [any[], unknown];
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>(); const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
@@ -161,12 +161,13 @@ app.get('/', async (c) => {
// ---- Query 7: Inventory vehicles (rent_status = 0) ---- // ---- Query 7: Inventory vehicles (rent_status = 0) ----
const [inventoryTruckRows] = await pool.execute(` const [inventoryTruckRows] = await pool.execute(`
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM tab_truck truck FROM vehicle_info vi
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type' LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0 LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE truck.is_deleted = 0 AND truck.is_operation = 1 WHERE vi.del_flag = '0'
AND truck.truck_rent_status = 0 AND COALESCE(vs.operation_status, '') IN ('3','4')
AND COALESCE(vs.vehicle_status, '') <> '4'
`) as [any[], unknown]; `) as [any[], unknown];
// ---- Build assessment vehicle lookup for inventory cross-reference ---- // ---- Build assessment vehicle lookup for inventory cross-reference ----

View File

@@ -17,75 +17,101 @@ import type { Context } from 'hono';
const app = new Hono(); const app = new Hono();
const MAIN_SQL = `SELECT const MAIN_SQL = `SELECT
CAST(truck.id AS CHAR) AS id, CAST(vi.id AS CHAR) AS id,
truck.plate_number AS 车牌号, vi.plate_number AS 车牌号,
truck.vin AS vin, vi.vin AS vin,
truck.brand AS 车辆品牌, vm.brand AS 车辆品牌,
truck.model AS 车辆型号, vm.model AS 车辆型号,
truck.color AS 车辆颜色, vi.body_color AS 车辆颜色,
truck.rent_from_company AS 租赁公司, vi.rental_company AS 租赁公司,
dic_ascription_status.dic_name AS 车辆归属状态Label, CASE vi.vehicle_source
dic_type.dic_name AS 车辆型号Label, WHEN '0' THEN '自有'
truck.stock_area AS 库存区域, WHEN '1' THEN '挂靠'
truck.truck_rent_status AS 车辆租赁状态, WHEN '2' THEN '外租'
dic_status.dic_name AS 车辆租赁状态Label, WHEN '3' THEN '自有'
truck.is_operation AS 是否营运, ELSE vi.actual_ownership
info.province AS 省, END AS 车辆归属状态Label,
info.city AS 市, vm.model AS 车辆型号Label,
vm.vehicle_type AS 车辆类型参数,
vi.operation_city AS 库存区域,
vs.vehicle_status AS 车辆租赁状态,
vs.operation_status AS 车辆租赁状态Label,
CASE WHEN COALESCE(vs.operation_status, '') = '5' THEN 0 ELSE 1 END AS 是否营运,
COALESCE(info_province.NAME, NULLIF(info.province, ''), vi.province_name, vi_province.NAME, NULLIF(vi.province, '')) AS 省,
COALESCE(info_city.NAME, NULLIF(info.city, ''), vi.city_name, vi_city.NAME, vi_operation_city.NAME, NULLIF(vi.city, ''), NULLIF(vi.operation_city, '')) AS 市,
info.lat AS 纬度, info.lat AS 纬度,
info.lng AS 经度, info.lng AS 经度,
dic_brand.dic_name AS 车辆品牌Label, CASE vm.brand
si.contract_id AS 合同ID, WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
COALESCE(c.contract_no, si.contract_no) AS 合同编码, WHEN 'yuejin' THEN '跃进'
cus.customer_name AS 客户名称, WHEN 'feichi' THEN '飞驰'
org.org_name AS 合同归属公司, WHEN 'sulong' THEN '苏龙'
dep.dep_name AS 合同归属部门, WHEN 'higer' THEN '海格'
org_truck.org_name AS 主体, WHEN 'dongfeng' THEN '东风'
c.project_name AS 项目名称, WHEN 'yutong' THEN '宇通'
u.user_name AS 客户经理, WHEN 'chufeng' THEN '楚风'
CAST(c.bd AS CHAR) AS 经理ID WHEN 'tonghua' THEN '通华'
FROM tab_truck truck WHEN 'maxus' THEN '大通'
WHEN 'mingwei' THEN '明威'
WHEN 'wanfeng' THEN '万风'
WHEN 'shujie' THEN '舒捷'
WHEN 'denza' THEN '腾势'
WHEN 'hongyan' THEN '红岩'
WHEN 'yuanchang brand' THEN '远程牌'
WHEN 'others' THEN '其他'
ELSE vm.brand
END AS 车辆品牌Label,
c.id AS 合同ID,
COALESCE(c.contract_code, vor.contract_code, vi.contract_code) AS 合同编码,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS 客户名称,
c.signing_company AS 合同归属公司,
COALESCE(c.business_department_name, vor.business_dept) AS 合同归属部门,
NULLIF(vi.registered_ownership, '') AS 主体,
COALESCE(c.project_name, vor.project_name) AS 项目名称,
COALESCE(c.business_manager_name, vor.business_manager) AS 客户经理,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS 经理ID
FROM vehicle_info vi
LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id
AND vs.del_flag = 0
LEFT JOIN vehicle_model vm
ON vm.id = vi.vehicle_model_id
AND vm.del_flag = '0'
LEFT JOIN tab_truck_remote_sync_realtime_info info LEFT JOIN tab_truck_remote_sync_realtime_info info
ON info.id = truck.id ON info.plate_number = vi.plate_number
LEFT JOIN tab_dic dic_type AND info.is_deleted = 0
ON dic_type.parent_code = 'dic_truck_type' LEFT JOIN common_district info_province
AND dic_type.dic_code = truck.model ON info_province.CODE = info.province COLLATE utf8mb4_unicode_ci
AND dic_type.is_deleted = 0 AND info_province.STATUS = 'VALID'
LEFT JOIN tab_dic dic_status LEFT JOIN common_district info_city
ON dic_status.parent_code = 'dic_truck_rent_status' ON info_city.CODE = info.city COLLATE utf8mb4_unicode_ci
AND dic_status.dic_code = truck.truck_rent_status AND info_city.STATUS = 'VALID'
AND dic_status.is_deleted = 0 LEFT JOIN common_district vi_province
LEFT JOIN tab_dic dic_brand ON vi_province.CODE = vi.province COLLATE utf8mb4_unicode_ci
ON dic_brand.parent_code = 'dic_vehicle_brand' AND vi_province.STATUS = 'VALID'
AND dic_brand.dic_code = truck.brand LEFT JOIN common_district vi_city
AND dic_brand.is_deleted = 0 ON vi_city.CODE = vi.city COLLATE utf8mb4_unicode_ci
LEFT JOIN tab_truck_status_info si AND vi_city.STATUS = 'VALID'
ON si.truck_id = truck.id LEFT JOIN common_district vi_operation_city
AND si.is_deleted = 0 ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
LEFT JOIN tab_contract c AND vi_operation_city.STATUS = 'VALID'
ON c.id = si.contract_id LEFT JOIN vehicle_lease_order_record vor
AND c.is_deleted = 0 ON vor.vehicle_id = vi.id
LEFT JOIN tab_customer cus AND vor.del_flag = '0'
ON cus.id = c.customer_id AND vor.id = (
AND cus.is_deleted = 0 SELECT MAX(vor2.id)
LEFT JOIN tab_org org FROM vehicle_lease_order_record vor2
ON org.id = c.org_id WHERE vor2.vehicle_id = vi.id
AND org.is_deleted = 0 AND vor2.del_flag = '0'
LEFT JOIN tab_org org_truck )
ON org_truck.id = truck.org_id LEFT JOIN vehicle_lease_contract_info c
AND org_truck.is_deleted = 0 ON c.order_id = vor.contract_id
LEFT JOIN tab_dic dic_ascription_status AND c.del_flag = '0'
ON dic_ascription_status.parent_code = 'dic_truck_ascription_status' LEFT JOIN customer_info ci
AND dic_ascription_status.dic_code = truck.ascription_status ON ci.id = vi.customer_id
AND dic_ascription_status.is_deleted = 0 AND ci.del_flag = '0'
LEFT JOIN tab_user u WHERE vi.del_flag = '0'
ON u.id = c.bd AND COALESCE(vs.operation_status, '') <> '5'`;
AND u.is_deleted = 0
LEFT JOIN tab_department dep
ON dep.id = u.dep_id
AND dep.is_deleted = 0
WHERE truck.is_deleted = 0
AND truck.is_operation = 1`;
// Region mapping: province/city -> display region // Region mapping: province/city -> display region
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const; const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
@@ -148,23 +174,33 @@ function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
return counts; return counts;
} }
// Map rental status to frontend status // Map operation status to frontend status.
// Actual DB values: 在库(0), 自营(1), 租赁(2), 待交车(7), 挂靠(8), 异动(12) // ln_asset_management.vehicle_status.operation_status:
function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' { // 1=租赁, 2=自营, 3=可运营, 4=待运营, 5=退出运营.
if (!rentStatus) return 'Inventory'; function mapStatus(operationStatus: string | null, vehicleStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
const s = rentStatus.trim(); const op = (operationStatus || '').trim();
if (s === '租赁' || s === '自营' || s === '挂靠') return 'Operating'; const vehicle = (vehicleStatus || '').trim();
if (s === '在库') return 'Inventory'; if (vehicle === '4') return 'Pending';
if (s === '待交车') return 'Pending'; if (vehicle === '14') return 'Abnormal';
if (s === '异动') return 'Abnormal'; if (op === '1' || op === '2') return 'Operating';
if (op === '3' || op === '4') return 'Inventory';
if (op === '租赁' || op === '自营') return 'Operating';
if (op === '可运营' || op === '待运营' || op === '在库') return 'Inventory';
if (op === '异动') return 'Abnormal';
return 'Inventory'; return 'Inventory';
} }
// Map ownership from truck_rent_status (rentStatusLabel) // Map ownership from vehicle_info.vehicle_source.
// DB values: 自营(1), 租赁(2), 挂靠(8) → these are the operating subtypes // ln_asset_management vehicle_source values: 1=挂靠, 2=外租, 3=自有. Keep 0=自有 for legacy rows.
function mapOwnership(rentStatusLabel: string | null): string { function mapOwnership(rentStatusLabel: string | null): string {
if (!rentStatusLabel) return 'Unknown'; if (!rentStatusLabel) return 'Unknown';
const s = rentStatusLabel.trim(); const s = rentStatusLabel.trim();
if (s === '0') return 'Self';
if (s === '1') return 'Hanging';
if (s === '2') return 'Leased';
if (s === '3') return 'Self';
if (s === '自有') return 'Self';
if (s === '外租') return 'Leased';
if (s === '自营') return 'Self'; if (s === '自营') return 'Self';
if (s === '租赁') return 'Leased'; if (s === '租赁') return 'Leased';
if (s === '挂靠') return 'Hanging'; if (s === '挂靠') return 'Hanging';
@@ -179,27 +215,41 @@ function resolveCity(city: string | null, province: string | null): string {
return p || '其他'; return p || '其他';
} }
// Derive vehicle type category from model label // Derive page category from ln_asset_management.vehicle_model.
// Actual DB values: 4.5吨冷链车, 4.5吨货车, 18吨双飞翼货车, 18吨厢式货车, 49吨牵引车头, 35吨牵引车头, // vehicle_type is a new-system category code, not the old lingniu_prod.dic_truck_type code:
// 重型集装箱半挂车, 重型平板半挂车, 氢能叉车, SJ型蓄电池观光车, 公务用车/小客车, 挂靠油车 // 1 = 4.5T, 2 = 18T / other truck-like models, 3 = tractor head, 5/6 = trailers.
function deriveType(modelLabel: string | null, brandLabel: string | null): string { function deriveType(modelLabel: string | null, vehicleTypeCode: string | null): string {
const label = (modelLabel || '').trim(); const label = (modelLabel || '').trim();
const code = (vehicleTypeCode || '').trim();
if (label.includes('半挂车') || code === '5' || code === '6') return '挂车';
if (label.includes('4.5吨')) return '4.5T'; if (label.includes('4.5吨')) return '4.5T';
if (label.includes('18吨')) return '18T'; if (label.includes('18吨')) return '18T';
if (label.includes('49吨')) return '49T'; if (label.includes('49吨')) return '49T';
if (label.includes('35吨')) return '35T'; if (label.includes('35吨')) return '35T';
if (code === '1') return '4.5T';
if (code === '3') return '49T';
if (label.includes('叉车')) return '叉车'; if (label.includes('叉车')) return '叉车';
if (label.includes('半挂车')) return '挂车';
return '其他车型'; return '其他车型';
} }
function normalizeModelLabel(modelLabel: string | null): string | null {
const label = (modelLabel || '').trim();
if (label === '帕力安牌4.5吨冷链车') return '4.5吨冷链车';
if (label === '帕力安牌18吨双飞翼货车') return '18吨双飞翼货车';
if (label === '海格牌18吨双飞翼货车') return '18吨双飞翼货车';
return label || null;
}
// Tag → alias mapping with sort order // Tag → alias mapping with sort order
// tag is generated as: brand-modelLabel-color[+rentCompany if 外租] // tag is generated as: brand-modelLabel-color[+rentCompany if 外租]
// Some tags are merged (e.g. 嘉氢 red + 嘉氢 blue/green → one alias) // Some tags are merged (e.g. 嘉氢 red + 嘉氢 blue/green → one alias)
const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = { const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
// 4.5T 普货 // 4.5T 普货
'现代-4.5吨货车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T普货(交投)', order: 101 }, '现代-4.5吨货车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T普货(交投)', order: 101 },
'现代-4.5吨货车-白': { alias: '现代4.5T普货(恒运)', order: 102 }, '现代-4.5吨货车-白恒运': { alias: '现代4.5T普货(恒运)', order: 102 },
'现代-4.5吨货车-白色恒运': { alias: '现代4.5T普货(恒运)', order: 102 },
'现代-4.5吨货车-白色': { alias: '现代4.5T普货', order: 101 },
'现代-4.5吨货车-白': { alias: '现代4.5T普货', order: 101 },
// 4.5T 冷链 // 4.5T 冷链
'帕力安牌-4.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 }, '帕力安牌-4.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 },
'帕力安牌-4.5吨冷链车-白色': { alias: '现代4.5T冷链(羚牛)', order: 202 }, '帕力安牌-4.5吨冷链车-白色': { alias: '现代4.5T冷链(羚牛)', order: 202 },
@@ -212,13 +262,16 @@ const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
'苏龙-18吨双飞翼货车-白色': { alias: '苏龙18T飞翼', order: 304 }, // dirty data, merge '苏龙-18吨双飞翼货车-白色': { alias: '苏龙18T飞翼', order: 304 }, // dirty data, merge
'苏龙-18吨双飞翼货车-白安吉天地物流科技有限公司': { alias: '苏龙18T飞翼安吉', order: 305 }, '苏龙-18吨双飞翼货车-白安吉天地物流科技有限公司': { alias: '苏龙18T飞翼安吉', order: 305 },
'帕力安牌-18吨双飞翼货车-白': { alias: '现代18T双飞翼羚牛', order: 306 }, '帕力安牌-18吨双飞翼货车-白': { alias: '现代18T双飞翼羚牛', order: 306 },
'帕力安牌-18吨双飞翼货车-白/绿': { alias: '现代18T双飞翼羚牛', order: 306 },
// 49T // 49T
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 }, '宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 }, '飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 }, '飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 },
'飞驰-49吨牵引车头-红': { alias: '49T飞驰', order: 402 },
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 }, // merge with above '飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 }, // merge with above
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-红)', order: 404 }, '飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-红)', order: 404 },
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-蓝白绿)', order: 405 }, '飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-蓝白绿)', order: 405 },
'楚风-49吨牵引车头-蓝/黑': { alias: '49T楚风', order: 406 },
'楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风海珀特', order: 406 }, '楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风海珀特', order: 406 },
// 其他 // 其他
'红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 }, '红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 },
@@ -232,9 +285,11 @@ const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
'万风-重型平板半挂车-红': { alias: '挂车', order: 503 }, '万风-重型平板半挂车-红': { alias: '挂车', order: 503 },
'舒捷-SJ型蓄电池观光车-蓝白': { alias: '观光车', order: 504 }, '舒捷-SJ型蓄电池观光车-蓝白': { alias: '观光车', order: 504 },
'东风-挂靠油车-白色': { alias: '公务车/挂靠车', order: 505 }, '东风-挂靠油车-白色': { alias: '公务车/挂靠车', order: 505 },
'东风-挂靠油车-白': { alias: '公务车/挂靠车', order: 505 },
'腾势-公务用车/小客车-黑': { alias: '公务车/挂靠车', order: 505 }, '腾势-公务用车/小客车-黑': { alias: '公务车/挂靠车', order: 505 },
'腾势-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 }, '腾势-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'其他-公务用车/小客车-蓝色': { alias: '公务车/挂靠车', order: 505 }, '其他-公务用车/小客车-蓝色': { alias: '公务车/挂靠车', order: 505 },
'其他-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
'远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 }, '远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 }, '大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
}; };
@@ -247,8 +302,9 @@ function deriveModelTag(
rentCompany: string | null, rentCompany: string | null,
): string { ): string {
const brand = (brandLabel || '').trim(); const brand = (brandLabel || '').trim();
const model = (modelLabel || '').trim(); const model = (normalizeModelLabel(modelLabel) || '').trim();
const c = (color || '').trim(); const c = (color || '').trim();
if (model === '公务用车/小客车') return '公务车/挂靠车';
const isRented = ownershipLabel?.trim() === '外租'; const isRented = ownershipLabel?.trim() === '外租';
const company = isRented ? (rentCompany || '').trim() : ''; const company = isRented ? (rentCompany || '').trim() : '';
@@ -272,15 +328,16 @@ function transformRow(row: VehicleRow): Vehicle {
id: row.id, id: row.id,
plateNumber: row.车牌号 || '', plateNumber: row.车牌号 || '',
vin: row.vin || '', vin: row.vin || '',
type: deriveType(row.Label, row.Label), type: deriveType(row.Label, row.),
model: deriveModelTag(row.Label, row.Label, row., row.Label, row.), model: deriveModelTag(row.Label, row.Label, row., row.Label, row.),
color: row.车辆颜色 || '', color: row.车辆颜色 || '',
location: region, location: region,
region, region,
province: row.省, province: row.省,
city: row.市, city: row.市,
status: mapStatus(row.Label), status: mapStatus(row.Label, row.),
ownership: mapOwnership(row.Label), operationStatus: row.车辆租赁状态Label,
ownership: mapOwnership(row.Label),
rentCompany: row.租赁公司 || '', rentCompany: row.租赁公司 || '',
contractNo: row.合同编码, contractNo: row.合同编码,
customerName: row.客户名称, customerName: row.客户名称,
@@ -318,7 +375,7 @@ async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
return maskCustomerNames(list); return maskCustomerNames(list);
} }
// 归属公司筛选(所属公司 = tab_truck.org_id → org_name, 即 Vehicle.subjectOrg // 归属公司筛选(所属公司 = vehicle_info.registered_ownership, 即 Vehicle.subjectOrg
function getSubjectParam(c: Context): string | null { function getSubjectParam(c: Context): string | null {
const raw = (c.req.query('subject') || '').trim(); const raw = (c.req.query('subject') || '').trim();
return raw ? raw : null; return raw ? raw : null;
@@ -354,24 +411,27 @@ async function getWeeklyTruckIds(): Promise<WeeklyTruckIds> {
} }
const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([ const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
pool.query<any[]>(`SELECT CAST(id AS CHAR) AS truck_id FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`), pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take FROM vehicle_status
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL FROM delivery_vehicle
AND task.task_type=1 AND task.task_status=1 AND take.update_time IS NOT NULL WHERE del_flag='0'
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`), AND vehicle_id IS NOT NULL
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_return r AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id AND delivery_status IN (2,3,5)`),
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL FROM return_vehicle_task
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`), WHERE del_flag='0'
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take AND vehicle_id IS NOT NULL
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id AND status IN (2,3,5)`),
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL pool.query<any[]>(`SELECT CAST(new_vehicle_id AS CHAR) AS truck_id
AND task.task_type=3 AND task.task_status=1 AND take.update_time IS NOT NULL FROM vehicle_replacement
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`), WHERE del_flag='0'
AND new_vehicle_id IS NOT NULL
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}
AND status=20`),
]); ]);
const toSet = (rows: any[]) => new Set((rows as any[]).map((r) => String(r.truck_id)).filter((s) => s && s !== 'null')); const toSet = (rows: any[]) => new Set((rows as any[]).map((r) => String(r.truck_id)).filter((s) => s && s !== 'null'));
@@ -408,6 +468,59 @@ function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds) {
const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`; const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`;
const WEEK_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`; const WEEK_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`;
type FlowType = 'delivered' | 'returned' | 'replaced';
interface FlowDetailRow {
id: string;
type: FlowType;
type_label: string;
stat_date: string;
truck_id: string;
plate_number: string;
event_time: string | null;
submit_time: string | null;
department: string | null;
manager: string | null;
customer_name: string | null;
}
function formatDateOnly(value: Date): string {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function isDateParam(value: string | undefined | null): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function addDateDays(date: string, days: number): string {
const d = new Date(`${date}T00:00:00`);
d.setDate(d.getDate() + days);
return formatDateOnly(d);
}
function listDateRange(start: string, end: string): string[] {
const dates: string[] = [];
let cursor = start;
while (cursor <= end && dates.length <= 370) {
dates.push(cursor);
cursor = addDateDays(cursor, 1);
}
return dates;
}
function normalizeDateRange(startRaw: string | undefined, endRaw: string | undefined): { start: string; end: string } {
const today = formatDateOnly(new Date());
const defaultStart = addDateDays(today, -29);
let start = isDateParam(startRaw) ? startRaw : defaultStart;
let end = isDateParam(endRaw) ? endRaw : today;
if (start > end) [start, end] = [end, start];
if (listDateRange(start, end).length > 370) start = addDateDays(end, -369);
return { start, end };
}
interface WeeklyStats { interface WeeklyStats {
pendingDelivery: number; pendingDelivery: number;
weeklyNew: number; weeklyNew: number;
@@ -419,59 +532,54 @@ interface WeeklyStats {
// 交车单 SQL // 交车单 SQL
const DELIVERED_SQL = `SELECT const DELIVERED_SQL = `SELECT
take.id, DATE(take.handover_date) AS handover_date, dv.id, DATE(dv.delivery_time) AS handover_date,
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, CAST(dv.vehicle_id AS CHAR) AS truck_id, dv.plate_number,
dic_contract_type.dic_name AS contract_type, c.contract_type,
customer.customer_name COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM tab_truck_rent_take take FROM delivery_vehicle dv
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id LEFT JOIN delivery_task_subject dts
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id ON dts.id = dv.delivery_task_subject_id
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id AND dts.del_flag = '0'
LEFT JOIN tab_contract contract ON task.contract_id = contract.id LEFT JOIN vehicle_lease_contract_info c
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id ON c.order_id = dv.contract_id
LEFT JOIN tab_dic dic_contract_type AND c.del_flag = '0'
ON dic_contract_type.parent_code = 'dic_contract_type' WHERE dv.del_flag = '0'
AND dic_contract_type.dic_code = contract.contract_type AND dv.vehicle_id IS NOT NULL
AND dic_contract_type.is_deleted = 0 AND dv.delivery_time IS NOT NULL
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL AND dv.delivery_status IN (2,3,5)`;
AND task.task_type = 1 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
// 还车单 SQL // 还车单 SQL
const RETURNED_SQL = `SELECT const RETURNED_SQL = `SELECT
r.id, DATE(r.return_date) AS handover_date, r.id, DATE(r.arrival_time) AS handover_date,
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, CAST(r.vehicle_id AS CHAR) AS truck_id, r.plate_number,
dic_contract_type.dic_name AS contract_type, c.contract_type,
customer.customer_name COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM tab_truck_rent_return r FROM return_vehicle_task r
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id LEFT JOIN delivery_task_subject dts
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id ON dts.id = r.delivery_task_subject_id
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id AND dts.del_flag = '0'
LEFT JOIN tab_contract contract ON task.contract_id = contract.id LEFT JOIN vehicle_lease_contract_info c
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id ON c.order_id = r.contract_id
LEFT JOIN tab_dic dic_contract_type AND c.del_flag = '0'
ON dic_contract_type.parent_code = 'dic_contract_type' WHERE r.del_flag = '0'
AND dic_contract_type.dic_code = contract.contract_type AND r.vehicle_id IS NOT NULL
AND dic_contract_type.is_deleted = 0 AND r.arrival_time IS NOT NULL
WHERE r.is_deleted = 0 AND r.return_date IS NOT NULL`; AND r.status IN (2,3,5)`;
// 替换车单 SQL // 替换车单 SQL
const REPLACED_SQL = `SELECT const REPLACED_SQL = `SELECT
take.id, DATE(take.handover_date) AS handover_date, vr.id, DATE(vr.replace_time) AS handover_date,
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, CAST(vr.new_vehicle_id AS CHAR) AS truck_id, vr.new_vehicle_plate AS plate_number,
dic_contract_type.dic_name AS contract_type, c.contract_type,
customer.customer_name c.customer_name
FROM tab_truck_rent_take take FROM vehicle_replacement vr
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id LEFT JOIN vehicle_lease_contract_info c
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id ON c.id = vr.contract_id
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id AND c.del_flag = '0'
LEFT JOIN tab_contract contract ON task.contract_id = contract.id WHERE vr.del_flag = '0'
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id AND vr.new_vehicle_id IS NOT NULL
LEFT JOIN tab_dic dic_contract_type AND vr.replace_time IS NOT NULL
ON dic_contract_type.parent_code = 'dic_contract_type' AND vr.status = 20`;
AND dic_contract_type.dic_code = contract.contract_type
AND dic_contract_type.is_deleted = 0
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
AND task.task_type = 3 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
let cachedWeeklyStats: WeeklyStats | null = null; let cachedWeeklyStats: WeeklyStats | null = null;
let weeklyStatsLastFetch = 0; let weeklyStatsLastFetch = 0;
@@ -483,23 +591,18 @@ async function getWeeklyStats(): Promise<WeeklyStats> {
} }
const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([ const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`), pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_status WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`), pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info WHERE del_flag='0' AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE (is_operation=0 OR is_deleted=1) AND update_time >= ${WEEK_START_SQL} AND update_time < ${WEEK_END_SQL}`), pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info vi LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0 WHERE (vi.del_flag='1' OR vs.operation_status='5') AND vi.update_time >= ${WEEK_START_SQL} AND vi.update_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`),
AND task.task_type=1 AND task.task_status=1 AND take.update_time IS NOT NULL pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`), WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`),
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_replacement
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL WHERE del_flag='0' AND replace_time IS NOT NULL AND status=20
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`), AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
AND task.task_type=3 AND task.task_status=1 AND take.update_time IS NOT NULL
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
]); ]);
cachedWeeklyStats = { cachedWeeklyStats = {
@@ -521,11 +624,11 @@ app.get('/summary', async (c) => {
const summary: SummaryData = { const summary: SummaryData = {
totalAssets: vehicles.length, totalAssets: vehicles.length,
operating: { operating: {
total: vehicles.filter((v) => v.status === 'Operating').length, total: vehicles.filter((v) => v.status === 'Operating' && (v.operationStatus === '1' || v.operationStatus === '2')).length,
self: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Self').length, self: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '2').length,
leased: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Leased').length, leased: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '1').length,
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length, public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
hanging: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Hanging').length, hanging: 0,
}, },
inventory: { inventory: {
total: vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length, total: vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length,
@@ -695,7 +798,8 @@ app.get('/dept-stats', async (c) => {
const deptMap = new Map<string, Map<string, Vehicle[]>>(); const deptMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of withManager) { for (const v of withManager) {
const dept = v.departmentName || '公务车'; const isPublicServiceVehicle = v.model === '公务车/挂靠车';
const dept = isPublicServiceVehicle ? '公务车' : (v.departmentName || '未分配部门');
const mgr = v.customerManager || '未分配'; const mgr = v.customerManager || '未分配';
if (EXCLUDED_MANAGERS.has(mgr)) continue; if (EXCLUDED_MANAGERS.has(mgr)) continue;
if (!deptMap.has(dept)) deptMap.set(dept, new Map()); if (!deptMap.has(dept)) deptMap.set(dept, new Map());
@@ -704,29 +808,6 @@ app.get('/dept-stats', async (c) => {
mgrMap.get(mgr)!.push(v); mgrMap.get(mgr)!.push(v);
} }
// 补齐:业务部门内所有在职用户,即使当前无车辆也需显示
const deptNames = Array.from(deptMap.keys()).filter((d) => d !== '公务车');
if (deptNames.length > 0) {
const placeholders = deptNames.map(() => '?').join(',');
const [userRows] = await pool.query<any[]>(
`SELECT u.user_name, dep.dep_name
FROM tab_user u
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
WHERE u.is_deleted = 0
AND dep.dep_name IN (${placeholders})`,
deptNames,
);
for (const r of userRows as any[]) {
const dept = r.dep_name as string | null;
const mgr = r.user_name as string | null;
if (!dept || !mgr) continue;
if (EXCLUDED_MANAGERS.has(mgr)) continue;
const mgrMap = deptMap.get(dept);
if (!mgrMap) continue;
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
}
}
// Compute attendance & avg mileage from realtime data // Compute attendance & avg mileage from realtime data
const getMileageStats = (vList: Vehicle[]) => { const getMileageStats = (vList: Vehicle[]) => {
const todayActive = vList.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length; const todayActive = vList.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length;
@@ -971,7 +1052,11 @@ app.get('/list', async (c) => {
filtered = filtered.filter((v) => customer === '未分配客户' ? !v.customerName : v.customerName === customer); filtered = filtered.filter((v) => customer === '未分配客户' ? !v.customerName : v.customerName === customer);
} }
if (department) { if (department) {
filtered = filtered.filter((v) => department === '公务车' ? !v.departmentName : v.departmentName === department); filtered = filtered.filter((v) => {
if (department === '公务车') return v.model === '公务车/挂靠车';
if (department === '未分配部门') return v.model !== '公务车/挂靠车' && !v.departmentName;
return v.departmentName === department;
});
} }
if (isColdChain !== undefined) { if (isColdChain !== undefined) {
const wantCold = isColdChain === 'true'; const wantCold = isColdChain === 'true';
@@ -994,6 +1079,7 @@ app.get('/list', async (c) => {
city: v.city, city: v.city,
status: v.status, status: v.status,
ownership: v.ownership, ownership: v.ownership,
rentCompany: v.rentCompany,
contractNo: v.contractNo, contractNo: v.contractNo,
customerName: v.customerName, customerName: v.customerName,
subjectOrg: v.subjectOrg, subjectOrg: v.subjectOrg,
@@ -1049,18 +1135,22 @@ app.get('/weekly-detail', async (c) => {
const source = c.req.query('source'); const source = c.req.query('source');
let sql: string; let sql: string;
if (type === 'delivered') { if (type === 'delivered') {
sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`; sql = `${DELIVERED_SQL} AND dv.delivery_time >= ${WEEK_START_SQL} AND dv.delivery_time < ${WEEK_END_SQL} ORDER BY dv.delivery_time DESC`;
} else if (type === 'returned') { } else if (type === 'returned') {
sql = `${RETURNED_SQL} AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL} ORDER BY r.return_date DESC`; sql = `${RETURNED_SQL} AND r.arrival_time >= ${WEEK_START_SQL} AND r.arrival_time < ${WEEK_END_SQL} ORDER BY r.arrival_time DESC`;
} else if (type === 'replaced') { } else if (type === 'replaced') {
sql = `${REPLACED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`; sql = `${REPLACED_SQL} AND vr.replace_time >= ${WEEK_START_SQL} AND vr.replace_time < ${WEEK_END_SQL} ORDER BY vr.replace_time DESC`;
} else if (type === 'pending') { } else if (type === 'pending') {
sql = `SELECT CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name sql = `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`; FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND vs.vehicle_status='4' AND COALESCE(vs.operation_status, '') <> '5'`;
} else if (type === 'new') { } else if (type === 'new') {
sql = `SELECT CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, truck.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name sql = `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, vi.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 FROM vehicle_info vi
AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`; LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND COALESCE(vs.operation_status, '') <> '5'
AND vi.create_time >= ${WEEK_START_SQL} AND vi.create_time < ${WEEK_END_SQL} ORDER BY vi.create_time DESC`;
} else { } else {
return c.json([]); return c.json([]);
} }
@@ -1085,6 +1175,141 @@ app.get('/weekly-detail', async (c) => {
return c.json(masked); return c.json(masked);
}); });
// GET /api/vehicles/flow-stats?start=YYYY-MM-DD&end=YYYY-MM-DD
// 资产流转日报:按提交时间(create_time)统计交车、还车、替换车,并返回可点击明细。
app.get('/flow-stats', async (c) => {
const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end'));
const allowedVehicles = await getVehiclesForUser(c);
const allowedTruckIds = new Set(allowedVehicles.map((v) => String(v.id)));
const sql = `
SELECT *
FROM (
SELECT
CONCAT('delivered-', dv.id) AS id,
'delivered' AS type,
'交车' AS type_label,
DATE_FORMAT(dv.create_time, '%Y-%m-%d') AS stat_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id,
dv.plate_number,
DATE_FORMAT(dv.delivery_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(dv.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM delivery_vehicle dv
LEFT JOIN delivery_task_subject dts
ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = dv.contract_id
AND c.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL
AND dv.create_time IS NOT NULL
AND dv.delivery_status IN (2,3,5)
AND dv.create_time >= ?
AND dv.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('returned-', r.id) AS id,
'returned' AS type,
'还车' AS type_label,
DATE_FORMAT(r.create_time, '%Y-%m-%d') AS stat_date,
CAST(r.vehicle_id AS CHAR) AS truck_id,
r.plate_number,
DATE_FORMAT(r.arrival_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(r.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM return_vehicle_task r
LEFT JOIN delivery_task_subject dts
ON dts.id = r.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = r.contract_id
AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL
AND r.create_time IS NOT NULL
AND r.status IN (2,3,5)
AND r.create_time >= ?
AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('replaced-', vr.id) AS id,
'replaced' AS type,
'替换' AS type_label,
DATE_FORMAT(vr.create_time, '%Y-%m-%d') AS stat_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id,
vr.new_vehicle_plate AS plate_number,
DATE_FORMAT(vr.replace_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(vr.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM vehicle_replacement vr
LEFT JOIN delivery_task_subject dts
ON dts.id = vr.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.id = vr.contract_id
AND c.del_flag = '0'
WHERE vr.del_flag = '0'
AND vr.new_vehicle_id IS NOT NULL
AND vr.create_time IS NOT NULL
AND vr.status = 20
AND vr.create_time >= ?
AND vr.create_time < DATE_ADD(?, INTERVAL 1 DAY)
) flow
ORDER BY flow.submit_time DESC
`;
const [rows] = await pool.query<any[]>(sql, [start, end, start, end, start, end]);
const details = (rows as FlowDetailRow[])
.filter((row) => allowedTruckIds.has(String(row.truck_id)))
.map((row) => ({
id: row.id,
type: row.type,
typeLabel: row.type_label,
date: row.stat_date,
truckId: row.truck_id,
plateNumber: row.plate_number,
eventTime: row.event_time,
submitTime: row.submit_time,
department: row.department || '',
manager: row.manager || '',
customerName: maskCustomerName(row.customer_name),
}));
const dailyMap = new Map<string, { date: string; delivered: number; returned: number; replaced: number; total: number }>();
for (const date of listDateRange(start, end)) dailyMap.set(date, { date, delivered: 0, returned: 0, replaced: 0, total: 0 });
for (const item of details) {
const stat = dailyMap.get(item.date);
if (!stat) continue;
stat[item.type] += 1;
stat.total += 1;
}
const daily = Array.from(dailyMap.values());
const totals = daily.reduce(
(acc, item) => ({
delivered: acc.delivered + item.delivered,
returned: acc.returned + item.returned,
replaced: acc.replaced + item.replaced,
total: acc.total + item.total,
}),
{ delivered: 0, returned: 0, replaced: 0, total: 0 },
);
return c.json({ start, end, daily, totals, details });
});
// GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉 // GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉
app.get('/subjects', async (c) => { app.get('/subjects', async (c) => {
const all = await getVehicles(); const all = await getVehicles();
@@ -1124,20 +1349,18 @@ app.get('/debug', async (c) => {
${WEEK_END_SQL} AS week_end, ${WEEK_END_SQL} AS week_end,
CURDATE() AS today, CURDATE() AS today,
WEEKDAY(CURDATE()) AS weekday`); WEEKDAY(CURDATE()) AS weekday`);
const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`); const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`);
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1 const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(delivery_time) AS latest FROM delivery_vehicle
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`); WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(take.handover_date) AS latest FROM tab_truck_rent_take take const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`); AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`);
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(arrival_time) AS latest FROM return_vehicle_task
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)`);
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`);
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(r.return_date) AS latest FROM tab_truck_rent_return r WHERE r.is_deleted=0 AND r.return_date IS NOT NULL`);
return c.json({ return c.json({
weekRange: dateRange, weekRange: dateRange,

View File

@@ -8,6 +8,7 @@ export interface VehicleRow {
租赁公司: string; 租赁公司: string;
车辆归属状态Label: string | null; 车辆归属状态Label: string | null;
车辆型号Label: string | null; 车辆型号Label: string | null;
车辆类型参数: string | null;
库存区域: string | null; 库存区域: string | null;
车辆租赁状态: string | null; 车辆租赁状态: string | null;
车辆租赁状态Label: string | null; 车辆租赁状态Label: string | null;
@@ -40,6 +41,7 @@ export interface Vehicle {
province: string | null; province: string | null;
city: string | null; city: string | null;
status: 'Operating' | 'Inventory' | 'Pending' | 'Abnormal'; status: 'Operating' | 'Inventory' | 'Pending' | 'Abnormal';
operationStatus: string | null;
ownership: string; ownership: string;
rentCompany: string; rentCompany: string;
contractNo: string | null; contractNo: string | null;