Compare commits
28 Commits
dev-ljyang
...
codex/big-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a558db5795 | ||
|
|
b0caa5afcb | ||
|
|
5377d2c225 | ||
|
|
654573ac4b | ||
|
|
43f94ed1b8 | ||
|
|
fb5789d705 | ||
|
|
94a6e0a75e | ||
|
|
67c5f9d281 | ||
|
|
91202bdf71 | ||
|
|
c13f341d5e | ||
|
|
6962c4ff1c | ||
|
|
6b7f0eedd9 | ||
|
|
5bb3ceb47a | ||
|
|
cc778f3701 | ||
|
|
74d6efe261 | ||
|
|
a124e31fab | ||
|
|
a3dfe7ab8c | ||
|
|
3f0edfaaf5 | ||
|
|
feb950dd59 | ||
|
|
5e1c12eba2 | ||
|
|
ae24bc7647 | ||
|
|
0a372e4290 | ||
|
|
1e08d1ea62 | ||
|
|
2d82918d73 | ||
|
|
482243e052 | ||
|
|
f1a69c8271 | ||
|
|
1d2c3a0cd5 | ||
|
|
e7ba5315e1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.worktrees
|
||||
|
||||
@@ -5,11 +5,21 @@ services:
|
||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
||||
network_mode: host
|
||||
environment:
|
||||
DB_HOST: "47.101.148.99"
|
||||
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
|
||||
DB_PORT: "3306"
|
||||
DB_USER: "root"
|
||||
DB_PASSWORD: "LN#Passw0rd@2026"
|
||||
DB_NAME: "lingniu_prod"
|
||||
DB_USER: "oneos_db_prod"
|
||||
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
|
||||
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"
|
||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>羚牛氢能车辆资产</title>
|
||||
<title>羚牛氢能</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"private": true,
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
|
||||
198
src/App.tsx
198
src/App.tsx
@@ -1,67 +1,165 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
||||
import { Shell, type ModuleConfig } from './components/Shell';
|
||||
import AssetsModule from './modules/assets/AssetsModule';
|
||||
import MileageModule from './modules/mileage/MileageModule';
|
||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
||||
import EnergyModule from './modules/energy/EnergyModule';
|
||||
import EleImportPage from './modules/ele/EleImportPage';
|
||||
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
|
||||
import AuthProvider from './auth/AuthProvider';
|
||||
import { useAuth } from './auth/useAuth';
|
||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
||||
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt } from "lucide-react";
|
||||
import { Shell, type ModuleConfig } from "./components/Shell";
|
||||
import AuthProvider from "./auth/AuthProvider";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
|
||||
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
||||
|
||||
const BASE_MODULES: ModuleConfig[] = [
|
||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||
];
|
||||
const AssetsModule = lazy(() => import("./modules/assets/AssetsModule"));
|
||||
const MileageModule = lazy(() => import("./modules/mileage/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 = {
|
||||
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
|
||||
const ASSETS_MODULE: ModuleConfig = {
|
||||
id: "assets",
|
||||
label: "资产管理",
|
||||
icon: Truck,
|
||||
component: AssetsModule,
|
||||
};
|
||||
|
||||
const MILEAGE_MODULE: ModuleConfig = {
|
||||
id: "mileage",
|
||||
label: "里程管理",
|
||||
icon: Route,
|
||||
component: MileageModule,
|
||||
};
|
||||
|
||||
const SCHEDULING_MODULE: ModuleConfig = {
|
||||
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
|
||||
id: "scheduling",
|
||||
label: "智能调度",
|
||||
icon: Activity,
|
||||
component: SchedulingModule,
|
||||
};
|
||||
|
||||
const HYDROGEN_MODULE: ModuleConfig = {
|
||||
id: "hydrogen",
|
||||
label: "氢能",
|
||||
icon: Fuel,
|
||||
component: HydrogenModule,
|
||||
};
|
||||
|
||||
const ELECTRIC_MODULE: ModuleConfig = {
|
||||
id: "electric",
|
||||
label: "电能",
|
||||
icon: BatteryCharging,
|
||||
component: ElectricModule,
|
||||
};
|
||||
|
||||
const ETC_MODULE: ModuleConfig = {
|
||||
id: "etc",
|
||||
label: "ETC",
|
||||
icon: Receipt,
|
||||
component: EtcModule,
|
||||
};
|
||||
|
||||
/**
|
||||
* 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径,
|
||||
* 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。
|
||||
*/
|
||||
function normalizePath() {
|
||||
if (typeof window === "undefined") return;
|
||||
const { pathname, hash, search } = window.location;
|
||||
|
||||
// 主路径 & 隐藏后台页保持不变
|
||||
if (pathname === "/asset" || pathname === "/energy") return;
|
||||
if (pathname === "/ele/import" || pathname === "/admin/feedback") return;
|
||||
|
||||
const legacyMap: Record<string, { path: string; hash?: string }> = {
|
||||
"/": { path: "/asset" },
|
||||
"/vehicle": { path: "/asset", hash: "assets" },
|
||||
"/assets": { path: "/asset", hash: "assets" },
|
||||
"/mileage": { path: "/asset", hash: "mileage" },
|
||||
"/scheduling": { path: "/asset", hash: "scheduling" },
|
||||
};
|
||||
|
||||
// 未知路径兜底到 /asset(保留原 hash 让 Shell 内部继续解析)
|
||||
const target = legacyMap[pathname] ?? { path: "/asset" };
|
||||
const finalHash = target.hash ? `#${target.hash}` : hash || "";
|
||||
window.history.replaceState(null, "", `${target.path}${search}${finalHash}`);
|
||||
}
|
||||
|
||||
normalizePath();
|
||||
|
||||
type PathSet = "asset" | "energy";
|
||||
|
||||
function getPathSet(): PathSet {
|
||||
return window.location.pathname === "/energy" ? "energy" : "asset";
|
||||
}
|
||||
|
||||
function getRouteKey(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
if (typeof window === "undefined") return "";
|
||||
const path = window.location.pathname;
|
||||
const hash = window.location.hash;
|
||||
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
|
||||
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
|
||||
return '';
|
||||
if (
|
||||
path === "/ele/import" ||
|
||||
hash === "#/ele/import" ||
|
||||
hash === "#ele/import"
|
||||
)
|
||||
return "ele/import";
|
||||
if (
|
||||
path === "/admin/feedback" ||
|
||||
hash === "#/admin/feedback" ||
|
||||
hash === "#admin/feedback"
|
||||
)
|
||||
return "admin/feedback";
|
||||
return "";
|
||||
}
|
||||
|
||||
function AuthGate() {
|
||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||
const [routeKey, setRouteKey] = useState(getRouteKey);
|
||||
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
|
||||
|
||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效
|
||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
|
||||
useEffect(() => {
|
||||
const update = () => setRouteKey(getRouteKey());
|
||||
window.addEventListener('hashchange', update);
|
||||
window.addEventListener('popstate', update);
|
||||
const update = () => {
|
||||
setRouteKey(getRouteKey());
|
||||
setPathSet(getPathSet());
|
||||
};
|
||||
window.addEventListener("hashchange", update);
|
||||
window.addEventListener("popstate", update);
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', update);
|
||||
window.removeEventListener('popstate', update);
|
||||
window.removeEventListener("hashchange", update);
|
||||
window.removeEventListener("popstate", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const modules = useMemo(() => {
|
||||
const result = [...BASE_MODULES];
|
||||
if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
|
||||
useEffect(() => {
|
||||
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||
}, [pathSet]);
|
||||
|
||||
const modules = useMemo<ModuleConfig[]>(() => {
|
||||
if (pathSet === "energy") {
|
||||
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
|
||||
}
|
||||
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE];
|
||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
||||
return result;
|
||||
}, [user?.roles]);
|
||||
}, [pathSet, user?.roles]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-xs text-slate-400 font-bold">正在验证身份...</p>
|
||||
<div className="min-h-screen bg-[var(--app-bg)] p-4 text-slate-800 md:p-8">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-4">
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
@@ -72,10 +170,28 @@ function AuthGate() {
|
||||
}
|
||||
|
||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||
if (routeKey === 'ele/import') return <EleImportPage />;
|
||||
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
|
||||
if (routeKey === "ele/import") {
|
||||
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() {
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
|
||||
import { PageFrame, SurfaceCard } from '../components/ui/surface';
|
||||
|
||||
export default function UnauthorizedPage({ message }: { message?: string }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
|
||||
<div className="text-center max-w-sm">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
|
||||
<ShieldX size={36} className="text-slate-400" />
|
||||
<PageFrame
|
||||
title="未授权访问"
|
||||
subtitle={message || '获取用户认证信息失败,可能是跳转令牌已过期或无效。'}
|
||||
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>
|
||||
<h1 className="text-lg font-black text-slate-800 mb-2">未授权访问</h1>
|
||||
<p className="text-xs text-slate-400 mb-4">
|
||||
{message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
|
||||
</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>
|
||||
<p className="mb-4 text-[10px] font-bold uppercase tracking-wider text-slate-400">请通过以下方式进入</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
||||
<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" />
|
||||
@@ -31,7 +36,7 @@ export default function UnauthorizedPage({ message }: { message?: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { DemoModeProvider } from './Blur';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
import { cn } from '../lib/cn';
|
||||
import { LoadingState } from './ui/surface';
|
||||
|
||||
export interface ModuleConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ size?: number; className?: string }>;
|
||||
component: ComponentType;
|
||||
component: ElementType;
|
||||
}
|
||||
|
||||
/** path 到模块 id 的映射 */
|
||||
const PATH_MAP: Record<string, string> = {
|
||||
'/vehicle': 'assets',
|
||||
'/assets': 'assets',
|
||||
'/mileage': 'mileage',
|
||||
'/scheduling': 'scheduling',
|
||||
'/energy': 'energy',
|
||||
};
|
||||
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||||
function getHashHead(): string {
|
||||
return window.location.hash.slice(1).split('/')[0];
|
||||
}
|
||||
|
||||
function getInitialModule(modules: ModuleConfig[]): string {
|
||||
// 优先看 hash
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (modules.some((m) => m.id === hash)) return hash;
|
||||
// 再看 pathname
|
||||
const pathModule = PATH_MAP[window.location.pathname];
|
||||
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
|
||||
// 默认第一个
|
||||
const head = getHashHead();
|
||||
if (modules.some((m) => m.id === head)) return head;
|
||||
return modules[0]?.id ?? '';
|
||||
}
|
||||
|
||||
function getHashModule(modules: ModuleConfig[]): string {
|
||||
const hash = window.location.hash.slice(1);
|
||||
return modules.some((m) => m.id === hash) ? hash : '';
|
||||
const head = getHashHead();
|
||||
return modules.some((m) => m.id === head) ? head : '';
|
||||
}
|
||||
|
||||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
@@ -48,16 +43,17 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
}, [modules]);
|
||||
|
||||
useEffect(() => {
|
||||
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
|
||||
if (window.location.hash.slice(1) !== activeModule) {
|
||||
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
|
||||
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
|
||||
if (getHashHead() !== activeModule) {
|
||||
const { pathname, search } = window.location;
|
||||
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
||||
}
|
||||
}, [activeModule]);
|
||||
|
||||
const switchModule = (id: string) => {
|
||||
if (window.location.hash.slice(1) === id) return;
|
||||
if (getHashHead() === id) return;
|
||||
const { pathname, search } = window.location;
|
||||
window.history.replaceState(null, '', `${pathname}${search}#${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 { user } = useAuth();
|
||||
const activeLabel = modules.find((m) => m.id === activeModule)?.label ?? '业务看板';
|
||||
const watermarkText = useMemo(() => {
|
||||
const name = user?.userName || '未登录';
|
||||
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
||||
@@ -74,7 +71,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
|
||||
return (
|
||||
<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="absolute inset-0" style={{
|
||||
@@ -83,7 +80,11 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
}} />
|
||||
</div>
|
||||
{/* 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) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = m.id === activeModule;
|
||||
@@ -91,27 +92,51 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
<button
|
||||
key={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
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
? 'text-white'
|
||||
: 'text-slate-400 hover:bg-white/8 hover:text-white',
|
||||
)}
|
||||
title={m.label}
|
||||
>
|
||||
<Icon size={22} />
|
||||
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="desktop-shell-active"
|
||||
className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30"
|
||||
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
|
||||
/>
|
||||
) : null}
|
||||
<Icon size={21} className="relative mb-1" />
|
||||
<span className="relative leading-tight">{m.label}</span>
|
||||
</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>
|
||||
|
||||
{/* 内容区 */}
|
||||
<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 />}
|
||||
</Suspense>
|
||||
<FeedbackFab module={activeModule} />
|
||||
</main>
|
||||
|
||||
{/* 移动端底部导航 (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) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = m.id === activeModule;
|
||||
@@ -119,13 +144,24 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
<button
|
||||
key={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} />
|
||||
<span className="text-[10px] mt-1">{m.label}</span>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="mobile-shell-active"
|
||||
className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100"
|
||||
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
|
||||
/>
|
||||
) : null}
|
||||
<Icon size={20} className="relative" />
|
||||
<span className="relative">{m.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</DemoModeProvider>
|
||||
|
||||
287
src/components/ui/surface.tsx
Normal file
287
src/components/ui/surface.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--app-bg: #f4f7fb;
|
||||
--panel-bg: rgba(255, 255, 255, 0.9);
|
||||
--hairline: rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
html, body {
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: var(--app-bg);
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -14,3 +21,52 @@ body {
|
||||
overflow: auto;
|
||||
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
3
src/lib/cn.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
interface FeedbackItem {
|
||||
id: number;
|
||||
@@ -20,10 +21,10 @@ interface FeedbackItem {
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
dimension: '💡 新维度',
|
||||
bug: '🐛 Bug',
|
||||
ux: '🎨 体验',
|
||||
other: '📝 其他',
|
||||
dimension: '新维度',
|
||||
bug: 'Bug',
|
||||
ux: '体验',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
||||
@@ -126,36 +127,36 @@ export default function FeedbackAdminPage() {
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
|
||||
<div className="max-w-5xl mx-auto space-y-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<PageFrame
|
||||
title="用户反馈管理"
|
||||
subtitle="查看、回复、跟进用户提交的建议,形成从问题发现到处理闭环的运营后台。"
|
||||
icon={Inbox}
|
||||
eyebrow="FEEDBACK OPS"
|
||||
meta={`当前 ${items.length} 条反馈`}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
|
||||
if (window.history.length > 1) window.history.back();
|
||||
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="返回"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</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">
|
||||
<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="刷新">
|
||||
<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="刷新">
|
||||
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</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
|
||||
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'}`}
|
||||
@@ -170,6 +171,7 @@ export default function FeedbackAdminPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
{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">
|
||||
@@ -192,9 +194,9 @@ export default function FeedbackAdminPage() {
|
||||
{/* 列表 */}
|
||||
<div className="space-y-2">
|
||||
{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 ? (
|
||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">还没有反馈</div>
|
||||
<EmptyState title="还没有反馈" description="新的用户反馈会出现在这里" />
|
||||
) : items.map(it => {
|
||||
const shots = parseScreenshots(it.screenshots);
|
||||
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
||||
@@ -218,7 +220,7 @@ export default function FeedbackAdminPage() {
|
||||
{(shots.length > 0 || it.contact) && (
|
||||
<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>}
|
||||
{it.contact && <span>📞 {it.contact}</span>}
|
||||
{it.contact && <span>联系 {it.contact}</span>}
|
||||
</div>
|
||||
)}
|
||||
{it.reply_content && (
|
||||
@@ -236,8 +238,6 @@ export default function FeedbackAdminPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详情 / 回复弹窗 */}
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
@@ -336,6 +336,6 @@ export default function FeedbackAdminPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,12 @@ import {
|
||||
Filter,
|
||||
ArrowRightLeft,
|
||||
MapPin,
|
||||
Download,
|
||||
CalendarDays,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -30,12 +34,13 @@ import {
|
||||
LabelList,
|
||||
} from 'recharts';
|
||||
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 type { WeeklyDetailItem } from './api';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api';
|
||||
import type { FlowDetailItem, FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
|
||||
import { SearchSelect } from '../../components/SearchSelect';
|
||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||
import Blur from '../../components/Blur';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
|
||||
// --- Constants ---
|
||||
@@ -46,6 +51,79 @@ const TABS = [
|
||||
{ id: 'customer', label: '按客户' },
|
||||
];
|
||||
|
||||
function MarqueeBanner() {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const [overflow, setOverflow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => {
|
||||
if (!trackRef.current || !innerRef.current) return;
|
||||
setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth);
|
||||
};
|
||||
check();
|
||||
const ro = new ResizeObserver(check);
|
||||
ro.observe(trackRef.current!);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const text = '车辆资产已于 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() {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
|
||||
const [tabReady, setTabReady] = useState(true);
|
||||
@@ -92,8 +170,13 @@ export default function AssetsModule() {
|
||||
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 [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
|
||||
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
|
||||
@@ -162,7 +245,7 @@ export default function AssetsModule() {
|
||||
setRegionData(region);
|
||||
setCustomerData(cust);
|
||||
setInventoryData(inv);
|
||||
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
||||
setLastUpdate(formatLocalDateTime(new Date()));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '数据加载失败');
|
||||
} finally {
|
||||
@@ -176,6 +259,24 @@ export default function AssetsModule() {
|
||||
return () => clearInterval(interval);
|
||||
}, [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(() => {
|
||||
fetchSubjects().then(setSubjects).catch(() => setSubjects([]));
|
||||
@@ -475,6 +576,40 @@ export default function AssetsModule() {
|
||||
return mp;
|
||||
}), [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 }[]>([]);
|
||||
useEffect(() => {
|
||||
if (customerChartView === 'province') {
|
||||
@@ -495,38 +630,60 @@ export default function AssetsModule() {
|
||||
|
||||
if (loading && !summary) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="animate-spin text-blue-500" size={32} />
|
||||
<span className="text-sm text-gray-500">正在加载数据...</span>
|
||||
<PageFrame
|
||||
title="车辆资产中心"
|
||||
subtitle="正在同步车辆、部门、区域与客户归属数据,加载完成后可继续穿透查看明细。"
|
||||
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 className="mt-8">
|
||||
<LoadingState label="正在加载车辆资产数据" />
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !summary) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="text-red-500 text-lg font-bold">加载失败</div>
|
||||
<div className="text-sm text-gray-500">{error}</div>
|
||||
<button onClick={loadData} className="mt-2 px-4 py-2 bg-blue-500 text-white rounded text-sm hover:bg-blue-600">
|
||||
重试
|
||||
<PageFrame
|
||||
title="车辆资产中心"
|
||||
subtitle="车辆资产数据暂时没有返回,请重试或稍后再看。"
|
||||
icon={Truck}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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 */}
|
||||
<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 */}
|
||||
<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 */}
|
||||
<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">
|
||||
@@ -689,13 +846,12 @@ export default function AssetsModule() {
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
|
||||
最后更新: {lastUpdate}
|
||||
</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>
|
||||
|
||||
{/* OneOS 迁移提示滚动条 */}
|
||||
<MarqueeBanner />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
@@ -708,11 +864,11 @@ export default function AssetsModule() {
|
||||
{tabReady && activeTab === 'overview' && (
|
||||
<>
|
||||
{/* 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 */}
|
||||
<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: '资产概览' })}>
|
||||
<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} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -722,9 +878,9 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
|
||||
{/* 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: '正在运营' })}>
|
||||
<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} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -737,9 +893,9 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
|
||||
{/* 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: '库存总数' })}>
|
||||
<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} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -752,9 +908,9 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
|
||||
{/* 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: '待交车' })}>
|
||||
<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} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -763,41 +919,163 @@ export default function AssetsModule() {
|
||||
</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 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">
|
||||
<span className="text-xs font-bold text-gray-800 group-hover:text-green-600">{SUMMARY.weeklyNew}</span>
|
||||
<span className="text-[8px] text-green-500/80 font-bold mt-0.5">新增</span>
|
||||
|
||||
<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">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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 className="w-[1px] h-3 bg-gray-100"></div>
|
||||
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-blue-50 py-1 rounded transition-all group"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Delivered', source: 'asset', title: '本周交车' })}>
|
||||
<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 className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
||||
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="w-[1px] h-3 bg-gray-100"></div>
|
||||
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-orange-50 py-1 rounded transition-all group"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Returned', source: 'asset', title: '本周还车' })}>
|
||||
<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 className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">待交率</div>
|
||||
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</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 */}
|
||||
<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="flex flex-wrap items-center gap-4 sm:gap-6">
|
||||
<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 */}
|
||||
<AnimatePresence>
|
||||
{showPlateNumbers && (
|
||||
|
||||
@@ -78,6 +78,43 @@ export interface WeeklyDetailItem {
|
||||
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[]> {
|
||||
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
|
||||
}
|
||||
@@ -125,3 +162,13 @@ export async function fetchWeeklyDetail(
|
||||
if (filters?.source) params.set('source', filters.source);
|
||||
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()}`);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface VehicleListItem {
|
||||
city: string | null;
|
||||
status: string;
|
||||
ownership: string;
|
||||
rentCompany?: string | null;
|
||||
contractNo: string | null;
|
||||
customerName: string | null;
|
||||
subjectOrg: string | null;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fetchJson } from '../../auth/api-client';
|
||||
import { useAuth } from '../../auth/useAuth';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import FeedbackFab from '../../components/FeedbackFab';
|
||||
import { PageFrame } from '../../components/ui/surface';
|
||||
|
||||
function getJwt(): string | null {
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
|
||||
<div className="max-w-6xl mx-auto space-y-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<PageFrame
|
||||
title="充电记录导入"
|
||||
subtitle="每日上传 xlsx,按订单编号去重,并自动匹配内部/外部车辆归属。"
|
||||
icon={Zap}
|
||||
eyebrow="ELECTRIC IMPORT"
|
||||
meta={user?.userName || '导入工作台'}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.history.length > 1) window.history.back();
|
||||
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="返回"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0">
|
||||
<Zap size={18} className="text-white" />
|
||||
<button
|
||||
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 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
|
||||
@@ -359,9 +364,8 @@ export default function EleImportPage() {
|
||||
</section>
|
||||
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
</div>
|
||||
<FeedbackFab module="ele" />
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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 TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly } from './api';
|
||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
@@ -12,26 +13,61 @@ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ 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() {
|
||||
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 [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
fetchElectricMonthly(customer, pick)
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
fetchElectricMonthly(customer, query)
|
||||
.then(m => {
|
||||
if (cancelled) return;
|
||||
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)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [customer, pick]);
|
||||
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
const toggleMonth = (m: string) => setOpenMonths(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 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 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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 日期速选 */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
||||
<SurfaceCard className="p-2 md:p-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setPick(opt.id)}
|
||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
||||
onClick={() => applyQuickPick(opt.id)}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === opt.id
|
||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</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 className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<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 => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
||||
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Truck size={14} />
|
||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</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 && (
|
||||
@@ -106,11 +210,11 @@ export default function ElectricDaily() {
|
||||
<span className="text-right">环比</span>
|
||||
</div>
|
||||
{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 ? (
|
||||
<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 ? (
|
||||
<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 => {
|
||||
const open = openMonths.has(m.month);
|
||||
return (
|
||||
|
||||
33
src/modules/energy/ElectricModule.tsx
Normal file
33
src/modules/energy/ElectricModule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Wallet, CalendarClock } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
||||
import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
function fmtYuan(yuan: number) {
|
||||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||
@@ -24,10 +25,10 @@ export default function ElectricOverview() {
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
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 trendData = data.trend;
|
||||
@@ -37,6 +38,12 @@ export default function ElectricOverview() {
|
||||
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
||||
? `${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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -44,28 +51,36 @@ export default function ElectricOverview() {
|
||||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||||
</div>
|
||||
{/* 横向 mini KPI 头 */}
|
||||
<div className="grid grid-cols-2 gap-2 md:gap-3">
|
||||
<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">
|
||||
<Wallet size={11} className="text-blue-600" />累计
|
||||
</div>
|
||||
<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 className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
|
||||
<MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
|
||||
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
|
||||
<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>
|
||||
|
||||
<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 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">
|
||||
<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>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<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 }}
|
||||
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]}>
|
||||
{trendData.map((_, i) => (
|
||||
<Cell key={i} fill="url(#electricBarGrad)" />
|
||||
@@ -98,7 +121,7 @@ export default function ElectricOverview() {
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
||||
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
||||
import ETCView from './ETCView';
|
||||
|
||||
type TopTab = 'hydrogen' | 'electric' | 'etc';
|
||||
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
|
||||
|
||||
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
|
||||
{ key: 'hydrogen', label: '氢能', icon: Fuel },
|
||||
{ key: 'electric', label: '电能', icon: BatteryCharging },
|
||||
{ key: 'etc', label: 'ETC', icon: Receipt },
|
||||
];
|
||||
|
||||
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
|
||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||
];
|
||||
|
||||
export default function EnergyModule() {
|
||||
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
|
||||
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
|
||||
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
|
||||
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
|
||||
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
|
||||
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
||||
|
||||
{/* 统一 sticky 头部:top tab + (氢能时) 子 tab;同一张卡片,无间隙 */}
|
||||
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
|
||||
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
{/* 顶部 tab:氢能 / 电能 / ETC */}
|
||||
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
|
||||
{TABS.map(tab => {
|
||||
const Icon = tab.icon;
|
||||
const active = activeTab === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span className="text-[11px] font-bold">{tab.label}</span>
|
||||
{active && (
|
||||
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 子 tab:氢能 / 电能 都显示 每日 / 总览 */}
|
||||
{showSubTabs && (
|
||||
<div className="p-1 flex gap-1">
|
||||
{SUB_TABS.map(({ id, label, icon: Icon }) => {
|
||||
const active = currentSub === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setSub(id)}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
||||
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
|
||||
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
|
||||
{activeTab === 'etc' && <ETCView />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/modules/energy/EtcModule.tsx
Normal file
17
src/modules/energy/EtcModule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { 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 { fetchHydrogenDaily } from './api';
|
||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
@@ -13,25 +14,76 @@ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ 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() {
|
||||
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 [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
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); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
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 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 next = new Set(prev);
|
||||
@@ -39,39 +91,91 @@ export default function HydrogenDaily() {
|
||||
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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 日期速选 */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
||||
<SurfaceCard className="p-2 md:p-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setPick(opt.id)}
|
||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
||||
onClick={() => applyQuickPick(opt.id)}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === opt.id
|
||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</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>
|
||||
|
||||
{/* 客户类型 segmented */}
|
||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<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 => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
||||
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Truck size={14} />
|
||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</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 && (
|
||||
@@ -96,13 +200,34 @@ export default function HydrogenDaily() {
|
||||
|
||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<SurfaceCard>
|
||||
<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-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">时间单位:日 · 单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
|
||||
<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
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
@@ -112,13 +237,27 @@ export default function HydrogenDaily() {
|
||||
interval="preserveStartEnd"
|
||||
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
|
||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
|
||||
labelFormatter={(d) => `日期 ${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
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]}>
|
||||
{trendData.map((_, i) => (
|
||||
<Cell key={i} fill="url(#hydrogenBarGrad)" />
|
||||
@@ -133,6 +272,7 @@ export default function HydrogenDaily() {
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
|
||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
||||
@@ -154,11 +294,11 @@ export default function HydrogenDaily() {
|
||||
</div>
|
||||
{/* 主行 + 子行 */}
|
||||
{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 ? (
|
||||
<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 ? (
|
||||
<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 => {
|
||||
const open = expanded.has(r.date);
|
||||
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
||||
|
||||
33
src/modules/energy/HydrogenModule.tsx
Normal file
33
src/modules/energy/HydrogenModule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||||
} 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 { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
@@ -147,6 +147,14 @@ export default function HydrogenOverview() {
|
||||
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
||||
|
||||
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 => ({
|
||||
@@ -158,7 +166,7 @@ export default function HydrogenOverview() {
|
||||
<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">
|
||||
<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-1 bg-slate-50 rounded-lg p-0.5">
|
||||
{availableYears.map(y => {
|
||||
@@ -247,6 +255,59 @@ export default function HydrogenOverview() {
|
||||
/>
|
||||
</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 && (
|
||||
<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 });
|
||||
}
|
||||
|
||||
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() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 animate-pulse">
|
||||
|
||||
22
src/modules/energy/SubTabs.tsx
Normal file
22
src/modules/energy/SubTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -27,8 +27,17 @@ export function fetchHydrogenOverview(year?: number, force = false): Promise<Hyd
|
||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
|
||||
}
|
||||
|
||||
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
const q = new URLSearchParams({ range, customer });
|
||||
export interface HydrogenDailyQuery {
|
||||
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()}`);
|
||||
}
|
||||
|
||||
@@ -41,7 +50,10 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||
}
|
||||
|
||||
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer, range });
|
||||
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||
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()}`);
|
||||
}
|
||||
|
||||
38
src/modules/energy/useHashSubTab.ts
Normal file
38
src/modules/energy/useHashSubTab.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 把模块内子 tab 状态同步到 URL hash 二级段。
|
||||
* hash 形如 `#<moduleId>`(= 默认 sub)或 `#<moduleId>/<sub>`。
|
||||
* 默认值不写入 hash,刷新页面可恢复。
|
||||
*/
|
||||
export function useHashSubTab<T extends string>(
|
||||
moduleId: string,
|
||||
subs: readonly T[],
|
||||
): [T, (sub: T) => void] {
|
||||
const defaultSub = subs[0];
|
||||
|
||||
const parse = (): T => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
const [first, second] = hash.split('/');
|
||||
if (first !== moduleId) return defaultSub;
|
||||
if (second && (subs as readonly string[]).includes(second)) return second as T;
|
||||
return defaultSub;
|
||||
};
|
||||
|
||||
const [sub, setSubState] = useState<T>(parse);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setSubState(parse());
|
||||
window.addEventListener('hashchange', onChange);
|
||||
return () => window.removeEventListener('hashchange', onChange);
|
||||
}, [moduleId]);
|
||||
|
||||
const setSub = (next: T) => {
|
||||
const { pathname, search } = window.location;
|
||||
const newHash = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
|
||||
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
|
||||
setSubState(next);
|
||||
};
|
||||
|
||||
return [sub, setSub];
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
import { FileText } from 'lucide-react';
|
||||
import { SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
export default function DailyReportView() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<FileText size={48} className="mx-auto text-gray-300 mb-4" />
|
||||
<h2 className="text-lg font-semibold text-gray-500">每日汇报</h2>
|
||||
<p className="text-sm text-gray-400 mt-2">开发中...</p>
|
||||
<SurfaceCard className="min-h-[360px]">
|
||||
<div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-10 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||
<FileText size={26} />
|
||||
</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>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,51 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import MonitoringView from './MonitoringView';
|
||||
import StatisticsView from './StatisticsView';
|
||||
import DailyReportView from './DailyReportView';
|
||||
import { useHashSubTab } from '../energy/useHashSubTab';
|
||||
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() {
|
||||
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
||||
const [activeSubTab, setActiveSubTab] = useHashSubTab<MileageSubTab>('mileage', MILEAGE_SUB_IDS);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
|
||||
{/* Sub-navigation — sticky */}
|
||||
<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">
|
||||
<button
|
||||
onClick={() => setActiveSubTab('monitoring')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
<PageFrame
|
||||
title="车辆里程中心"
|
||||
subtitle="统一监控车辆日里程、累计里程、考核进度与日报经营口径,突出异常车辆和任务压力。"
|
||||
icon={LayoutDashboard}
|
||||
eyebrow="MILEAGE BI"
|
||||
meta="实时监控 · 统计报表 · 每日汇报"
|
||||
compactInfo
|
||||
>
|
||||
<LayoutDashboard size={14} />
|
||||
<span className="text-[11px] font-bold">实时监控</span>
|
||||
{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 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={MILEAGE_TABS} active={activeSubTab} onChange={setActiveSubTab} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<FadeIn key={activeSubTab}>
|
||||
{activeSubTab === 'monitoring' ? (
|
||||
<MonitoringView />
|
||||
) : activeSubTab === 'statistics' ? (
|
||||
@@ -53,8 +42,9 @@ export default function MileageModule() {
|
||||
) : (
|
||||
<DailyReportView />
|
||||
)}
|
||||
</FadeIn>
|
||||
</AnimatePresence>
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Truck, Filter, ChevronDown,
|
||||
Maximize2, Minimize2, RotateCcw,
|
||||
ArrowUp, ArrowDown, ChevronsUp, Download,
|
||||
ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays,
|
||||
} from 'lucide-react';
|
||||
import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine, XAxis } from 'recharts';
|
||||
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
||||
import { fetchMonitoring } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
@@ -12,6 +13,46 @@ import PlateMultiSelect from './PlateMultiSelect';
|
||||
import { exportMileageXlsx } from './xlsx-export';
|
||||
import VehicleDetailModal from './VehicleDetailModal';
|
||||
|
||||
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
||||
'交投40辆4.5T普货',
|
||||
'交投190辆4.5T冷链车',
|
||||
]);
|
||||
const HIGH_MILEAGE_ALERT_KM = 800;
|
||||
|
||||
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 = ({
|
||||
options,
|
||||
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() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterDept, setFilterDept] = useState('All');
|
||||
@@ -111,21 +275,20 @@ export default function MonitoringView() {
|
||||
const [filterEntity, setFilterEntity] = useState('All');
|
||||
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
||||
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
||||
const [filterTargetName, setFilterTargetName] = useState('All');
|
||||
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
|
||||
const [filterRegion, setFilterRegion] = useState('All');
|
||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
||||
const [filterDate, setFilterDate] = useState(() => {
|
||||
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')}`;
|
||||
});
|
||||
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
|
||||
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
|
||||
|
||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||
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 [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 [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
@@ -136,6 +299,26 @@ export default function MonitoringView() {
|
||||
|
||||
const departments = filterOptions.departments;
|
||||
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(() => {
|
||||
@@ -152,21 +335,24 @@ export default function MonitoringView() {
|
||||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
date: filterDate || undefined,
|
||||
startDate: rangeStart || undefined,
|
||||
endDate: rangeEnd || undefined,
|
||||
}).then(d => {
|
||||
setVehicles(d.vehicles);
|
||||
setStats(d.stats);
|
||||
setFilterOptions(d.filters);
|
||||
setRangeDailyTotals(d.rangeDailyTotals || []);
|
||||
setEffectiveRange(d.dateRange || { start: rangeStart, end: rangeEnd });
|
||||
setTotal(d.total);
|
||||
setPage(1);
|
||||
setHasMore(d.page < d.totalPages);
|
||||
}).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(() => {
|
||||
@@ -185,18 +371,19 @@ export default function MonitoringView() {
|
||||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
date: filterDate || undefined,
|
||||
startDate: rangeStart || undefined,
|
||||
endDate: rangeEnd || undefined,
|
||||
}).then(d => {
|
||||
setVehicles(prev => [...prev, ...d.vehicles]);
|
||||
setPage(nextPage);
|
||||
setHasMore(nextPage < d.totalPages);
|
||||
}).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(() => {
|
||||
@@ -228,20 +415,21 @@ export default function MonitoringView() {
|
||||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
mileageMin: appliedMileageRange.min || 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) {
|
||||
console.error('export failed', err);
|
||||
} finally {
|
||||
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(() => {
|
||||
@@ -307,16 +495,17 @@ export default function MonitoringView() {
|
||||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
date: filterDate || undefined,
|
||||
startDate: rangeStart || undefined,
|
||||
endDate: rangeEnd || undefined,
|
||||
}).then(d => {
|
||||
setFullscreenVehicles(d.vehicles);
|
||||
setFullscreenStats(d.stats);
|
||||
setFilterOptions(d.filters);
|
||||
}).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(() => {
|
||||
@@ -377,7 +566,7 @@ export default function MonitoringView() {
|
||||
<h2 className="text-white font-bold text-xs">全屏监控</h2>
|
||||
</div>
|
||||
<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-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>
|
||||
@@ -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 items-center gap-1 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => setFilterTargetName('All')}
|
||||
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'}`}
|
||||
onClick={() => setFilterTargetNames([])}
|
||||
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>
|
||||
{filterOptions.targetNames.map(n => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setFilterTargetName(filterTargetName === n ? 'All' : 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'}`}
|
||||
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 ${filterTargetNames.includes(n) ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
|
||||
>{n.replace(/交投|羚牛|恒运/, '').replace(/辆/, '台')}</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -498,7 +687,7 @@ export default function MonitoringView() {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span>今日里程</span>
|
||||
<span>区间里程</span>
|
||||
{sortBy === 'today' && (
|
||||
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
|
||||
)}
|
||||
@@ -525,7 +714,9 @@ export default function MonitoringView() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
<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>
|
||||
@@ -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.department || '-'}</td>
|
||||
<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'}`}>
|
||||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
|
||||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
@@ -545,7 +736,8 @@ export default function MonitoringView() {
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -581,7 +773,7 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight">实时监控 • 每分钟更新</span>
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight">数据监控 • 每15分钟更新</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -592,7 +784,7 @@ export default function MonitoringView() {
|
||||
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'}`}
|
||||
>
|
||||
今日
|
||||
区间
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSortBy('total')}
|
||||
@@ -614,10 +806,10 @@ export default function MonitoringView() {
|
||||
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
||||
<SearchableSelect
|
||||
<BatchMultiSelect
|
||||
options={filterOptions.targetNames}
|
||||
value={filterTargetName}
|
||||
onChange={setFilterTargetName}
|
||||
selected={filterTargetNames}
|
||||
onChange={setFilterTargetNames}
|
||||
placeholder="批次型号"
|
||||
/>
|
||||
<SearchableSelect
|
||||
@@ -636,11 +828,40 @@ export default function MonitoringView() {
|
||||
|
||||
<button
|
||||
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} />
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{/* Expandable Filter Panel */}
|
||||
@@ -653,15 +874,42 @@ export default function MonitoringView() {
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<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
|
||||
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={filterDate}
|
||||
onChange={(e) => setFilterDate(e.target.value)}
|
||||
value={rangeStart}
|
||||
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 className="grid grid-cols-2 gap-3">
|
||||
@@ -784,10 +1032,13 @@ export default function MonitoringView() {
|
||||
setFilterProject('All');
|
||||
setFilterEntity('All');
|
||||
setFilterPlatePrefix('All');
|
||||
setFilterTargetName('All');
|
||||
setFilterTargetNames([]);
|
||||
setFilterRegion('All');
|
||||
setFilterMileageRange({ min: '', max: '' });
|
||||
setAppliedMileageRange({ min: '', max: '' });
|
||||
const today = defaultMileageDate();
|
||||
setRangeStart(today);
|
||||
setRangeEnd(today);
|
||||
}}
|
||||
className="text-[10px] font-bold text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
@@ -811,7 +1062,10 @@ export default function MonitoringView() {
|
||||
{/* Active Filter Tags */}
|
||||
{(() => {
|
||||
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 (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') });
|
||||
@@ -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.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 (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;
|
||||
const clearAll = () => {
|
||||
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: '' });
|
||||
setFilterDate('');
|
||||
const today = defaultMileageDate();
|
||||
setRangeStart(today); setRangeEnd(today);
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -847,13 +1109,14 @@ export default function MonitoringView() {
|
||||
})()}
|
||||
|
||||
{/* 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="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">
|
||||
{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 className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -866,6 +1129,76 @@ export default function MonitoringView() {
|
||||
<div className="text-[7px] text-slate-400">台</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">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆详情清单</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">
|
||||
{filteredVehicles.map((v) => (
|
||||
{filteredVehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -929,9 +1264,9 @@ export default function MonitoringView() {
|
||||
{!v.isDataSynced && v.totalKm == null && (
|
||||
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
|
||||
)}
|
||||
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none">今</span>
|
||||
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? '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>}
|
||||
<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) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
|
||||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>km</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -942,7 +1277,8 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredVehicles.length === 0 && !loadingMore && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Truck, ChevronDown, Maximize2, Minimize2,
|
||||
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
||||
} from 'lucide-react';
|
||||
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||
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')}`;
|
||||
}
|
||||
|
||||
function getCurrentDateLabel(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
|
||||
}
|
||||
|
||||
function fmtKm(value: number): string {
|
||||
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function fmtPercent(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function getTargetAssessment(target: TargetSummary, selectedYear?: number): TargetYearlyAssessment | null {
|
||||
if (target.yearlyAssessments.length === 0) return null;
|
||||
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
|
||||
}
|
||||
|
||||
function fmtDateLabel(date: string | null): string {
|
||||
if (!date) return '';
|
||||
const [year, month, day] = date.split('-');
|
||||
return `${year}.${Number(month)}.${Number(day)}`;
|
||||
}
|
||||
|
||||
function shortTargetName(name: string): string {
|
||||
// Extract the number and a short description
|
||||
const match = name.match(/(\d+)[辆台](.+)/);
|
||||
@@ -39,6 +59,7 @@ function shortTargetName(name: string): string {
|
||||
}
|
||||
|
||||
export default function StatisticsView() {
|
||||
const currentDateLabel = getCurrentDateLabel();
|
||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||
@@ -46,7 +67,8 @@ export default function StatisticsView() {
|
||||
|
||||
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
||||
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 [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
||||
const [viewAllSearch, setViewAllSearch] = useState('');
|
||||
@@ -54,12 +76,33 @@ export default function StatisticsView() {
|
||||
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
||||
const [viewAllLoading, setViewAllLoading] = useState(false);
|
||||
|
||||
const selectedTarget = targets.find(t => t.id === selectedTargetId);
|
||||
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
||||
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
||||
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
|
||||
useEffect(() => {
|
||||
fetchTargets().then(data => {
|
||||
setTargets(data);
|
||||
if (data.length > 0 && !selectedTargetId) {
|
||||
setSelectedTargetId(data[0].id);
|
||||
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
||||
const ordered = focused
|
||||
? [focused, ...data.filter(item => item.id !== focused.id)]
|
||||
: data;
|
||||
setTargets(ordered);
|
||||
if (ordered.length > 0 && !selectedTargetId) {
|
||||
setSelectedTargetId(focused.id);
|
||||
setExpandedTargetId(focused.id);
|
||||
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1])));
|
||||
fetchTargetVehicles(focused.id).then(vehicles => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [focused.id]: vehicles }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
@@ -67,6 +110,12 @@ export default function StatisticsView() {
|
||||
// Load trend when selectedTargetId changes
|
||||
useEffect(() => {
|
||||
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([]));
|
||||
}, [selectedTargetId]);
|
||||
|
||||
@@ -80,13 +129,16 @@ export default function StatisticsView() {
|
||||
}, [viewAllTargetId, viewAllDate]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1" 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 */}
|
||||
<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 => (
|
||||
<button
|
||||
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 ${
|
||||
selectedTargetId === target.id
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
||||
@@ -98,12 +150,51 @@ export default function StatisticsView() {
|
||||
))}
|
||||
</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">
|
||||
{/* 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">
|
||||
{/* KPI Cards in Landscape — linked to selected target */}
|
||||
{(() => {
|
||||
const sel = targets.find(t => t.id === selectedTargetId);
|
||||
const sel = selectedTarget;
|
||||
return (
|
||||
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
|
||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||
@@ -130,7 +221,7 @@ export default function StatisticsView() {
|
||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
|
||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||
{(sel?.avgCompletion ?? 0).toFixed(1)}
|
||||
{selectedCompletion.toFixed(1)}
|
||||
<span className="text-blue-500 text-[10px] ml-1">%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,9 +249,8 @@ export default function StatisticsView() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 w-full min-h-[250px] relative">
|
||||
<div className="absolute inset-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<div className="h-[280px] w-full min-w-0">
|
||||
<ResponsiveContainer width="100%" height={280} minWidth={0}>
|
||||
{chartType === 'bar' ? (
|
||||
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
||||
@@ -205,14 +295,16 @@ export default function StatisticsView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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="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" />
|
||||
<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>
|
||||
<button
|
||||
onClick={() => setIsTableFullscreen(true)}
|
||||
@@ -223,13 +315,18 @@ export default function StatisticsView() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
key={idx}
|
||||
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
|
||||
onClick={() => {
|
||||
const name = target.targetName;
|
||||
setExpandedModel(expandedModel === name ? null : name);
|
||||
setExpandedTargetId(target.id);
|
||||
if (!targetVehiclesMap[target.id]) {
|
||||
fetchTargetVehicles(target.id).then(data => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
|
||||
@@ -249,12 +346,12 @@ export default function StatisticsView() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-slate-400">完成率:</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] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
|
||||
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-slate-400">达标:</span>
|
||||
<span className="text-[9px] font-bold text-slate-600">{target.yearQualifiedCount}台</span>
|
||||
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}台</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,7 +366,7 @@ export default function StatisticsView() {
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: expandedModel === target.targetName ? 180 : 0 }}
|
||||
animate={{ rotate: 180 }}
|
||||
className="text-slate-300"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
@@ -277,17 +374,37 @@ export default function StatisticsView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedModel === target.targetName && (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{expandedTargetId === target.id && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
|
||||
<span className="text-[10px] font-black text-blue-700">考核年度</span>
|
||||
<select
|
||||
value={assessment?.yearNumber || ''}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setAssessmentYearMap(prev => ({ ...prev, [target.id]: Number(e.target.value) }));
|
||||
}}
|
||||
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
|
||||
>
|
||||
{target.yearlyAssessments.map(item => (
|
||||
<option key={item.yearNumber} value={item.yearNumber}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">考核区间</p>
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核区间</p>
|
||||
{target.periods.map((p, i) => (
|
||||
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
|
||||
))}
|
||||
@@ -296,33 +413,64 @@ export default function StatisticsView() {
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
|
||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
||||
</div>
|
||||
</div>
|
||||
{assessment ? (
|
||||
<>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">年考核任务/辆</p>
|
||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle)} km</p>
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}区间</p>
|
||||
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, i) => (
|
||||
<p key={i} className="text-[10px] font-black text-slate-700">{period}</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%达标数</p>
|
||||
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} 台</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 * assessment.yearNumber)} km</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">本年需完成</p>
|
||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</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">{assessment.vehicleCount} 台</p>
|
||||
{assessment.vehicleCount < target.vehicleCount && (
|
||||
<p className="text-[8px] font-bold text-slate-400">其余 {target.vehicleCount - assessment.vehicleCount} 台尚未进入{assessment.label}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">已完成(截止3.31)</p>
|
||||
<p className="text-[10px] font-black text-emerald-600">{fmtKm(target.currentYearCompleted)} km</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(assessment.target)} km</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">未完成总数</p>
|
||||
<p className="text-[10px] font-black text-rose-500">{fmtKm(target.remaining)} km</p>
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已完成</p>
|
||||
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
|
||||
<p className="text-[8px] font-bold text-slate-300">
|
||||
数据截至 {assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">日均需完成</p>
|
||||
<p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</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">{fmtPercent(assessment.completionRate)}</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}达标率</p>
|
||||
<p className="text-[10px] font-black text-emerald-600">{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount}台)</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}剩余里程</p>
|
||||
<p className="text-[10px] font-black text-rose-500">{fmtKm(assessment.remaining)} km</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}日均需完成</p>
|
||||
<p className="text-[10px] font-black text-blue-500">
|
||||
{assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '考核已到期'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="col-span-2 bg-slate-50 p-2 rounded-lg text-[10px] font-bold text-slate-400">
|
||||
暂无第一年度车辆,当前车型车辆已进入后续考核年度。
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
|
||||
<span className="text-[9px] font-bold text-slate-500">剩余考核天数</span>
|
||||
<span className="text-[10px] font-black text-slate-900">{target.daysLeft} 天</span>
|
||||
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
|
||||
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} 天</span>
|
||||
</div>
|
||||
|
||||
{/* Vehicle List Detail */}
|
||||
@@ -363,6 +511,8 @@ export default function StatisticsView() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,7 +541,7 @@ export default function StatisticsView() {
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> 台</span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">完成率 <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + 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 className="flex items-center gap-2">
|
||||
@@ -417,12 +567,12 @@ export default function StatisticsView() {
|
||||
<tr className="border-b border-slate-800/60">
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">完成进度</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年任务/辆</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">达标</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">年度进度</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度任务/辆</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">年度达标</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%达标</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right">今日里程</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">本年目标</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度目标</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">已完成</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right">未完成</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
|
||||
@@ -430,10 +580,22 @@ export default function StatisticsView() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
|
||||
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
|
||||
<div className="text-[9px] text-blue-400 font-bold mt-0.5">{assessment?.label || '当前年度'}</div>
|
||||
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
|
||||
@@ -441,28 +603,31 @@ export default function StatisticsView() {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
||||
style={{ width: `${Math.min(target.avgCompletion, 100)}%` }}
|
||||
className={`h-full rounded-full ${completion >= 90 ? 'bg-emerald-500' : completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
||||
style={{ width: `${Math.min(completion, 100)}%` }}
|
||||
/>
|
||||
</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 className="flex justify-between mt-1 text-[9px] text-slate-500">
|
||||
<span>{fmtKm(target.cumulativeTotal)}</span>
|
||||
<span>/ {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</span>
|
||||
<span>{fmtKm(completed)}</span>
|
||||
<span>/ {fmtKm(goal)} km</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(target.annualMileagePerVehicle)} km</td>
|
||||
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{target.yearQualifiedCount}</td>
|
||||
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{target.halfQualifiedCount}</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(taskPerVehicle)} km</td>
|
||||
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{qualified}</td>
|
||||
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{halfQualified}</td>
|
||||
<td className="px-3 py-3 text-xs 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-emerald-400/80 text-right">{fmtKm(target.currentYearCompleted)} km</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(target.remaining)} km</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-300 text-center">{target.daysLeft}</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">{fmtKm(target.dailyTarget)} km</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(goal)} km</td>
|
||||
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(completed)} km</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(remainingMileage)} km</td>
|
||||
<td className="px-3 py-3 text-xs text-slate-300 text-center">{days}</td>
|
||||
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
|
||||
{assessment && days === 0 ? '考核已到期' : `${fmtKm(daily)} km`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -16,11 +16,14 @@ export async function fetchMonitoring(params?: {
|
||||
rentStatus?: string;
|
||||
platePrefix?: string;
|
||||
targetName?: string;
|
||||
targetNames?: string[];
|
||||
region?: string;
|
||||
plate?: string;
|
||||
mileageMin?: string;
|
||||
mileageMax?: string;
|
||||
date?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<MonitoringData> {
|
||||
const query = new URLSearchParams();
|
||||
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?.platePrefix) query.set('platePrefix', params.platePrefix);
|
||||
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?.plate) query.set('plate', params.plate);
|
||||
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
||||
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
||||
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();
|
||||
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
@@ -87,3 +97,135 @@ export async function fetchVehicleRecent(
|
||||
`${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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface MonitoringVehicle {
|
||||
plate: string;
|
||||
vin: string;
|
||||
dailyKm: number;
|
||||
dailyMileage?: Record<string, number>;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
isOnline: boolean;
|
||||
@@ -13,6 +14,7 @@ export interface MonitoringVehicle {
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
region: string | null;
|
||||
targetNames: string[];
|
||||
}
|
||||
|
||||
export interface MonitoringStats {
|
||||
@@ -38,6 +40,8 @@ export interface MonitoringData {
|
||||
vehicles: MonitoringVehicle[];
|
||||
stats: MonitoringStats;
|
||||
filters: MonitoringFilters;
|
||||
rangeDailyTotals?: { date: string; totalKm: number }[];
|
||||
dateRange?: { start: string; end: string };
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
@@ -63,6 +67,37 @@ export interface TargetSummary {
|
||||
remaining: number;
|
||||
daysLeft: number;
|
||||
dailyTarget: number;
|
||||
firstYearVehicleCount: number;
|
||||
firstYearTarget: number;
|
||||
firstYearCompleted: number;
|
||||
firstYearRemaining: number;
|
||||
firstYearCompletionRate: number;
|
||||
firstYearQualifiedCount: number;
|
||||
firstYearQualifiedRate: number;
|
||||
firstYearHalfQualifiedCount: number;
|
||||
firstYearDaysLeft: number;
|
||||
firstYearDailyTarget: number;
|
||||
firstYearStartDate: string | null;
|
||||
firstYearEndDate: string | null;
|
||||
yearlyAssessments: TargetYearlyAssessment[];
|
||||
}
|
||||
|
||||
export interface TargetYearlyAssessment {
|
||||
yearNumber: number;
|
||||
label: string;
|
||||
vehicleCount: number;
|
||||
target: number;
|
||||
completed: number;
|
||||
remaining: number;
|
||||
completionRate: number;
|
||||
qualifiedCount: number;
|
||||
qualifiedRate: number;
|
||||
halfQualifiedCount: number;
|
||||
daysLeft: number;
|
||||
dailyTarget: number;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
periods: string[];
|
||||
}
|
||||
|
||||
export interface TargetVehicle {
|
||||
|
||||
@@ -2,13 +2,15 @@ import * as XLSX from 'xlsx';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
|
||||
interface ExportContext {
|
||||
date: string;
|
||||
date?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortBy: 'today' | 'total';
|
||||
}
|
||||
|
||||
const HEADERS = [
|
||||
'状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
||||
'运营区域', '今日里程(km)', '累计里程(km)',
|
||||
'运营区域', '区间里程(km)', '累计里程(km)',
|
||||
] as const;
|
||||
|
||||
function statusLabel(v: MonitoringVehicle): string {
|
||||
@@ -18,15 +20,18 @@ function statusLabel(v: MonitoringVehicle): string {
|
||||
|
||||
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
|
||||
if (kind === 'today') {
|
||||
// 当日未对接但有历史累计,视作今日 0;只有完全无数据才标「未对接」
|
||||
// 区间内未对接但有历史累计,视作区间 0;只有完全无数据才标「未对接」。
|
||||
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 {
|
||||
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],
|
||||
...vehicles.map(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'] = [
|
||||
{ wch: 8 }, // 状态
|
||||
@@ -51,12 +56,19 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
{ wch: 16 }, // 项目
|
||||
{ wch: 10 }, // 租赁状态
|
||||
{ wch: 12 }, // 运营区域
|
||||
{ wch: 14 }, // 今日里程
|
||||
{ wch: 14 }, // 区间里程
|
||||
{ wch: 14 }, // 累计里程
|
||||
];
|
||||
|
||||
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 社区版仅基本样式生效)
|
||||
for (let c = 0; c < HEADERS.length; 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();
|
||||
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 y = now.getFullYear();
|
||||
@@ -78,7 +136,9 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const dateTag = ctx.date ? ctx.date.replace(/-/g, '') : `${y}${m}${d}`;
|
||||
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? '今日' : '累计'}.xlsx`;
|
||||
const dateTag = start && end
|
||||
? `${start.replace(/-/g, '')}-${end.replace(/-/g, '')}`
|
||||
: `${y}${m}${d}`;
|
||||
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? (isRange ? '区间' : '今日') : '累计'}.xlsx`;
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { fetchSuggestions, sendNotifyBatch } from './api';
|
||||
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
@@ -9,6 +9,7 @@ import NotificationHistory from './NotificationHistory';
|
||||
import { exportSuggestionsCsv } from './csv-export';
|
||||
import Blur from '../../components/Blur';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { MetricTile, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||
|
||||
@@ -87,63 +88,43 @@ function FilterSelect({ label, options, value, onChange, placeholder }: {
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton pulse block */
|
||||
function Sk({ className }: { className?: string }) {
|
||||
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
|
||||
}
|
||||
|
||||
function SkeletonPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F0F4F8] font-sans p-3 md:p-6">
|
||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
||||
{/* Cards skeleton */}
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className="p-4 rounded-2xl bg-white border border-slate-100 space-y-2.5">
|
||||
<Sk className="h-3 w-16" />
|
||||
<Sk className="h-7 w-12" />
|
||||
<Sk className="h-2.5 w-24" />
|
||||
</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>
|
||||
<PageFrame
|
||||
title="智能调度工作台"
|
||||
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
|
||||
icon={Activity}
|
||||
eyebrow="SCHEDULING OPS"
|
||||
meta="建议生成中 · 正在计算候选车辆"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <SkeletonBlock key={i} className="h-28" />)}
|
||||
</div>
|
||||
<SurfaceCard>
|
||||
<div className="space-y-3 p-4">
|
||||
<SkeletonBlock className="h-5 w-40" />
|
||||
<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>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="divide-y divide-slate-50">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3 flex items-center gap-3">
|
||||
<Sk className="w-1 h-10 rounded-full" />
|
||||
<div key={i} className="flex items-center gap-3 py-3">
|
||||
<SkeletonBlock className="h-10 w-1 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sk className="h-3.5 w-20" />
|
||||
<Sk className="h-3 w-10 rounded-full" />
|
||||
<Sk className="h-3 w-14" />
|
||||
<SkeletonBlock className="h-3.5 w-48" />
|
||||
<SkeletonBlock className="h-2.5 w-72 max-w-full" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<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" />
|
||||
<SkeletonBlock className="h-6 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -275,89 +256,33 @@ export default function SchedulingModule() {
|
||||
if (loading && !data) return <SkeletonPage />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F0F4F8] text-slate-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
|
||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
||||
<PageFrame
|
||||
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 ===== */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
|
||||
{/* 里程高·换下 — warm orange */}
|
||||
<button
|
||||
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>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<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'}>
|
||||
<MetricTile icon={CheckCircle2} label="已完成考核目标" value={summary?.qualifiedCount ?? 0} unit="台" helper="换下,腾位给待达标车" tone="amber" />
|
||||
</button>
|
||||
|
||||
{/* 里程低·换走 — cool 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 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'}>
|
||||
<MetricTile icon={AlertTriangle} label="预估无法达标" value={summary?.hopelessCount ?? 0} unit="台" helper="换走,换上快达标的车" tone="blue" />
|
||||
</button>
|
||||
|
||||
{/* 替换建议 — neutral dark */}
|
||||
<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 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'}>
|
||||
<MetricTile icon={Activity} label="替换建议" value={summary?.suggestionCount ?? 0} unit="条" helper={`执行后预计 +${summary?.estimatedGain ?? 0} 台达标`} tone="slate" />
|
||||
</button>
|
||||
|
||||
{/* 近期已干预 — 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 type="button" onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }} className="rounded-2xl">
|
||||
<MetricTile icon={SendHorizonal} label="近期已干预" value={summary?.recentInterventionCount ?? 0} unit="条" helper="最近 7 天 · 点击查看" tone="emerald" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -632,8 +557,7 @@ export default function SchedulingModule() {
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,11 +52,15 @@ app.get('/exchange', async (c) => {
|
||||
// 查询 depCode 对应的部门名称
|
||||
let depName = '';
|
||||
if (userInfo.depCode) {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
|
||||
[userInfo.depCode]
|
||||
) as [{ dep_name: string }[], unknown];
|
||||
depName = rows[0]?.dep_name || '';
|
||||
} catch (e: any) {
|
||||
if (e?.code !== 'ER_NO_SUCH_TABLE') throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const payload: JwtPayload = {
|
||||
|
||||
17
src/server/hydrogen-db.ts
Normal file
17
src/server/hydrogen-db.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const hydrogenPool = mysql.createPool({
|
||||
host: process.env.HYDROGEN_DB_HOST || '47.99.185.173',
|
||||
port: Number(process.env.HYDROGEN_DB_PORT) || 3306,
|
||||
user: process.env.HYDROGEN_DB_USER || 'root',
|
||||
password: process.env.HYDROGEN_DB_PASSWORD || 'lnMysql.',
|
||||
database: process.env.HYDROGEN_DB_NAME || 'ln_asset_management',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
export default hydrogenPool;
|
||||
@@ -1,11 +1,14 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const mileagePool = mysql.createPool({
|
||||
host: '101.133.130.65',
|
||||
port: 3306,
|
||||
user: 'bi_reader_02',
|
||||
password: 'bi_reader_02_Pass',
|
||||
database: 'hydrogen_energy',
|
||||
host: process.env.MILEAGE_DB_HOST || '101.133.130.65',
|
||||
port: Number(process.env.MILEAGE_DB_PORT) || 3306,
|
||||
user: process.env.MILEAGE_DB_USER || 'bi_reader_02',
|
||||
password: process.env.MILEAGE_DB_PASSWORD || 'bi_reader_02_Pass',
|
||||
database: process.env.MILEAGE_DB_NAME || 'hydrogen_energy',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
queueLimit: 0,
|
||||
|
||||
@@ -149,8 +149,8 @@ async function buildPlateLookup(plates: Set<string>): Promise<Map<string, string
|
||||
const placeholders = arr.map(() => '?').join(',');
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
|
||||
FROM tab_truck
|
||||
WHERE is_deleted = 0 AND plate_number IN (${placeholders})`,
|
||||
FROM vehicle_info
|
||||
WHERE del_flag = '0' AND plate_number IN (${placeholders})`,
|
||||
arr,
|
||||
);
|
||||
const map = new Map<string, string>();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db.js';
|
||||
import hydrogenPool from '../../hydrogen-db.js';
|
||||
import { cached } from './cache.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { canAccessEnergy } from '../../auth/types.js';
|
||||
@@ -19,20 +20,70 @@ app.use('*', async (c, next) => {
|
||||
|
||||
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||||
|
||||
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
|
||||
const HYDROGEN_LOCAL = `hydrogen_time`;
|
||||
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
|
||||
const HYDROGEN_TABLE = 'hydrogen_fuel_ledger';
|
||||
const HYDROGEN_LOCAL = `refuel_time`;
|
||||
const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
|
||||
const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
|
||||
const ELECTRIC_LOCAL = `charging_start_time`;
|
||||
|
||||
type CustomerKind = 'external' | 'lingniu' | 'all';
|
||||
|
||||
// 外部/我司判定:truck_id 为空 = 外部;truck_id 非空 = 我司(羚牛车辆)
|
||||
function customerClause(field: string, customer: CustomerKind): string {
|
||||
if (customer === 'external') return `${field} IS NULL`;
|
||||
if (customer === 'lingniu') return `${field} IS NOT NULL`;
|
||||
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
|
||||
function customerClause(customer: CustomerKind): string {
|
||||
if (customer === 'external') return '1=0';
|
||||
if (customer === 'lingniu') return '1=1';
|
||||
return '1=1';
|
||||
}
|
||||
|
||||
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 {
|
||||
switch (range) {
|
||||
@@ -44,25 +95,16 @@ function rangeClause(localExpr: string, range: Range): string {
|
||||
|
||||
/** 列出某 range 在当前时点下的全部日期(YYYY-MM-DD),用于补零 */
|
||||
function enumerateDates(range: Range): string[] {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
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') {
|
||||
// 周一为一周开始(与 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 { start, end } = resolveDateRange(range);
|
||||
return enumerateDateRange(start, end);
|
||||
}
|
||||
|
||||
function enumerateDateRange(startYmd: string, endYmd: string): string[] {
|
||||
const result: string[] = [];
|
||||
const cur = new Date(start);
|
||||
while (cur <= today) {
|
||||
result.push(fmt(cur));
|
||||
const cur = new Date(`${startYmd}T00:00:00`);
|
||||
const end = new Date(`${endYmd}T00:00:00`);
|
||||
while (cur <= end) {
|
||||
result.push(fmtYmd(cur));
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
return result;
|
||||
@@ -80,10 +122,10 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
|
||||
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
||||
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
||||
const [yearListRows] = await pool.query<RowDataPacket[]>(
|
||||
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
|
||||
ORDER BY y DESC`,
|
||||
[HYDROGEN_MIN_DATE],
|
||||
);
|
||||
@@ -92,44 +134,46 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
const isCurrentYear = year === todayYear;
|
||||
|
||||
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT
|
||||
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}) = ?
|
||||
THEN cost_expense ELSE 0 END) AS yearFee,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||||
THEN cost_expense ELSE 0 END) AS yearCustomerCost,
|
||||
THEN cost_total ELSE 0 END) AS yearFee,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||
THEN cost_total ELSE 0 END) AS yearCustomerCost,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
THEN customer_expense ELSE 0 END) AS yearRevenue,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||||
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||||
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||||
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
||||
THEN fee_total ELSE 0 END) AS yearRevenue,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
||||
THEN amount_kg ELSE 0 END) AS ourYearKg,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
||||
THEN cost_total ELSE 0 END) AS ourYearFee,
|
||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||
THEN amount_kg ELSE 0 END) AS customerYearKg,
|
||||
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')
|
||||
THEN cost_expense ELSE 0 END) AS monthFee,
|
||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') AND cost_type = 2
|
||||
THEN cost_expense ELSE 0 END) AS monthCustomerCost,
|
||||
THEN cost_total ELSE 0 END) AS monthFee,
|
||||
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()
|
||||
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()
|
||||
THEN cost_expense ELSE 0 END) AS todayFee,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() AND cost_type = 2
|
||||
THEN cost_expense ELSE 0 END) AS todayCustomerCost,
|
||||
THEN cost_total ELSE 0 END) AS todayFee,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||
THEN customer_expense ELSE 0 END) AS todayRevenue,
|
||||
SUM(CASE WHEN truck_id IS NOT NULL
|
||||
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
||||
SUM(CASE WHEN truck_id IS NOT NULL
|
||||
THEN cost_expense ELSE 0 END) AS lingniuBornFee
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
|
||||
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||
THEN cost_total ELSE 0 END) AS todayCustomerCost,
|
||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||
THEN fee_total ELSE 0 END) AS todayRevenue,
|
||||
SUM(CASE WHEN vehicle_id IS NOT NULL
|
||||
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
|
||||
SUM(CASE WHEN vehicle_id IS NOT NULL
|
||||
THEN cost_total ELSE 0 END) AS lingniuBornFee
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
|
||||
[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,
|
||||
@@ -166,23 +210,19 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
};
|
||||
|
||||
// Top5 加氢站(指定年份)
|
||||
const [top5Rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT b.hydrogen_station_id AS id,
|
||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||
MAX(i.hydrogen_station_name),
|
||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
||||
SUM(b.hydrogen_quantity) AS kg,
|
||||
SUM(b.cost_expense) AS fee
|
||||
FROM tab_energy_hydrogen_bill b
|
||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||
WHERE b.is_deleted = 0
|
||||
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.station_id AS id,
|
||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||||
SUM(b.amount_kg) AS kg,
|
||||
SUM(b.cost_total) AS fee
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY b.hydrogen_station_id
|
||||
GROUP BY b.station_id
|
||||
ORDER BY kg DESC
|
||||
LIMIT 5`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
@@ -197,23 +237,19 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
}));
|
||||
|
||||
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
||||
const [stationFullRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT b.hydrogen_station_id AS id,
|
||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||
MAX(i.hydrogen_station_name),
|
||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
||||
SUM(b.hydrogen_quantity) AS kg,
|
||||
SUM(b.customer_expense) AS revenue
|
||||
FROM tab_energy_hydrogen_bill b
|
||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||
WHERE b.is_deleted = 0
|
||||
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.station_id AS id,
|
||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||||
SUM(b.amount_kg) AS kg,
|
||||
SUM(b.fee_total) AS revenue
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY b.hydrogen_station_id
|
||||
GROUP BY b.station_id
|
||||
ORDER BY kg DESC`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
);
|
||||
@@ -228,14 +264,22 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
}));
|
||||
|
||||
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
||||
const [regionRows] = await pool.query<RowDataPacket[]>(
|
||||
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT region, SUM(kg) AS kg FROM (
|
||||
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
|
||||
b.hydrogen_quantity AS kg
|
||||
FROM tab_energy_hydrogen_bill b
|
||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||
WHERE b.is_deleted = 0
|
||||
SELECT CASE
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
|
||||
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
|
||||
END AS region,
|
||||
b.amount_kg AS kg
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
) r
|
||||
@@ -257,15 +301,15 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
];
|
||||
|
||||
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
||||
// 利润 = 客户单收入 - 客户单成本(仅 cost_type = 2)
|
||||
const [monthRows] = await pool.query<RowDataPacket[]>(
|
||||
// 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担)
|
||||
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
||||
ROUND(SUM(hydrogen_quantity), 2) AS kg,
|
||||
ROUND(SUM(cost_expense), 2) AS fee,
|
||||
ROUND(SUM(CASE WHEN cost_type = 2 THEN cost_expense ELSE 0 END), 2) AS customerCost,
|
||||
ROUND(SUM(customer_expense), 2) AS revenue
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0
|
||||
ROUND(SUM(amount_kg), 2) AS kg,
|
||||
ROUND(SUM(cost_total), 2) AS fee,
|
||||
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
|
||||
ROUND(SUM(fee_total), 2) AS revenue
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE}
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY m
|
||||
@@ -290,16 +334,16 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
}
|
||||
|
||||
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
||||
// payer:cost_type=2 → 客户承担;cost_type=3 → 羚牛承担;其他 → 客户(默认)
|
||||
const [customerRows] = await pool.query<RowDataPacket[]>(
|
||||
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
|
||||
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`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,
|
||||
SUM(hydrogen_quantity) AS kg,
|
||||
SUM(cost_expense) AS cost,
|
||||
SUM(customer_expense) AS revenue
|
||||
FROM tab_energy_hydrogen_bill
|
||||
WHERE is_deleted = 0
|
||||
SUM(amount_kg) AS kg,
|
||||
SUM(cost_total) AS cost,
|
||||
SUM(fee_total) AS revenue
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE}
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
GROUP BY name
|
||||
@@ -325,39 +369,37 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
// =========================================================
|
||||
app.get('/hydrogen/daily', async (c) => {
|
||||
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 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 = [
|
||||
'b.is_deleted = 0',
|
||||
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
||||
rangeClause(`b.hydrogen_time`, range),
|
||||
customerClause('b.truck_id', customer),
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
||||
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
|
||||
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
||||
].join(' AND ');
|
||||
|
||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||
// 站点名 fallback:内部站表 → 外部站表 → 导入订单表(tab_import_hydrogen_order,按 bill_code 关联)
|
||||
// 单价不重算:同价组显示原价,混合价组返回 NULL,前端显示「—」
|
||||
const [stationRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.hydrogen_time, '%Y-%m-%d') AS d,
|
||||
b.hydrogen_station_id AS stationId,
|
||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||
MAX(i.hydrogen_station_name),
|
||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS stationName,
|
||||
ROUND(SUM(b.hydrogen_quantity), 2) AS kg,
|
||||
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
||||
// 单价不重算:直接取账本成本价。
|
||||
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
|
||||
COALESCE(b.station_id, 0) AS stationId,
|
||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
||||
MAX(b.cost_price) AS pricePerKg
|
||||
FROM tab_energy_hydrogen_bill b
|
||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${where}
|
||||
GROUP BY d, b.hydrogen_station_id
|
||||
GROUP BY d, COALESCE(b.station_id, 0)
|
||||
ORDER BY d DESC, kg DESC`,
|
||||
[dateRange.start, dateRange.end],
|
||||
);
|
||||
|
||||
// 站点环比:同站点上一条记录的 kg
|
||||
@@ -407,14 +449,14 @@ app.get('/hydrogen/daily', async (c) => {
|
||||
}
|
||||
|
||||
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
|
||||
const allDates = enumerateDates(range);
|
||||
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
|
||||
const fullDays = allDates.map(date => {
|
||||
const info = dayMap.get(date);
|
||||
return {
|
||||
date,
|
||||
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
||||
chainPct: dayChainPct.get(date) ?? 0,
|
||||
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
||||
customerType: customer,
|
||||
stations: info
|
||||
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
||||
name: s.name,
|
||||
@@ -531,9 +573,10 @@ app.get('/electric/overview', async (c) => {
|
||||
app.get('/electric/monthly', async (c) => {
|
||||
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
||||
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 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=外部
|
||||
let kindClause = '1=1';
|
||||
@@ -546,8 +589,9 @@ app.get('/electric/monthly', async (c) => {
|
||||
SUM(fee) AS fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE ${kindClause}
|
||||
AND ${rangeClause('start_time', range)}
|
||||
AND ${dateRangeClause('start_time')}
|
||||
GROUP BY date`,
|
||||
[dateRange.start, dateRange.end],
|
||||
);
|
||||
|
||||
// 实际数据 map
|
||||
@@ -560,7 +604,7 @@ app.get('/electric/monthly', async (c) => {
|
||||
}
|
||||
|
||||
// 补零:枚举 range 全部日期
|
||||
const allDates = enumerateDates(range);
|
||||
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
|
||||
const fullDays = allDates.map(date => {
|
||||
const d = dataMap.get(date);
|
||||
return {
|
||||
|
||||
@@ -65,12 +65,62 @@ interface MileageRow {
|
||||
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>> {
|
||||
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULL(G7 只回传日增量),
|
||||
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
|
||||
// 业务库 lingniu_prod.tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
|
||||
// 用它兜底保证 totalKm 汇总完整。
|
||||
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];
|
||||
const map = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
@@ -115,6 +165,7 @@ function mergeVehicles(
|
||||
yesterdayMap: Map<string, number>,
|
||||
bizTotalMap: Map<string, number>,
|
||||
latestPgTotalMap: Map<string, number>,
|
||||
targetNamesByPlate: Map<string, string[]>,
|
||||
): CachedVehicle[] {
|
||||
const mileageMap = new Map<string, MileageRow>();
|
||||
for (const row of mileageRows) {
|
||||
@@ -124,30 +175,32 @@ function mergeVehicles(
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(mileageMap.values()).map(m => {
|
||||
const info = infoMap.get(m.plate);
|
||||
const dailyKm = Number(m.daily_km) || 0;
|
||||
const source = m.source || 'NONE';
|
||||
const gpsTotal = m.total_km !== null ? Number(m.total_km) : null;
|
||||
const latestPgTotal = latestPgTotalMap.get(m.plate);
|
||||
const bizTotal = bizTotalMap.get(m.plate);
|
||||
return Array.from(infoMap.values()).map(info => {
|
||||
const m = mileageMap.get(info.plate);
|
||||
const plate = info.plate;
|
||||
const dailyKm = Number(m?.daily_km) || 0;
|
||||
const source = m?.source || 'NONE';
|
||||
const gpsTotal = m?.total_km != null ? Number(m.total_km) : null;
|
||||
const latestPgTotal = latestPgTotalMap.get(plate);
|
||||
const bizTotal = bizTotalMap.get(plate);
|
||||
return {
|
||||
plate: m.plate,
|
||||
vin: m.vin,
|
||||
plate,
|
||||
vin: m?.vin || info.vin || '',
|
||||
dailyKm,
|
||||
totalKm: gpsTotal !== null ? gpsTotal : (latestPgTotal ?? bizTotal ?? null),
|
||||
source,
|
||||
isOnline: source !== 'NONE' && dailyKm > 0,
|
||||
isDataSynced: source !== 'NONE',
|
||||
customer: info?.customer || null,
|
||||
department: info?.department || null,
|
||||
manager: info?.manager || null,
|
||||
managerId: info?.manager_id || null,
|
||||
rentStatus: info?.rent_status || null,
|
||||
entity: info?.entity || null,
|
||||
project: info?.project || null,
|
||||
region: regionMap[m.plate] || null,
|
||||
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
||||
customer: info.customer || null,
|
||||
department: info.department || null,
|
||||
manager: info.manager || null,
|
||||
managerId: info.manager_id || null,
|
||||
rentStatus: info.rent_status || null,
|
||||
entity: info.entity || null,
|
||||
project: info.project || null,
|
||||
region: regionMap[plate] || null,
|
||||
targetNames: targetNamesByPlate.get(plate) || [],
|
||||
yesterdayKm: yesterdayMap.get(plate) || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -184,25 +237,16 @@ export async function refreshMonitoringCache(): Promise<void> {
|
||||
return map;
|
||||
})(),
|
||||
fetchVehicleInfoMap(),
|
||||
pool.execute(
|
||||
`SELECT t.id, t.target_name, v.plate_number
|
||||
FROM tab_mileage_assessment_target t
|
||||
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
|
||||
WHERE t.is_deleted = 0`
|
||||
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
|
||||
fetchTargetRows(),
|
||||
fetchBizTotalMileageMap(),
|
||||
fetchLatestPgTotalMileageMap(),
|
||||
]);
|
||||
|
||||
const targetPlatesMap = new Map<string, Set<string>>();
|
||||
for (const r of targetRows) {
|
||||
const set = targetPlatesMap.get(r.target_name) || new Set();
|
||||
set.add(r.plate_number);
|
||||
targetPlatesMap.set(r.target_name, set);
|
||||
}
|
||||
const targetPlatesMap = buildTargetPlatesMap(targetRows);
|
||||
const targetNamesByPlate = buildPlateTargetNamesMap(targetRows);
|
||||
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 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[]> {
|
||||
const [mileageRows, yesterdayRows, infoMap, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
||||
const [mileageRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
||||
mileagePool.execute(
|
||||
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
|
||||
[dateStr]
|
||||
@@ -231,6 +275,7 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
|
||||
[dateStr]
|
||||
).then(([r]) => r as { plate: string; daily_km: string }[]),
|
||||
fetchVehicleInfoMap(),
|
||||
fetchTargetRows(),
|
||||
fetchBizTotalMileageMap(),
|
||||
fetchLatestPgTotalMileageMap(dateStr),
|
||||
]);
|
||||
@@ -242,7 +287,118 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { AuthUser } from '../../auth/types.js';
|
||||
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
|
||||
@@ -19,7 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
|
||||
function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
search: string; dept: string; customer: string; project: 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[] {
|
||||
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.region) result = result.filter(v => v.region === params.region);
|
||||
if (params.targetName) {
|
||||
const cache = getCache();
|
||||
const tPlates = cache?.targetPlatesMap.get(params.targetName);
|
||||
result = tPlates ? result.filter(v => tPlates.has(v.plate)) : [];
|
||||
if (params.targetNames.length > 0) {
|
||||
const selectedTargets = new Set(params.targetNames);
|
||||
result = result.filter(v => v.targetNames.some(targetName => selectedTargets.has(targetName)));
|
||||
}
|
||||
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
|
||||
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
|
||||
@@ -53,12 +52,52 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
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) => {
|
||||
const sortBy = c.req.query('sortBy') || 'today';
|
||||
const sortOrder = c.req.query('sortOrder') || 'desc';
|
||||
const limit = Number(c.req.query('limit')) || 50;
|
||||
const page = Number(c.req.query('page')) || 1;
|
||||
const date = c.req.query('date') || '';
|
||||
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
|
||||
|
||||
const filterParams = {
|
||||
search: c.req.query('search') || '',
|
||||
@@ -69,7 +108,7 @@ app.get('/', async (c) => {
|
||||
rentStatus: c.req.query('rentStatus') || '',
|
||||
plate: c.req.query('plate') || '',
|
||||
platePrefix: c.req.query('platePrefix') || '',
|
||||
targetName: c.req.query('targetName') || '',
|
||||
targetNames: parseTargetNames(c.req.url),
|
||||
region: c.req.query('region') || '',
|
||||
mileageMin: c.req.query('mileageMin') || '',
|
||||
mileageMax: c.req.query('mileageMax') || '',
|
||||
@@ -77,8 +116,21 @@ app.get('/', async (c) => {
|
||||
|
||||
let allVehicles: CachedVehicle[];
|
||||
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 {
|
||||
allVehicles = await queryDateMileage(date);
|
||||
filters = buildDateFilters(allVehicles);
|
||||
@@ -107,6 +159,12 @@ app.get('/', async (c) => {
|
||||
}
|
||||
|
||||
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 = {
|
||||
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
|
||||
@@ -129,10 +187,12 @@ app.get('/', async (c) => {
|
||||
vehicles: maskCustomerNames(paged),
|
||||
stats,
|
||||
filters,
|
||||
rangeDailyTotals,
|
||||
dateRange,
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
updatedAt: date || getCache()?.updatedAt || new Date().toISOString(),
|
||||
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const app = new Hono();
|
||||
app.get('/', async (c) => {
|
||||
try {
|
||||
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];
|
||||
|
||||
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) as current_year_completed,
|
||||
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
|
||||
`) as [any[], unknown];
|
||||
|
||||
const statsMap = new Map<number, any>();
|
||||
for (const s of vehicleStats) statsMap.set(s.target_id, s);
|
||||
|
||||
const [firstYearRows] = await pool.execute(`
|
||||
SELECT
|
||||
v.target_id,
|
||||
COUNT(*) as first_year_total,
|
||||
SUM(t.annual_mileage_per_vehicle) as first_year_target,
|
||||
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) as first_year_completed,
|
||||
SUM(GREATEST(t.annual_mileage_per_vehicle - v.current_mileage, 0)) as first_year_remaining,
|
||||
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) / NULLIF(SUM(t.annual_mileage_per_vehicle), 0) as first_year_completion_rate,
|
||||
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle THEN 1 ELSE 0 END) as first_year_qualified_count,
|
||||
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * 0.5 THEN 1 ELSE 0 END) as first_year_half_qualified_count,
|
||||
DATE_FORMAT(MIN(v.assessment_start_date), '%Y-%m-%d') as first_year_start_date,
|
||||
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL 1 YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as first_year_end_date
|
||||
FROM 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(`
|
||||
SELECT target_id,
|
||||
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
|
||||
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
|
||||
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
|
||||
ORDER BY target_id, assessment_start_date
|
||||
`) as [any[], unknown];
|
||||
@@ -58,7 +135,7 @@ app.get('/', async (c) => {
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
const targetIdPlatesMap = new Map<number, string[]>();
|
||||
@@ -71,12 +148,44 @@ app.get('/', async (c) => {
|
||||
const now = new Date();
|
||||
const result = targets.map((t: any) => {
|
||||
const s = statsMap.get(t.id) || {};
|
||||
const fy = firstYearMap.get(t.id) || {};
|
||||
const currentYearTarget = Number(s.current_year_target) || 0;
|
||||
const currentYearCompleted = Number(s.current_year_completed) || 0;
|
||||
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
|
||||
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
|
||||
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
|
||||
const dailyTarget = remaining / daysLeft;
|
||||
const firstYearEnd = fy.first_year_end_date ? new Date(fy.first_year_end_date) : now;
|
||||
const firstYearDaysLeft = Math.max(0, Math.ceil((firstYearEnd.getTime() - now.getTime()) / 86400000));
|
||||
const firstYearRemaining = Number(fy.first_year_remaining) || 0;
|
||||
const firstYearVehicleCount = Number(fy.first_year_total) || 0;
|
||||
const firstYearQualifiedCount = Number(fy.first_year_qualified_count) || 0;
|
||||
const yearlyAssessments = (yearlyMap.get(t.id) || []).map((row: any) => {
|
||||
const vehicleCount = Number(row.vehicle_count) || 0;
|
||||
const qualifiedCount = Number(row.qualified_count) || 0;
|
||||
const remainingMileage = Number(row.remaining_mileage) || 0;
|
||||
const endDate = row.end_date ? new Date(row.end_date) : now;
|
||||
const assessmentDaysLeft = Math.max(0, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
|
||||
const yearNumber = Number(row.year_number) || 0;
|
||||
|
||||
return {
|
||||
yearNumber,
|
||||
label: `第${yearNumber}年`,
|
||||
vehicleCount,
|
||||
target: Number(row.target_mileage) || 0,
|
||||
completed: Number(row.completed_mileage) || 0,
|
||||
remaining: remainingMileage,
|
||||
completionRate: (Number(row.completion_rate) || 0) * 100,
|
||||
qualifiedCount,
|
||||
qualifiedRate: vehicleCount > 0 ? (qualifiedCount / vehicleCount) * 100 : 0,
|
||||
halfQualifiedCount: Number(row.half_qualified_count) || 0,
|
||||
daysLeft: assessmentDaysLeft,
|
||||
dailyTarget: assessmentDaysLeft > 0 ? Math.round((remainingMileage / assessmentDaysLeft) * 10) / 10 : 0,
|
||||
startDate: row.start_date || null,
|
||||
endDate: row.end_date || null,
|
||||
periods: yearlyPeriodsMap.get(`${row.target_id}-${row.year_number}`) || [],
|
||||
};
|
||||
});
|
||||
|
||||
const periods = periodsMap.get(t.id) || [];
|
||||
if (periods.length === 0) {
|
||||
@@ -104,6 +213,19 @@ app.get('/', async (c) => {
|
||||
remaining,
|
||||
daysLeft,
|
||||
dailyTarget: Math.round(dailyTarget * 10) / 10,
|
||||
firstYearVehicleCount,
|
||||
firstYearTarget: Number(fy.first_year_target) || 0,
|
||||
firstYearCompleted: Number(fy.first_year_completed) || 0,
|
||||
firstYearRemaining,
|
||||
firstYearCompletionRate: (Number(fy.first_year_completion_rate) || 0) * 100,
|
||||
firstYearQualifiedCount,
|
||||
firstYearQualifiedRate: firstYearVehicleCount > 0 ? (firstYearQualifiedCount / firstYearVehicleCount) * 100 : 0,
|
||||
firstYearHalfQualifiedCount: Number(fy.first_year_half_qualified_count) || 0,
|
||||
firstYearDaysLeft,
|
||||
firstYearDailyTarget: firstYearDaysLeft > 0 ? Math.round((firstYearRemaining / firstYearDaysLeft) * 10) / 10 : 0,
|
||||
firstYearStartDate: fy.first_year_start_date || null,
|
||||
firstYearEndDate: fy.first_year_end_date || null,
|
||||
yearlyAssessments,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -123,7 +245,7 @@ app.get('/:id/vehicles', async (c) => {
|
||||
`SELECT plate_number, today_mileage, vehicle_total_mileage,
|
||||
completion_rate, is_qualified, current_year_is_qualified,
|
||||
daily_required_mileage
|
||||
FROM tab_mileage_assessment_vehicle
|
||||
FROM lingniu_prod.tab_mileage_assessment_vehicle
|
||||
WHERE target_id = ? AND is_deleted = 0
|
||||
ORDER BY today_mileage DESC`,
|
||||
[targetId]
|
||||
|
||||
@@ -12,7 +12,7 @@ app.get('/', async (c) => {
|
||||
let plates: string[] = [];
|
||||
if (targetId) {
|
||||
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]
|
||||
) as [{ plate_number: string }[], unknown];
|
||||
plates = vehicleRows.map(r => r.plate_number);
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface CachedVehicle {
|
||||
plate: string;
|
||||
vin: string;
|
||||
dailyKm: number;
|
||||
dailyMileage?: Record<string, number>;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
isOnline: boolean;
|
||||
@@ -15,6 +16,7 @@ export interface CachedVehicle {
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
region: string | null;
|
||||
targetNames: string[];
|
||||
yesterdayKm: number;
|
||||
}
|
||||
|
||||
@@ -59,6 +61,8 @@ export interface MonitoringResponse {
|
||||
vehicles: CachedVehicle[];
|
||||
stats: MonitoringStats;
|
||||
filters: MonitoringFilters;
|
||||
rangeDailyTotals?: { date: string; totalKm: number }[];
|
||||
dateRange?: { start: string; end: string };
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
@@ -68,6 +72,7 @@ export interface MonitoringResponse {
|
||||
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */
|
||||
export interface VehicleInfoRow {
|
||||
plate: string;
|
||||
vin: string | null;
|
||||
customer: string | null;
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
|
||||
@@ -3,24 +3,43 @@ import type { VehicleInfoRow } from './types.js';
|
||||
|
||||
/** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目) */
|
||||
export const VEHICLE_INFO_SQL = `SELECT
|
||||
truck.plate_number AS plate,
|
||||
cus.customer_name AS customer,
|
||||
dep.dep_name AS department,
|
||||
u.user_name AS manager,
|
||||
CAST(c.bd AS CHAR) AS manager_id,
|
||||
dic_status.dic_name AS rent_status,
|
||||
org_truck.org_name AS entity,
|
||||
c.project_name AS project
|
||||
FROM tab_truck truck
|
||||
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
|
||||
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
|
||||
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
|
||||
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
|
||||
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
|
||||
LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status'
|
||||
AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0
|
||||
LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0
|
||||
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
|
||||
vi.plate_number AS plate,
|
||||
vi.vin AS vin,
|
||||
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer,
|
||||
COALESCE(c.business_department_name, vor.business_dept) AS department,
|
||||
COALESCE(c.business_manager_name, vor.business_manager) AS manager,
|
||||
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS manager_id,
|
||||
CASE vs.operation_status
|
||||
WHEN '1' THEN '租赁'
|
||||
WHEN '2' THEN '自营'
|
||||
WHEN '3' THEN '可运营'
|
||||
WHEN '4' THEN '待运营'
|
||||
WHEN '5' THEN '退出运营'
|
||||
ELSE vs.operation_status
|
||||
END AS rent_status,
|
||||
NULLIF(vi.registered_ownership, '') AS entity,
|
||||
COALESCE(c.project_name, vor.project_name) AS project
|
||||
FROM vehicle_info vi
|
||||
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 */
|
||||
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>> {
|
||||
if (plates.length === 0) return new Map();
|
||||
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
|
||||
) as [VehicleInfoRow[], unknown];
|
||||
const map = new Map<string, VehicleInfoRow>();
|
||||
|
||||
@@ -28,16 +28,17 @@ function inferTypeFromTargetName(targetName: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify vehicle type from dic_type.dic_name (e.g. "4.5吨冷链车", "4.5吨货车", "18吨双飞翼货车").
|
||||
* The typeName is the full label from the dictionary, modelRaw is the numeric dic_code.
|
||||
* Classify vehicle type from ln_asset_management.vehicle_model.
|
||||
* modelRaw is vehicle_model.vehicle_type, which is not the old dic_truck_type code.
|
||||
*/
|
||||
function classifyVehicleType(typeName: string, _modelRaw: string): string {
|
||||
const t = (typeName || '').trim();
|
||||
if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链';
|
||||
if (t.includes('4.5')) return '4.5T普货';
|
||||
if (t.includes('18')) return '18T';
|
||||
if (t.includes('49') || t.includes('牵引')) return '49T';
|
||||
if (t.includes('挂车')) return '挂车';
|
||||
if (t.includes('49')) return '49T';
|
||||
if (t.includes('35')) return '35T';
|
||||
return t || '其他';
|
||||
}
|
||||
|
||||
@@ -54,7 +55,7 @@ app.get('/', async (c) => {
|
||||
|
||||
// ---- Query 1: Assessment targets ----
|
||||
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];
|
||||
|
||||
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,
|
||||
completion_rate, is_qualified, current_year_is_qualified,
|
||||
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];
|
||||
|
||||
// ---- Query 3: Vehicle info (customer, dept, manager) ----
|
||||
const vehicleInfoMap = await fetchVehicleInfoMap();
|
||||
|
||||
// ---- Query 4: Vehicle types from tab_truck ----
|
||||
// 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.
|
||||
// ---- Query 4: Vehicle types from vehicle_info ----
|
||||
const [truckTypeRows] = await pool.execute(`
|
||||
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
|
||||
FROM tab_truck truck
|
||||
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
|
||||
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
|
||||
WHERE truck.is_operation = 1
|
||||
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
|
||||
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'
|
||||
WHERE vi.del_flag = '0'
|
||||
AND COALESCE(vs.operation_status, '') <> '5'
|
||||
`) as [any[], unknown];
|
||||
|
||||
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
|
||||
@@ -161,12 +161,13 @@ app.get('/', async (c) => {
|
||||
|
||||
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
|
||||
const [inventoryTruckRows] = await pool.execute(`
|
||||
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
|
||||
FROM tab_truck truck
|
||||
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
|
||||
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
|
||||
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
|
||||
AND truck.truck_rent_status = 0
|
||||
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
|
||||
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'
|
||||
WHERE vi.del_flag = '0'
|
||||
AND COALESCE(vs.operation_status, '') IN ('3','4')
|
||||
AND COALESCE(vs.vehicle_status, '') <> '4'
|
||||
`) as [any[], unknown];
|
||||
|
||||
// ---- Build assessment vehicle lookup for inventory cross-reference ----
|
||||
|
||||
@@ -17,75 +17,101 @@ import type { Context } from 'hono';
|
||||
const app = new Hono();
|
||||
|
||||
const MAIN_SQL = `SELECT
|
||||
CAST(truck.id AS CHAR) AS id,
|
||||
truck.plate_number AS 车牌号,
|
||||
truck.vin AS vin,
|
||||
truck.brand AS 车辆品牌,
|
||||
truck.model AS 车辆型号,
|
||||
truck.color AS 车辆颜色,
|
||||
truck.rent_from_company AS 租赁公司,
|
||||
dic_ascription_status.dic_name AS 车辆归属状态Label,
|
||||
dic_type.dic_name AS 车辆型号Label,
|
||||
truck.stock_area AS 库存区域,
|
||||
truck.truck_rent_status AS 车辆租赁状态,
|
||||
dic_status.dic_name AS 车辆租赁状态Label,
|
||||
truck.is_operation AS 是否营运,
|
||||
info.province AS 省,
|
||||
info.city AS 市,
|
||||
CAST(vi.id AS CHAR) AS id,
|
||||
vi.plate_number AS 车牌号,
|
||||
vi.vin AS vin,
|
||||
vm.brand AS 车辆品牌,
|
||||
vm.model AS 车辆型号,
|
||||
vi.body_color AS 车辆颜色,
|
||||
vi.rental_company AS 租赁公司,
|
||||
CASE vi.vehicle_source
|
||||
WHEN '0' THEN '自有'
|
||||
WHEN '1' THEN '挂靠'
|
||||
WHEN '2' THEN '外租'
|
||||
WHEN '3' THEN '自有'
|
||||
ELSE vi.actual_ownership
|
||||
END AS 车辆归属状态Label,
|
||||
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.lng AS 经度,
|
||||
dic_brand.dic_name AS 车辆品牌Label,
|
||||
si.contract_id AS 合同ID,
|
||||
COALESCE(c.contract_no, si.contract_no) AS 合同编码,
|
||||
cus.customer_name AS 客户名称,
|
||||
org.org_name AS 合同归属公司,
|
||||
dep.dep_name AS 合同归属部门,
|
||||
org_truck.org_name AS 主体,
|
||||
c.project_name AS 项目名称,
|
||||
u.user_name AS 客户经理,
|
||||
CAST(c.bd AS CHAR) AS 经理ID
|
||||
FROM tab_truck truck
|
||||
CASE vm.brand
|
||||
WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
|
||||
WHEN 'yuejin' THEN '跃进'
|
||||
WHEN 'feichi' THEN '飞驰'
|
||||
WHEN 'sulong' THEN '苏龙'
|
||||
WHEN 'higer' THEN '海格'
|
||||
WHEN 'dongfeng' THEN '东风'
|
||||
WHEN 'yutong' THEN '宇通'
|
||||
WHEN 'chufeng' THEN '楚风'
|
||||
WHEN 'tonghua' THEN '通华'
|
||||
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
|
||||
ON info.id = truck.id
|
||||
LEFT JOIN tab_dic dic_type
|
||||
ON dic_type.parent_code = 'dic_truck_type'
|
||||
AND dic_type.dic_code = truck.model
|
||||
AND dic_type.is_deleted = 0
|
||||
LEFT JOIN tab_dic dic_status
|
||||
ON dic_status.parent_code = 'dic_truck_rent_status'
|
||||
AND dic_status.dic_code = truck.truck_rent_status
|
||||
AND dic_status.is_deleted = 0
|
||||
LEFT JOIN tab_dic dic_brand
|
||||
ON dic_brand.parent_code = 'dic_vehicle_brand'
|
||||
AND dic_brand.dic_code = truck.brand
|
||||
AND dic_brand.is_deleted = 0
|
||||
LEFT JOIN tab_truck_status_info si
|
||||
ON si.truck_id = truck.id
|
||||
AND si.is_deleted = 0
|
||||
LEFT JOIN tab_contract c
|
||||
ON c.id = si.contract_id
|
||||
AND c.is_deleted = 0
|
||||
LEFT JOIN tab_customer cus
|
||||
ON cus.id = c.customer_id
|
||||
AND cus.is_deleted = 0
|
||||
LEFT JOIN tab_org org
|
||||
ON org.id = c.org_id
|
||||
AND org.is_deleted = 0
|
||||
LEFT JOIN tab_org org_truck
|
||||
ON org_truck.id = truck.org_id
|
||||
AND org_truck.is_deleted = 0
|
||||
LEFT JOIN tab_dic dic_ascription_status
|
||||
ON dic_ascription_status.parent_code = 'dic_truck_ascription_status'
|
||||
AND dic_ascription_status.dic_code = truck.ascription_status
|
||||
AND dic_ascription_status.is_deleted = 0
|
||||
LEFT JOIN tab_user u
|
||||
ON u.id = c.bd
|
||||
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`;
|
||||
ON info.plate_number = vi.plate_number
|
||||
AND info.is_deleted = 0
|
||||
LEFT JOIN common_district info_province
|
||||
ON info_province.CODE = info.province COLLATE utf8mb4_unicode_ci
|
||||
AND info_province.STATUS = 'VALID'
|
||||
LEFT JOIN common_district info_city
|
||||
ON info_city.CODE = info.city COLLATE utf8mb4_unicode_ci
|
||||
AND info_city.STATUS = 'VALID'
|
||||
LEFT JOIN common_district vi_province
|
||||
ON vi_province.CODE = vi.province COLLATE utf8mb4_unicode_ci
|
||||
AND vi_province.STATUS = 'VALID'
|
||||
LEFT JOIN common_district vi_city
|
||||
ON vi_city.CODE = vi.city COLLATE utf8mb4_unicode_ci
|
||||
AND vi_city.STATUS = 'VALID'
|
||||
LEFT JOIN common_district vi_operation_city
|
||||
ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
|
||||
AND vi_operation_city.STATUS = 'VALID'
|
||||
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'`;
|
||||
|
||||
// Region mapping: province/city -> display region
|
||||
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
|
||||
@@ -148,23 +174,33 @@ function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
|
||||
return counts;
|
||||
}
|
||||
|
||||
// Map rental status to frontend status
|
||||
// Actual DB values: 在库(0), 自营(1), 租赁(2), 待交车(7), 挂靠(8), 异动(12)
|
||||
function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
|
||||
if (!rentStatus) return 'Inventory';
|
||||
const s = rentStatus.trim();
|
||||
if (s === '租赁' || s === '自营' || s === '挂靠') return 'Operating';
|
||||
if (s === '在库') return 'Inventory';
|
||||
if (s === '待交车') return 'Pending';
|
||||
if (s === '异动') return 'Abnormal';
|
||||
// Map operation status to frontend status.
|
||||
// ln_asset_management.vehicle_status.operation_status:
|
||||
// 1=租赁, 2=自营, 3=可运营, 4=待运营, 5=退出运营.
|
||||
function mapStatus(operationStatus: string | null, vehicleStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
|
||||
const op = (operationStatus || '').trim();
|
||||
const vehicle = (vehicleStatus || '').trim();
|
||||
if (vehicle === '4') return 'Pending';
|
||||
if (vehicle === '14') 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';
|
||||
}
|
||||
|
||||
// Map ownership from truck_rent_status (rentStatusLabel)
|
||||
// DB values: 自营(1), 租赁(2), 挂靠(8) → these are the operating subtypes
|
||||
// Map ownership from vehicle_info.vehicle_source.
|
||||
// ln_asset_management vehicle_source values: 1=挂靠, 2=外租, 3=自有. Keep 0=自有 for legacy rows.
|
||||
function mapOwnership(rentStatusLabel: string | null): string {
|
||||
if (!rentStatusLabel) return 'Unknown';
|
||||
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 'Leased';
|
||||
if (s === '挂靠') return 'Hanging';
|
||||
@@ -179,27 +215,41 @@ function resolveCity(city: string | null, province: string | null): string {
|
||||
return p || '其他';
|
||||
}
|
||||
|
||||
// Derive vehicle type category from model label
|
||||
// Actual DB values: 4.5吨冷链车, 4.5吨货车, 18吨双飞翼货车, 18吨厢式货车, 49吨牵引车头, 35吨牵引车头,
|
||||
// 重型集装箱半挂车, 重型平板半挂车, 氢能叉车, SJ型蓄电池观光车, 公务用车/小客车, 挂靠油车
|
||||
function deriveType(modelLabel: string | null, brandLabel: string | null): string {
|
||||
// Derive page category from ln_asset_management.vehicle_model.
|
||||
// vehicle_type is a new-system category code, not the old lingniu_prod.dic_truck_type code:
|
||||
// 1 = 4.5T, 2 = 18T / other truck-like models, 3 = tractor head, 5/6 = trailers.
|
||||
function deriveType(modelLabel: string | null, vehicleTypeCode: string | null): string {
|
||||
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('18吨')) return '18T';
|
||||
if (label.includes('49吨')) return '49T';
|
||||
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 '挂车';
|
||||
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 is generated as: brand-modelLabel-color[+rentCompany if 外租]
|
||||
// Some tags are merged (e.g. 嘉氢 red + 嘉氢 blue/green → one alias)
|
||||
const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
|
||||
// 4.5T 普货
|
||||
'现代-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.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 },
|
||||
'帕力安牌-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: 305 },
|
||||
'帕力安牌-18吨双飞翼货车-白': { alias: '现代18T双飞翼(羚牛)', order: 306 },
|
||||
'帕力安牌-18吨双飞翼货车-白/绿': { alias: '现代18T双飞翼(羚牛)', order: 306 },
|
||||
// 49T
|
||||
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
|
||||
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
|
||||
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 },
|
||||
'飞驰-49吨牵引车头-红': { alias: '49T飞驰(红)', order: 402 },
|
||||
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 }, // merge with above
|
||||
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-红)', order: 404 },
|
||||
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-蓝白绿)', order: 405 },
|
||||
'楚风-49吨牵引车头-蓝/黑': { alias: '49T楚风', order: 406 },
|
||||
'楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风(海珀特)', order: 406 },
|
||||
// 其他
|
||||
'红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 },
|
||||
@@ -232,9 +285,11 @@ const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
|
||||
'万风-重型平板半挂车-红': { alias: '挂车', order: 503 },
|
||||
'舒捷-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 },
|
||||
};
|
||||
@@ -247,8 +302,9 @@ function deriveModelTag(
|
||||
rentCompany: string | null,
|
||||
): string {
|
||||
const brand = (brandLabel || '').trim();
|
||||
const model = (modelLabel || '').trim();
|
||||
const model = (normalizeModelLabel(modelLabel) || '').trim();
|
||||
const c = (color || '').trim();
|
||||
if (model === '公务用车/小客车') return '公务车/挂靠车';
|
||||
const isRented = ownershipLabel?.trim() === '外租';
|
||||
const company = isRented ? (rentCompany || '').trim() : '';
|
||||
|
||||
@@ -272,15 +328,16 @@ function transformRow(row: VehicleRow): Vehicle {
|
||||
id: row.id,
|
||||
plateNumber: row.车牌号 || '',
|
||||
vin: row.vin || '',
|
||||
type: deriveType(row.车辆型号Label, row.车辆品牌Label),
|
||||
type: deriveType(row.车辆型号Label, row.车辆类型参数),
|
||||
model: deriveModelTag(row.车辆品牌Label, row.车辆型号Label, row.车辆颜色, row.车辆归属状态Label, row.租赁公司),
|
||||
color: row.车辆颜色 || '',
|
||||
location: region,
|
||||
region,
|
||||
province: row.省,
|
||||
city: row.市,
|
||||
status: mapStatus(row.车辆租赁状态Label),
|
||||
ownership: mapOwnership(row.车辆租赁状态Label),
|
||||
status: mapStatus(row.车辆租赁状态Label, row.车辆租赁状态),
|
||||
operationStatus: row.车辆租赁状态Label,
|
||||
ownership: mapOwnership(row.车辆归属状态Label),
|
||||
rentCompany: row.租赁公司 || '',
|
||||
contractNo: row.合同编码,
|
||||
customerName: row.客户名称,
|
||||
@@ -318,7 +375,7 @@ async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
|
||||
return maskCustomerNames(list);
|
||||
}
|
||||
|
||||
// 归属公司筛选(所属公司 = tab_truck.org_id → org_name, 即 Vehicle.subjectOrg)
|
||||
// 归属公司筛选(所属公司 = vehicle_info.registered_ownership, 即 Vehicle.subjectOrg)
|
||||
function getSubjectParam(c: Context): string | null {
|
||||
const raw = (c.req.query('subject') || '').trim();
|
||||
return raw ? raw : null;
|
||||
@@ -354,24 +411,27 @@ async function getWeeklyTruckIds(): Promise<WeeklyTruckIds> {
|
||||
}
|
||||
|
||||
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(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take
|
||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
|
||||
AND task.task_type=1 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}`),
|
||||
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_return r
|
||||
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
|
||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||||
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
|
||||
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
|
||||
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take
|
||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_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}`),
|
||||
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
|
||||
FROM vehicle_status
|
||||
WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
|
||||
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
|
||||
FROM delivery_vehicle
|
||||
WHERE del_flag='0'
|
||||
AND vehicle_id IS NOT NULL
|
||||
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}
|
||||
AND delivery_status IN (2,3,5)`),
|
||||
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
|
||||
FROM return_vehicle_task
|
||||
WHERE del_flag='0'
|
||||
AND vehicle_id IS NOT NULL
|
||||
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}
|
||||
AND status IN (2,3,5)`),
|
||||
pool.query<any[]>(`SELECT CAST(new_vehicle_id AS CHAR) AS truck_id
|
||||
FROM vehicle_replacement
|
||||
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'));
|
||||
@@ -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_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 {
|
||||
pendingDelivery: number;
|
||||
weeklyNew: number;
|
||||
@@ -419,59 +532,54 @@ interface WeeklyStats {
|
||||
|
||||
// 交车单 SQL
|
||||
const DELIVERED_SQL = `SELECT
|
||||
take.id, DATE(take.handover_date) AS handover_date,
|
||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
||||
dic_contract_type.dic_name AS contract_type,
|
||||
customer.customer_name
|
||||
FROM tab_truck_rent_take take
|
||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||||
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
||||
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
||||
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
||||
LEFT JOIN tab_dic dic_contract_type
|
||||
ON dic_contract_type.parent_code = 'dic_contract_type'
|
||||
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 = 1 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
|
||||
dv.id, DATE(dv.delivery_time) AS handover_date,
|
||||
CAST(dv.vehicle_id AS CHAR) AS truck_id, dv.plate_number,
|
||||
c.contract_type,
|
||||
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.delivery_time IS NOT NULL
|
||||
AND dv.delivery_status IN (2,3,5)`;
|
||||
|
||||
// 还车单 SQL
|
||||
const RETURNED_SQL = `SELECT
|
||||
r.id, DATE(r.return_date) AS handover_date,
|
||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
||||
dic_contract_type.dic_name AS contract_type,
|
||||
customer.customer_name
|
||||
FROM tab_truck_rent_return r
|
||||
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
|
||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||||
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
||||
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
||||
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
||||
LEFT JOIN tab_dic dic_contract_type
|
||||
ON dic_contract_type.parent_code = 'dic_contract_type'
|
||||
AND dic_contract_type.dic_code = contract.contract_type
|
||||
AND dic_contract_type.is_deleted = 0
|
||||
WHERE r.is_deleted = 0 AND r.return_date IS NOT NULL`;
|
||||
r.id, DATE(r.arrival_time) AS handover_date,
|
||||
CAST(r.vehicle_id AS CHAR) AS truck_id, r.plate_number,
|
||||
c.contract_type,
|
||||
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.arrival_time IS NOT NULL
|
||||
AND r.status IN (2,3,5)`;
|
||||
|
||||
// 替换车单 SQL
|
||||
const REPLACED_SQL = `SELECT
|
||||
take.id, DATE(take.handover_date) AS handover_date,
|
||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
||||
dic_contract_type.dic_name AS contract_type,
|
||||
customer.customer_name
|
||||
FROM tab_truck_rent_take take
|
||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||||
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
||||
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
||||
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
||||
LEFT JOIN tab_dic dic_contract_type
|
||||
ON dic_contract_type.parent_code = 'dic_contract_type'
|
||||
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`;
|
||||
vr.id, DATE(vr.replace_time) AS handover_date,
|
||||
CAST(vr.new_vehicle_id AS CHAR) AS truck_id, vr.new_vehicle_plate AS plate_number,
|
||||
c.contract_type,
|
||||
c.customer_name
|
||||
FROM vehicle_replacement vr
|
||||
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.replace_time IS NOT NULL
|
||||
AND vr.status = 20`;
|
||||
|
||||
let cachedWeeklyStats: WeeklyStats | null = null;
|
||||
let weeklyStatsLastFetch = 0;
|
||||
@@ -483,23 +591,18 @@ async function getWeeklyStats(): Promise<WeeklyStats> {
|
||||
}
|
||||
|
||||
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 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 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 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=1 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}`),
|
||||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
|
||||
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
|
||||
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
|
||||
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${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}`),
|
||||
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 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 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 delivery_vehicle
|
||||
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
|
||||
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`),
|
||||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
|
||||
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
|
||||
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`),
|
||||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_replacement
|
||||
WHERE del_flag='0' AND replace_time IS NOT NULL AND status=20
|
||||
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}`),
|
||||
]);
|
||||
|
||||
cachedWeeklyStats = {
|
||||
@@ -521,11 +624,11 @@ app.get('/summary', async (c) => {
|
||||
const summary: SummaryData = {
|
||||
totalAssets: vehicles.length,
|
||||
operating: {
|
||||
total: vehicles.filter((v) => v.status === 'Operating').length,
|
||||
self: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Self').length,
|
||||
leased: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Leased').length,
|
||||
total: vehicles.filter((v) => v.status === 'Operating' && (v.operationStatus === '1' || v.operationStatus === '2')).length,
|
||||
self: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '2').length,
|
||||
leased: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '1').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: {
|
||||
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[]>>();
|
||||
for (const v of withManager) {
|
||||
const dept = v.departmentName || '公务车';
|
||||
const isPublicServiceVehicle = v.model === '公务车/挂靠车';
|
||||
const dept = isPublicServiceVehicle ? '公务车' : (v.departmentName || '未分配部门');
|
||||
const mgr = v.customerManager || '未分配';
|
||||
if (EXCLUDED_MANAGERS.has(mgr)) continue;
|
||||
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
|
||||
@@ -704,29 +808,6 @@ app.get('/dept-stats', async (c) => {
|
||||
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
|
||||
const getMileageStats = (vList: Vehicle[]) => {
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
const wantCold = isColdChain === 'true';
|
||||
@@ -994,6 +1079,7 @@ app.get('/list', async (c) => {
|
||||
city: v.city,
|
||||
status: v.status,
|
||||
ownership: v.ownership,
|
||||
rentCompany: v.rentCompany,
|
||||
contractNo: v.contractNo,
|
||||
customerName: v.customerName,
|
||||
subjectOrg: v.subjectOrg,
|
||||
@@ -1049,18 +1135,22 @@ app.get('/weekly-detail', async (c) => {
|
||||
const source = c.req.query('source');
|
||||
let sql: string;
|
||||
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') {
|
||||
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') {
|
||||
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') {
|
||||
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
|
||||
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`;
|
||||
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 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') {
|
||||
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
|
||||
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1
|
||||
AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`;
|
||||
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 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 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 {
|
||||
return c.json([]);
|
||||
}
|
||||
@@ -1085,6 +1175,141 @@ app.get('/weekly-detail', async (c) => {
|
||||
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 — 归属公司列表(含台数预览),用于顶部筛选下拉
|
||||
app.get('/subjects', async (c) => {
|
||||
const all = await getVehicles();
|
||||
@@ -1124,20 +1349,18 @@ app.get('/debug', async (c) => {
|
||||
${WEEK_END_SQL} AS week_end,
|
||||
CURDATE() AS today,
|
||||
WEEKDAY(CURDATE()) AS weekday`);
|
||||
const [[deliveredAll]] = await 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=1 AND task.task_status=1`);
|
||||
const [[deliveredRecent]] = await 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=1 AND task.task_status=1
|
||||
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`);
|
||||
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(take.handover_date) AS latest 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=1 AND task.task_status=1`);
|
||||
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
|
||||
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
|
||||
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`);
|
||||
const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
|
||||
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
|
||||
const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
|
||||
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
|
||||
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`);
|
||||
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(delivery_time) AS latest FROM delivery_vehicle
|
||||
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
|
||||
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
|
||||
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
|
||||
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`);
|
||||
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(arrival_time) AS latest FROM return_vehicle_task
|
||||
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)`);
|
||||
|
||||
return c.json({
|
||||
weekRange: dateRange,
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface VehicleRow {
|
||||
租赁公司: string;
|
||||
车辆归属状态Label: string | null;
|
||||
车辆型号Label: string | null;
|
||||
车辆类型参数: string | null;
|
||||
库存区域: string | null;
|
||||
车辆租赁状态: string | null;
|
||||
车辆租赁状态Label: string | null;
|
||||
@@ -40,6 +41,7 @@ export interface Vehicle {
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
status: 'Operating' | 'Inventory' | 'Pending' | 'Abnormal';
|
||||
operationStatus: string | null;
|
||||
ownership: string;
|
||||
rentCompany: string;
|
||||
contractNo: string | null;
|
||||
|
||||
Reference in New Issue
Block a user