feat: polish BI dashboards and bump version
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
lingniu
2026-06-27 21:59:33 +08:00
parent 5377d2c225
commit b0caa5afcb
33 changed files with 2363 additions and 483 deletions

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,15 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--app-bg: #f4f7fb;
--panel-bg: rgba(255, 255, 255, 0.9);
--hairline: rgba(148, 163, 184, 0.18);
}
html, body { html, body {
overscroll-behavior: none; overscroll-behavior: none;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
background: var(--app-bg);
} }
html { html {
@@ -20,6 +27,41 @@ body {
to { transform: translateX(-50%); } 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 { @utility animate-marquee {
animation: marquee 30s linear infinite; 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%);
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Plug } from 'lucide-react'; import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge'; import TrendBadge from './TrendBadge';
import { fetchElectricMonthly } from './api'; import { fetchElectricMonthly } from './api';
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types'; import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile } from '../../components/ui/surface';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' }, { id: 'thisWeek', label: '本周' },
@@ -40,10 +41,30 @@ export default function ElectricDaily() {
}); });
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]); const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
const totalFee = useMemo(() => (months ?? []).reduce((s, m) => s + (m.fee || 0), 0), [months]);
const activeDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => r.kwh > 0).length, 0), [months]);
const abnormalDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => Math.abs(r.chainPct) >= 0.3).length, 0), [months]);
const avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
const scopeLabel = QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const hasFeeDetail = totalFee > 0;
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0; const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<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="按日期汇总" />
<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>
{/* 日期速选 */} {/* 日期速选 */}
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x"> <div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
{QUICK_PICK_OPTIONS.map(opt => ( {QUICK_PICK_OPTIONS.map(opt => (
@@ -106,11 +127,11 @@ export default function ElectricDaily() {
<span className="text-right"></span> <span className="text-right"></span>
</div> </div>
{error ? ( {error ? (
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div> <div className="p-3"><ErrorState message={error} /></div>
) : months === null ? ( ) : months === null ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div> <div className="p-3"><LoadingState label="正在加载充电明细" /></div>
) : months.length === 0 ? ( ) : months.length === 0 ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div> <div className="p-3"><EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" /></div>
) : months.map(m => { ) : months.map(m => {
const open = openMonths.has(m.month); const open = openMonths.has(m.month);
return ( return (

View File

@@ -1,7 +1,9 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react'; import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import ElectricView, { type ElectricSubTab } from './ElectricView'; import ElectricView, { type ElectricSubTab } from './ElectricView';
import SubTabs from './SubTabs'; import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab'; import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
const SUB_TABS = [ const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays }, { id: 'daily', label: '每日', icon: CalendarDays },
@@ -13,11 +15,19 @@ const SUB_IDS: readonly ElectricSubTab[] = ['daily', 'overview'];
export default function ElectricModule() { export default function ElectricModule() {
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS); const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
return ( return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}> <PageFrame
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden"> title="电能成本看板"
subtitle="围绕充电量、费用、日趋势和车辆归属展示电能支出结构,辅助识别费用波动。"
icon={CalendarDays}
eyebrow="ELECTRIC BI"
meta="时间单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} /> <SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<ElectricView sub={sub} /> <ElectricView sub={sub} />
</div> </FadeIn>
</div> </AnimatePresence>
</PageFrame>
); );
} }

View File

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

View File

@@ -1,11 +1,17 @@
import { Receipt } from 'lucide-react';
import ETCView from './ETCView'; import ETCView from './ETCView';
import { PageFrame } from '../../components/ui/surface';
export default function EtcModule() { export default function EtcModule() {
return ( return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}> <PageFrame
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden"> title="ETC 通行费看板"
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
icon={Receipt}
eyebrow="ETC BI"
meta="数据对接中 · 页面能力预留"
>
<ETCView /> <ETCView />
</div> </PageFrame>
</div>
); );
} }

View File

@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Plug } from 'lucide-react'; import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import TrendBadge from './TrendBadge'; import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api'; import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types'; import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' }, { id: 'thisWeek', label: '本周' },
@@ -32,6 +33,19 @@ export default function HydrogenDaily() {
// 柱图:按日期升序,用于"从左到右时间流" // 柱图:按日期升序,用于"从左到右时间流"
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]); const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0); const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
const stationCount = useMemo(() => {
const names = new Set<string>();
(rows ?? []).forEach(r => r.stations.forEach(s => names.add(s.name)));
return names.size;
}, [rows]);
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
const scopeLabel = QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
const lowDay = trendData
.filter(item => item.totalKg > 0)
.reduce<HydrogenDailyRow | null>((low, item) => (!low || item.totalKg < low.totalKg ? item : low), null);
const zeroDays = (rows ?? []).filter(r => r.totalKg === 0).length;
const toggle = (date: string) => setExpanded(prev => { const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -41,6 +55,13 @@ export default function HydrogenDaily() {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<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="按日期汇总" />
<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>
{/* 日期速选 */} {/* 日期速选 */}
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x"> <div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
{QUICK_PICK_OPTIONS.map(opt => ( {QUICK_PICK_OPTIONS.map(opt => (
@@ -96,13 +117,34 @@ export default function HydrogenDaily() {
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */} {/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && ( {!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4"> <SurfaceCard>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700"></span> <span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span> <span className="text-[11px] text-slate-400 font-bold"> · Kg</span>
</div> </div>
<ResponsiveContainer width="100%" height={160}> <div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}> <div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{zeroDays}
</div>
</div>
</div>
<div className="h-[180px] min-w-0 px-2 pb-2">
<ResponsiveContainer width="100%" height={180} minWidth={0}>
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis <XAxis
dataKey="date" dataKey="date"
tickFormatter={(v: string) => v.slice(5)} tickFormatter={(v: string) => v.slice(5)}
@@ -112,13 +154,27 @@ export default function HydrogenDaily() {
interval="preserveStartEnd" interval="preserveStartEnd"
minTickGap={8} minTickGap={8}
/> />
<YAxis hide /> <YAxis
width={42}
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
/>
<Tooltip <Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']} formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
labelFormatter={(d) => `日期 ${d}`} labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }} contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }} cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/> />
{avgKg > 0 && (
<ReferenceLine
y={avgKg}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}> <Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => ( {trendData.map((_, i) => (
<Cell key={i} fill="url(#hydrogenBarGrad)" /> <Cell key={i} fill="url(#hydrogenBarGrad)" />
@@ -133,6 +189,7 @@ export default function HydrogenDaily() {
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</SurfaceCard>
)} )}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */} {/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
@@ -154,11 +211,11 @@ export default function HydrogenDaily() {
</div> </div>
{/* 主行 + 子行 */} {/* 主行 + 子行 */}
{error ? ( {error ? (
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div> <div className="p-3"><ErrorState message={error} /></div>
) : rows === null ? ( ) : rows === null ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div> <div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div> <div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
) : rows.map(r => { ) : rows.map(r => {
const open = expanded.has(r.date); const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3; const isAbnormal = Math.abs(r.chainPct) >= 0.3;

View File

@@ -1,7 +1,9 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react'; import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView'; import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import SubTabs from './SubTabs'; import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab'; import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
const SUB_TABS = [ const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays }, { id: 'daily', label: '每日', icon: CalendarDays },
@@ -13,11 +15,19 @@ const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
export default function HydrogenModule() { export default function HydrogenModule() {
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS); const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
return ( return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}> <PageFrame
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden"> title="氢能经营看板"
subtitle="按时间、车辆归属、加氢站和区域统一展示加氢量、费用、收入与异常波动。"
icon={CalendarDays}
eyebrow="ENERGY BI"
meta="数据单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} /> <SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<HydrogenView sub={sub} /> <HydrogenView sub={sub} />
</div> </FadeIn>
</div> </AnimatePresence>
</PageFrame>
); );
} }

View File

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

View File

@@ -1,4 +1,5 @@
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import { SegmentedNav } from '../../components/ui/surface';
interface SubTab<T extends string> { interface SubTab<T extends string> {
id: T; id: T;
@@ -14,26 +15,8 @@ interface Props<T extends string> {
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) { export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
return ( return (
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]"> <div className="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">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden"> <SegmentedNav tabs={tabs} active={active} onChange={onChange} />
<div className="p-1 flex gap-1">
{tabs.map(({ id, label, icon: Icon }) => {
const isActive = active === id;
return (
<button
key={id}
onClick={() => onChange(id)}
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
isActive ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
}`}
>
<Icon size={14} />
<span>{label}</span>
</button>
);
})}
</div>
</div>
</div> </div>
); );
} }

View File

@@ -1,13 +1,210 @@
import { FileText } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ArrowDownRight, BarChart3, CheckCircle2, Database, Route, Target, Truck } from 'lucide-react';
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import { fetchDailyReport, type DailyReportData, type DailyReportVehicle } from './api';
function fmt(value: number, digits = 0) {
return value.toLocaleString('zh-CN', { maximumFractionDigits: digits });
}
function fmtKm(value: number, digits = 1) {
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(digits)}`;
return fmt(value, digits);
}
export default function DailyReportView() { export default function DailyReportView() {
const [data, setData] = useState<DailyReportData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetchDailyReport()
.then(result => { if (!cancelled) setData(result); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
const totals = useMemo(() => {
const models = data?.models ?? [];
return models.reduce(
(acc, item) => ({
count: acc.count + item.count,
today: acc.today + item.today,
total: acc.total + item.total,
active: acc.active + item.active,
zero: acc.zero + item.zero,
dailyNeed: acc.dailyNeed + item.dailyNeed,
}),
{ count: 0, today: 0, total: 0, active: 0, zero: 0, dailyNeed: 0 },
);
}, [data]);
if (loading) return <LoadingState label="正在从数据库生成每日汇报" />;
if (error) return <ErrorState message={error} />;
if (!data) return <ErrorState message="日报数据为空" />;
const activeRate = totals.count > 0 ? (totals.active / totals.count) * 100 : 0;
const dailyGap = totals.today - totals.dailyNeed;
const maxTrend = Math.max(1, ...data.trend.map(item => item.value));
const maxModel = Math.max(1, ...data.models.map(item => item.today));
return ( return (
<div className="flex items-center justify-center py-20"> <div className="space-y-4">
<div className="text-center"> <SurfaceCard className="overflow-hidden">
<FileText size={48} className="mx-auto text-gray-300 mb-4" /> <div className="grid gap-3 p-4 md:grid-cols-[1.3fr_1fr] md:items-center">
<h2 className="text-lg font-semibold text-gray-500"></h2> <div>
<p className="text-sm text-gray-400 mt-2">...</p> <div className="flex flex-wrap items-center gap-2">
<div className="text-[11px] font-black text-blue-600"> {data.reportDate} 23:59 · </div>
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-black text-emerald-600">
<Database size={11} />
DB LIVE
</span>
</div>
<h2 className="mt-2 text-xl font-black tracking-tight text-slate-950"></h2>
<p className="mt-2 text-xs font-bold leading-relaxed text-slate-500">
/
</p>
</div>
<div className="grid grid-cols-3 gap-2 rounded-2xl bg-slate-50 p-2 text-center">
<div className="rounded-xl bg-white px-2 py-2">
<div className="text-lg font-black text-slate-950">{fmt(totals.count)}</div>
<div className="text-[10px] font-black text-slate-400"> </div>
</div>
<div className="rounded-xl bg-white px-2 py-2">
<div className="text-lg font-black text-emerald-600">{activeRate.toFixed(1)}%</div>
<div className="text-[10px] font-black text-slate-400"></div>
</div>
<div className="rounded-xl bg-white px-2 py-2">
<div className="text-lg font-black text-rose-600">{totals.zero}</div>
<div className="text-[10px] font-black text-slate-400"></div>
</div>
</div>
</div>
</SurfaceCard>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Route} label="当日总里程" value={fmtKm(totals.today)} unit="km" helper={`${data.reportDate} 数据库合计`} />
<MetricTile icon={Truck} label="当日有里程车辆" value={`${totals.active}/${totals.count}`} helper={`零里程 ${totals.zero}`} tone="emerald" />
<MetricTile icon={Target} label="日需完成" value={fmtKm(totals.dailyNeed)} unit="km" helper="当前考核年度压力折算" tone="amber" />
<MetricTile
icon={ArrowDownRight}
label="对比日需"
value={`${dailyGap >= 0 ? '+' : '-'}${fmtKm(Math.abs(dailyGap))}`}
unit="km"
helper={dailyGap >= 0 ? '今日高于日需目标' : '今日低于日需目标'}
tone={dailyGap >= 0 ? 'emerald' : 'rose'}
/>
</div>
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
<SurfaceCard title="近 7 日总里程趋势" subtitle="时间单位:日 · 单位 km · 数据来自 v_vehicle_daily_stats">
<div className="flex h-64 items-end gap-2 px-4 pb-4 pt-6">
{data.trend.map(item => (
<div key={item.date} className="flex min-w-0 flex-1 flex-col items-center gap-2">
<div className="relative flex h-44 w-full items-end rounded-xl bg-slate-50">
<div
className="w-full rounded-xl bg-gradient-to-t from-blue-600 to-cyan-400 shadow-sm"
style={{ height: `${Math.max(4, (item.value / maxTrend) * 100)}%` }}
/>
</div>
<div className="text-[10px] font-black text-slate-400">{item.date}</div>
</div>
))}
</div>
</SurfaceCard>
<SurfaceCard title="车型任务进度" subtitle={`时间单位:${data.reportDate} 单日 / 当前考核年度`}>
<div className="space-y-3 p-4">
{data.models.map(item => (
<div key={item.id}>
<div className="mb-1.5 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-xs font-black text-slate-800">{item.name}</div>
<div className="mt-0.5 text-[10px] font-bold text-slate-400">{item.active}/{item.count} · {item.zero}</div>
</div>
<div className="text-right text-xs font-black text-blue-600">{fmt(item.today, 1)} km</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-blue-500" style={{ width: `${Math.max(4, (item.today / maxModel) * 100)}%` }} />
</div>
<div className="mt-1 flex justify-between text-[10px] font-bold text-slate-400">
<span> {item.completion.toFixed(1)}%</span>
<span> {fmt(item.dailyNeed, 1)} km</span>
</div>
</div>
))}
</div>
</SurfaceCard>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<ReportList title="当日里程 Top 5" subtitle={`时间单位:${data.reportDate} 单日 · 单位 km`} rows={data.topVehicles} mode="top" />
<ReportList title="需要关注:运营中零里程" subtitle="筛选租赁/自营车辆,按累计完成率优先跟进" rows={data.zeroRisk} mode="risk" />
</div>
<div className="grid gap-3 md:grid-cols-3">
<SurfaceCard>
<div className="p-4">
<CheckCircle2 className="mb-2 text-emerald-500" size={18} />
<div className="text-sm font-black text-slate-900">{data.qualifiedCount} </div>
<div className="mt-1 text-[11px] font-bold text-slate-400">{data.halfQualifiedCount} 50% 线</div>
</div>
</SurfaceCard>
<SurfaceCard>
<div className="p-4">
<BarChart3 className="mb-2 text-blue-500" size={18} />
<div className="text-sm font-black text-slate-900"> {fmt(totals.count > 0 ? totals.today / totals.count : 0, 1)} km/</div>
<div className="mt-1 text-[11px] font-bold text-slate-400"> {fmt(totals.active > 0 ? totals.today / totals.active : 0, 1)} km/</div>
</div>
</SurfaceCard>
<SurfaceCard>
<div className="p-4">
<AlertTriangle className="mb-2 text-amber-500" size={18} />
<div className="text-sm font-black text-slate-900"> {fmtKm(Math.abs(dailyGap))} km</div>
<div className="mt-1 text-[11px] font-bold text-slate-400"></div>
</div>
</SurfaceCard>
</div> </div>
</div> </div>
); );
} }
function ReportList({
title,
subtitle,
rows,
mode,
}: {
title: string;
subtitle: string;
rows: DailyReportVehicle[];
mode: 'top' | 'risk';
}) {
return (
<SurfaceCard title={title} subtitle={subtitle}>
<div className="divide-y divide-slate-50">
{rows.length === 0 ? (
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400"></div>
) : rows.map(item => (
<div key={`${item.plate}-${mode}`} className="grid grid-cols-[1fr_auto] gap-3 px-4 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-black text-slate-900">{item.plate}</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-black text-slate-500">{item.model}</span>
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-black text-blue-600">{item.status}</span>
</div>
<div className="mt-1 truncate text-[11px] font-bold text-slate-400">{item.customer}</div>
</div>
<div className={mode === 'top' ? 'text-right text-sm font-black text-blue-600' : 'text-right text-sm font-black text-amber-600'}>
{mode === 'top' ? `${fmt(item.today ?? 0, 1)} km` : `${(item.completion ?? 0).toFixed(1)}%`}
</div>
</div>
))}
</div>
</SurfaceCard>
);
}

View File

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

View File

@@ -3,8 +3,9 @@ import { motion, AnimatePresence } from 'motion/react';
import { import {
Truck, Filter, ChevronDown, Truck, Filter, ChevronDown,
Maximize2, Minimize2, RotateCcw, Maximize2, Minimize2, RotateCcw,
ArrowUp, ArrowDown, ChevronsUp, Download, Check, ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays,
} from 'lucide-react'; } from 'lucide-react';
import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine } from 'recharts';
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types'; import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
import { fetchMonitoring } from './api'; import { fetchMonitoring } from './api';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
@@ -18,6 +19,40 @@ const HIGH_MILEAGE_ALERT_TARGETS = new Set([
]); ]);
const HIGH_MILEAGE_ALERT_KM = 800; const HIGH_MILEAGE_ALERT_KM = 800;
function defaultMileageDate(): string {
const now = new Date();
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
function normalizeRangeLabel(start: string, end: string): string {
if (!start && !end) return '最新数据';
if (start && end && start === end) return start;
return `${start || end}${end || start}`;
}
function fmtDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
type RangePreset = 'today' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15';
function getRangePreset(preset: RangePreset): { start: string; end: string } {
const end = new Date(`${defaultMileageDate()}T00:00:00`);
const start = new Date(end);
if (preset === 'thisWeek') {
const day = start.getDay() || 7;
start.setDate(start.getDate() - day + 1);
} else if (preset === 'thisMonth') {
start.setDate(1);
} else if (preset === 'last7') {
start.setDate(start.getDate() - 6);
} else if (preset === 'last15') {
start.setDate(start.getDate() - 14);
}
return { start: fmtDate(start), end: fmtDate(end) };
}
const SearchableSelect = ({ const SearchableSelect = ({
options, options,
value, value,
@@ -246,15 +281,14 @@ export default function MonitoringView() {
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' }); const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null); const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
const [filterDate, setFilterDate] = useState(() => { const [rangeStart, setRangeStart] = useState(defaultMileageDate);
const now = new Date(); const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
});
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]); const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }); const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] }); const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] });
const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number }[]>([]);
const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() }));
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
@@ -265,6 +299,20 @@ export default function MonitoringView() {
const departments = filterOptions.departments; const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates; const plateNumbers = filterOptions.plates;
const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end);
const isRangeMode = !!effectiveRange.start && !!effectiveRange.end && effectiveRange.start !== effectiveRange.end;
const averageDailyKm = rangeDailyTotals.length > 0
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
: 0;
const topLoadedVehicle = useMemo(
() => vehicles.reduce<MonitoringVehicle | null>((best, vehicle) => (!best || vehicle.dailyKm > best.dailyKm ? vehicle : best), null),
[vehicles],
);
const applyRangePreset = useCallback((preset: RangePreset) => {
const range = getRangePreset(preset);
setRangeStart(range.start);
setRangeEnd(range.end);
}, []);
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => { const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name)) const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name))
@@ -292,16 +340,19 @@ export default function MonitoringView() {
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined, mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined, mileageMax: appliedMileageRange.max || undefined,
date: filterDate || undefined, startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
}).then(d => { }).then(d => {
setVehicles(d.vehicles); setVehicles(d.vehicles);
setStats(d.stats); setStats(d.stats);
setFilterOptions(d.filters); setFilterOptions(d.filters);
setRangeDailyTotals(d.rangeDailyTotals || []);
setEffectiveRange(d.dateRange || { start: rangeStart, end: rangeEnd });
setTotal(d.total); setTotal(d.total);
setPage(1); setPage(1);
setHasMore(d.page < d.totalPages); setHasMore(d.page < d.totalPages);
}).catch(() => {}).finally(() => setPageLoading(false)); }).catch(() => {}).finally(() => setPageLoading(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate]); }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd]);
// 加载更多 // 加载更多
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
@@ -325,13 +376,14 @@ export default function MonitoringView() {
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined, mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined, mileageMax: appliedMileageRange.max || undefined,
date: filterDate || undefined, startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
}).then(d => { }).then(d => {
setVehicles(prev => [...prev, ...d.vehicles]); setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage); setPage(nextPage);
setHasMore(nextPage < d.totalPages); setHasMore(nextPage < d.totalPages);
}).catch(() => {}).finally(() => setLoadingMore(false)); }).catch(() => {}).finally(() => setLoadingMore(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]); }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore]);
// 筛选/排序变化时重新加载 // 筛选/排序变化时重新加载
useEffect(() => { useEffect(() => {
@@ -368,15 +420,16 @@ export default function MonitoringView() {
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined, mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined, mileageMax: appliedMileageRange.max || undefined,
date: filterDate || undefined, startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
}); });
exportMileageXlsx(d.vehicles, { date: filterDate, sortBy }); exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
} catch (err) { } catch (err) {
console.error('export failed', err); console.error('export failed', err);
} finally { } finally {
setExporting(false); setExporting(false);
} }
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate]); }, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd]);
// 每分钟自动刷新 // 每分钟自动刷新
useEffect(() => { useEffect(() => {
@@ -445,13 +498,14 @@ export default function MonitoringView() {
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined, targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined, region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
date: filterDate || undefined, startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
}).then(d => { }).then(d => {
setFullscreenVehicles(d.vehicles); setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats); setFullscreenStats(d.stats);
setFilterOptions(d.filters); setFilterOptions(d.filters);
}).catch(() => {}).finally(() => setFullscreenLoading(false)); }).catch(() => {}).finally(() => setFullscreenLoading(false));
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, filterDate, fullscreenRefresh]); }, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh]);
// 全屏时禁止背景滚动 // 全屏时禁止背景滚动
useEffect(() => { useEffect(() => {
@@ -512,7 +566,7 @@ export default function MonitoringView() {
<h2 className="text-white font-bold text-xs"></h2> <h2 className="text-white font-bold text-xs"></h2>
</div> </div>
<div className="flex items-center gap-3 text-[10px]"> <div className="flex items-center gap-3 text-[10px]">
<span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span> <span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span> <span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span> <span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span> <span className="text-slate-700">|</span>
@@ -633,7 +687,7 @@ export default function MonitoringView() {
}} }}
> >
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<span></span> <span></span>
{sortBy === 'today' && ( {sortBy === 'today' && (
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} /> sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
)} )}
@@ -730,7 +784,7 @@ export default function MonitoringView() {
onClick={() => setSortBy('today')} onClick={() => setSortBy('today')}
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`} className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
> >
</button> </button>
<button <button
onClick={() => setSortBy('total')} onClick={() => setSortBy('total')}
@@ -779,6 +833,35 @@ export default function MonitoringView() {
<Filter size={16} /> <Filter size={16} />
</button> </button>
</div> </div>
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
{([
['today', '今天'],
['thisWeek', '本周'],
['thisMonth', '本月'],
['last15', '近15天'],
] as Array<[RangePreset, string]>).map(([preset, label]) => {
const range = getRangePreset(preset);
const active = rangeStart === range.start && rangeEnd === range.end;
return (
<button
key={preset}
type="button"
onClick={() => applyRangePreset(preset)}
className={`shrink-0 rounded-lg border px-2.5 py-1.5 text-[10px] font-black transition-all ${
active
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-slate-50 text-slate-500 hover:border-blue-100 hover:bg-blue-50 hover:text-blue-600'
}`}
>
{label}
</button>
);
})}
<span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">
{rangeLabel}
</span>
</div>
</div> </div>
{/* Expandable Filter Panel */} {/* Expandable Filter Panel */}
@@ -791,15 +874,42 @@ export default function MonitoringView() {
className="overflow-hidden" className="overflow-hidden"
> >
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-2 space-y-4"> <div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-2 space-y-4">
{/* Date */} {/* Date range */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label> <label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<input <input
type="date" type="date"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none" className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterDate} value={rangeStart}
onChange={(e) => setFilterDate(e.target.value)} onChange={(e) => setRangeStart(e.target.value)}
/> />
<span className="text-[10px] font-bold text-slate-300"></span>
<input
type="date"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={rangeEnd}
onChange={(e) => setRangeEnd(e.target.value)}
/>
</div>
<div className="flex items-center gap-1.5 pt-1">
{([
['today', '今天'],
['thisWeek', '本周'],
['thisMonth', '本月'],
['last7', '近7天'],
['last15', '近15天'],
] as Array<[RangePreset, string]>).map(([preset, label]) => (
<button
key={preset}
type="button"
className="rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
onClick={() => applyRangePreset(preset)}
>
{label}
</button>
))}
</div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -926,6 +1036,9 @@ export default function MonitoringView() {
setFilterRegion('All'); setFilterRegion('All');
setFilterMileageRange({ min: '', max: '' }); setFilterMileageRange({ min: '', max: '' });
setAppliedMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
const today = defaultMileageDate();
setRangeStart(today);
setRangeEnd(today);
}} }}
className="text-[10px] font-bold text-slate-400 hover:text-slate-600" className="text-[10px] font-bold text-slate-400 hover:text-slate-600"
> >
@@ -964,13 +1077,21 @@ export default function MonitoringView() {
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, min: '' })); setAppliedMileageRange(prev => ({ ...prev, min: '' })); } }); if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, min: '' })); setAppliedMileageRange(prev => ({ ...prev, min: '' })); } });
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } }); if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } });
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') }); if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') });
if (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') }); if (rangeStart || rangeEnd) tags.push({
label: `区间: ${normalizeRangeLabel(rangeStart, rangeEnd)}`,
onClear: () => {
const today = defaultMileageDate();
setRangeStart(today);
setRangeEnd(today);
}
});
if (tags.length === 0) return null; if (tags.length === 0) return null;
const clearAll = () => { const clearAll = () => {
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All'); setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetNames([]); setFilterRegion('All'); setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetNames([]); setFilterRegion('All');
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' }); setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
setFilterDate(''); const today = defaultMileageDate();
setRangeStart(today); setRangeEnd(today);
}; };
return ( return (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -988,13 +1109,14 @@ export default function MonitoringView() {
})()} })()}
{/* Sticky header: KPI + 清单标题 */} {/* Sticky header: KPI + 清单标题 */}
<div className="sticky top-[44px] z-20 bg-[#F8F9FB] pt-1 pb-1 space-y-2"> <div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}> <div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}>
<div className="col-span-2 bg-slate-900 p-2.5 rounded-xl text-white relative overflow-hidden"> <div className="col-span-2 bg-slate-900 p-2.5 rounded-xl text-white relative overflow-hidden">
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'today' ? '今日' : '累计'}</div> <div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'today' ? (isRangeMode ? '区间' : '当日') : '累计'}</div>
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1"> <div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>} {pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
</div> </div>
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
</div> </div>
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm"> <div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
<div className="text-[7px] font-bold text-slate-400 uppercase"></div> <div className="text-[7px] font-bold text-slate-400 uppercase"></div>
@@ -1007,6 +1129,66 @@ export default function MonitoringView() {
<div className="text-[7px] text-slate-400"></div> <div className="text-[7px] text-slate-400"></div>
</div> </div>
</div> </div>
<div className="rounded-xl border border-slate-100 bg-white shadow-sm overflow-hidden">
{pageLoading ? (
<div className="h-[74px] bg-slate-50 animate-pulse" />
) : isRangeMode ? (
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
<div className="min-w-0">
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
<CalendarDays size={12} className="text-blue-500" />
</div>
<div className="mt-1 text-[9px] font-bold text-slate-400">{rangeDailyTotals.length} · km</div>
<div className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{rangeLabel}</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 }}>
<Tooltip
formatter={(value) => [`${Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} km`, '当日里程']}
labelFormatter={(label) => `日期 ${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)_84px_84px] items-center gap-2 px-3 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 truncate text-[9px] font-bold text-slate-400">{rangeLabel} · km</div>
<div className="mt-1 truncate text-[10px] font-bold text-slate-500">
{topLoadedVehicle ? `${topLoadedVehicle.plate} · ${Math.round(topLoadedVehicle.dailyKm).toLocaleString()} km` : '-'}
</div>
</div>
<div className="rounded-lg bg-blue-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-blue-400"></div>
<div className="text-xs font-black text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
</div>
<div className="rounded-lg bg-slate-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-slate-400"></div>
<div className="text-xs font-black text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></span> <span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></span>
<span className="text-[9px] font-bold text-slate-300">{total} </span> <span className="text-[9px] font-bold text-slate-300">{total} </span>
@@ -1072,7 +1254,7 @@ export default function MonitoringView() {
{!v.isDataSynced && v.totalKm == null && ( {!v.isDataSynced && v.totalKm == null && (
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div> <div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
)} )}
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none"></span> <span className="text-[7px] font-black text-blue-600/50 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none" title={isRangeMode ? '区间内里程' : '当日里程'}></span>
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}> <div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>km</span></> : <span className="text-[7px] text-amber-500/70"></span>} {(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>km</span></> : <span className="text-[7px] text-amber-500/70"></span>}
</div> </div>

View File

@@ -79,6 +79,14 @@ export default function StatisticsView() {
const selectedTarget = targets.find(t => t.id === selectedTargetId); const selectedTarget = targets.find(t => t.id === selectedTargetId);
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null; const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0; const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
const selectedRemaining = selectedAssessment?.remaining ?? 0;
const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0;
const selectedDailyTarget = selectedAssessment?.dailyTarget ?? (selectedDaysLeft > 0 ? selectedRemaining / selectedDaysLeft : 0);
const selectedQualifiedRate = selectedAssessment?.qualifiedRate ?? (selectedTarget?.vehicleCount ? selectedTarget.yearQualifiedCount / selectedTarget.vehicleCount * 100 : 0);
const latestTrend = trendData[trendData.length - 1];
const previousTrend = trendData[trendData.length - 2];
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
const pressureLevel = selectedCompletion >= 90 ? '健康' : selectedCompletion >= 70 ? '关注' : '高压';
// Load targets on mount // Load targets on mount
useEffect(() => { useEffect(() => {
@@ -102,6 +110,12 @@ export default function StatisticsView() {
// Load trend when selectedTargetId changes // Load trend when selectedTargetId changes
useEffect(() => { useEffect(() => {
if (selectedTargetId === null) return; if (selectedTargetId === null) return;
setExpandedTargetId(selectedTargetId);
if (!targetVehiclesMap[selectedTargetId]) {
fetchTargetVehicles(selectedTargetId).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [selectedTargetId]: vehicles }));
}).catch(() => {});
}
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([])); fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
}, [selectedTargetId]); }, [selectedTargetId]);
@@ -121,7 +135,10 @@ export default function StatisticsView() {
{targets.map(target => ( {targets.map(target => (
<button <button
key={target.id} key={target.id}
onClick={() => setSelectedTargetId(target.id)} onClick={() => {
setSelectedTargetId(target.id);
setExpandedTargetId(target.id);
}}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${ className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedTargetId === target.id selectedTargetId === target.id
? 'bg-blue-600 text-white shadow-md shadow-blue-200' ? 'bg-blue-600 text-white shadow-md shadow-blue-200'
@@ -133,6 +150,45 @@ export default function StatisticsView() {
))} ))}
</div> </div>
{selectedTarget && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-2 gap-2 md:grid-cols-4"
>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${pressureLevel === '健康' ? 'text-emerald-600' : pressureLevel === '关注' ? 'text-amber-600' : 'text-rose-600'}`}>
{pressureLevel}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400"> {fmtPercent(selectedCompletion)}</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">{fmtKm(Math.max(0, selectedRemaining))}<span className="ml-1 text-[10px] text-slate-400">km</span></div>
<div className="mt-1 text-[10px] font-bold text-slate-400"> {selectedDaysLeft} </div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className="mt-1 text-lg font-black text-blue-600">
{selectedDaysLeft > 0 ? (
<>
{fmtKm(selectedDailyTarget)}<span className="ml-1 text-[10px] text-slate-400">km</span>
</>
) : '已到期'}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400"></div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400"> {fmtPercent(selectedQualifiedRate)}</div>
</div>
</motion.div>
)}
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden"> <div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
{/* Left Side: Trend Chart / Dashboard Sidebar */} {/* Left Side: Trend Chart / Dashboard Sidebar */}
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0"> <div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
@@ -193,9 +249,8 @@ export default function StatisticsView() {
))} ))}
</div> </div>
</div> </div>
<div className="flex-1 w-full min-h-[250px] relative"> <div className="h-[280px] w-full min-w-0">
<div className="absolute inset-0"> <ResponsiveContainer width="100%" height={280} minWidth={0}>
<ResponsiveContainer width="100%" height="100%">
{chartType === 'bar' ? ( {chartType === 'bar' ? (
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}> <BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
@@ -240,14 +295,16 @@ export default function StatisticsView() {
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Right Side: Summary Section */} {/* Right Side: Summary Section */}
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden"> <div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
<div className="flex items-center justify-between px-2 flex-shrink-0"> <div className="flex items-center justify-between px-2 flex-shrink-0">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" /> <div className="w-1 h-4 bg-blue-600 rounded-full" />
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest"></h3> <div className="min-w-0">
<h3 className="truncate text-xs font-black text-slate-700"></h3>
<p className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{selectedTarget?.targetName || '请选择车型'}</p>
</div>
</div> </div>
<button <button
onClick={() => setIsTableFullscreen(true)} onClick={() => setIsTableFullscreen(true)}
@@ -258,7 +315,7 @@ export default function StatisticsView() {
</div> </div>
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2"> <div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
{targets.map((target, idx) => ( {(selectedTarget ? [selectedTarget] : []).map((target, idx) => (
(() => { (() => {
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]); const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
const primaryCompletion = assessment?.completionRate ?? target.avgCompletion; const primaryCompletion = assessment?.completionRate ?? target.avgCompletion;
@@ -269,7 +326,7 @@ export default function StatisticsView() {
key={idx} key={idx}
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer" className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
onClick={() => { onClick={() => {
setExpandedTargetId(expandedTargetId === target.id ? null : target.id); setExpandedTargetId(target.id);
if (!targetVehiclesMap[target.id]) { if (!targetVehiclesMap[target.id]) {
fetchTargetVehicles(target.id).then(data => { fetchTargetVehicles(target.id).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data })); setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
@@ -309,7 +366,7 @@ export default function StatisticsView() {
</div> </div>
</div> </div>
<motion.div <motion.div
animate={{ rotate: expandedTargetId === target.id ? 180 : 0 }} animate={{ rotate: 180 }}
className="text-slate-300" className="text-slate-300"
> >
<ChevronDown size={14} /> <ChevronDown size={14} />

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,6 +65,21 @@ interface MileageRow {
source: string; source: string;
} }
interface DailyMileageRow {
plate: string;
vin: string | null;
date: string;
daily_km: string | number | null;
source: string | null;
}
export interface RangeMileageResult {
vehicles: CachedVehicle[];
dailyTotals: { date: string; totalKm: number }[];
start: string;
end: string;
}
interface TargetRow { interface TargetRow {
id: number; id: number;
target_name: string; target_name: string;
@@ -160,31 +175,32 @@ function mergeVehicles(
} }
} }
return Array.from(mileageMap.values()).map(m => { return Array.from(infoMap.values()).map(info => {
const info = infoMap.get(m.plate); const m = mileageMap.get(info.plate);
const dailyKm = Number(m.daily_km) || 0; const plate = info.plate;
const source = m.source || 'NONE'; const dailyKm = Number(m?.daily_km) || 0;
const gpsTotal = m.total_km !== null ? Number(m.total_km) : null; const source = m?.source || 'NONE';
const latestPgTotal = latestPgTotalMap.get(m.plate); const gpsTotal = m?.total_km != null ? Number(m.total_km) : null;
const bizTotal = bizTotalMap.get(m.plate); const latestPgTotal = latestPgTotalMap.get(plate);
const bizTotal = bizTotalMap.get(plate);
return { return {
plate: m.plate, plate,
vin: m.vin, vin: m?.vin || info.vin || '',
dailyKm, dailyKm,
totalKm: gpsTotal !== null ? gpsTotal : (latestPgTotal ?? bizTotal ?? null), totalKm: gpsTotal !== null ? gpsTotal : (latestPgTotal ?? bizTotal ?? null),
source, source,
isOnline: source !== 'NONE' && dailyKm > 0, isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE', isDataSynced: source !== 'NONE',
customer: info?.customer || null, customer: info.customer || null,
department: info?.department || null, department: info.department || null,
manager: info?.manager || null, manager: info.manager || null,
managerId: info?.manager_id || null, managerId: info.manager_id || null,
rentStatus: info?.rent_status || null, rentStatus: info.rent_status || null,
entity: info?.entity || null, entity: info.entity || null,
project: info?.project || null, project: info.project || null,
region: regionMap[m.plate] || null, region: regionMap[plate] || null,
targetNames: targetNamesByPlate.get(m.plate) || [], targetNames: targetNamesByPlate.get(plate) || [],
yesterdayKm: yesterdayMap.get(m.plate) || 0, yesterdayKm: yesterdayMap.get(plate) || 0,
}; };
}); });
} }
@@ -281,6 +297,110 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
); );
} }
function datesBetween(start: string, end: string): string[] {
const result: string[] = [];
const [sy, sm, sd] = start.split('-').map(Number);
const [ey, em, ed] = end.split('-').map(Number);
const cursor = new Date(sy, sm - 1, sd);
const last = new Date(ey, em - 1, ed);
cursor.setHours(0, 0, 0, 0);
last.setHours(0, 0, 0, 0);
while (cursor <= last) {
result.push(`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`);
cursor.setDate(cursor.getDate() + 1);
}
return result;
}
export async function queryRangeMileage(startDate: string, endDate: string): Promise<RangeMileageResult> {
const days = datesBetween(startDate, endDate);
const [dailyRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
mileagePool.execute(
`SELECT plate,
DATE_FORMAT(stat_date, '%Y-%m-%d') AS date,
vin,
daily_km,
source
FROM v_vehicle_daily_stats
WHERE stat_date >= ? AND stat_date <= ?
ORDER BY stat_date, plate`,
[startDate, endDate]
).then(([r]) => r as DailyMileageRow[]),
mileagePool.execute(
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
[startDate]
).then(([r]) => r as { plate: string; daily_km: string }[]),
fetchVehicleInfoMap(),
fetchTargetRows(),
fetchBizTotalMileageMap(),
fetchLatestPgTotalMileageMap(endDate),
]);
const perVehicleDaily = new Map<string, Record<string, number>>();
const perVehicleSum = new Map<string, { plate: string; vin: string; daily_km: string; total_km: null; source: string }>();
const dailyTotals = new Map<string, number>();
const bestDailyRows = new Map<string, DailyMileageRow>();
for (const day of days) dailyTotals.set(day, 0);
for (const row of dailyRows) {
const key = `${row.plate}\u0000${row.date}`;
const km = Math.max(0, Number(row.daily_km) || 0);
const existing = bestDailyRows.get(key);
if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) {
bestDailyRows.set(key, row);
}
}
for (const row of bestDailyRows.values()) {
const km = Math.max(0, Number(row.daily_km) || 0);
const date = row.date;
const plate = row.plate;
dailyTotals.set(date, (dailyTotals.get(date) || 0) + km);
const daily = perVehicleDaily.get(plate) || {};
daily[date] = km;
perVehicleDaily.set(plate, daily);
const existing = perVehicleSum.get(plate);
perVehicleSum.set(plate, {
plate,
vin: existing?.vin || row.vin || '',
daily_km: String((Number(existing?.daily_km) || 0) + km),
total_km: null,
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
});
}
const yesterdayMap = new Map<string, number>();
for (const r of yesterdayRows) {
const km = Number(r.daily_km) || 0;
const existing = yesterdayMap.get(r.plate) || 0;
if (km > existing) yesterdayMap.set(r.plate, km);
}
const vehicles = mergeVehicles(
Array.from(perVehicleSum.values()),
infoMap,
yesterdayMap,
bizTotalMap,
latestPgTotalMap,
buildPlateTargetNamesMap(targetRows),
).map(vehicle => {
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
const completedDailyMileage: Record<string, number> = {};
for (const day of days) completedDailyMileage[day] = dailyMileage[day] || 0;
return { ...vehicle, dailyMileage: completedDailyMileage };
});
return {
vehicles,
dailyTotals: days.map(date => ({ date, totalKm: dailyTotals.get(date) || 0 })),
start: startDate,
end: endDate,
};
}
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters { export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
return buildFilters(vehicles, monitoringCache?.filters.targetNames || []); return buildFilters(vehicles, monitoringCache?.filters.targetNames || []);
} }

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { getCache, queryDateMileage, buildDateFilters } from './cache.js'; import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from './cache.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js'; import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import type { AuthUser } from '../../auth/types.js'; import type { AuthUser } from '../../auth/types.js';
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js'; import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
@@ -64,12 +64,40 @@ function parseTargetNames(reqUrl: string): string[] {
return Array.from(new Set(names)); return Array.from(new Set(names));
} }
function parseYmd(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return null;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
date.setHours(0, 0, 0, 0);
return Number.isFinite(date.getTime()) ? date : null;
}
function fmtYmd(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function normalizeRange(startQuery: string, endQuery: string): { start: string; end: string } | null {
if (!startQuery && !endQuery) return null;
const start = parseYmd(startQuery || endQuery);
const end = parseYmd(endQuery || startQuery);
if (!start || !end) return null;
const a = start <= end ? start : end;
let b = start <= end ? end : start;
const span = Math.round((b.getTime() - a.getTime()) / 86400000) + 1;
if (span > 366) {
b = new Date(a);
b.setDate(a.getDate() + 365);
}
return { start: fmtYmd(a), end: fmtYmd(b) };
}
app.get('/', async (c) => { app.get('/', async (c) => {
const sortBy = c.req.query('sortBy') || 'today'; const sortBy = c.req.query('sortBy') || 'today';
const sortOrder = c.req.query('sortOrder') || 'desc'; const sortOrder = c.req.query('sortOrder') || 'desc';
const limit = Number(c.req.query('limit')) || 50; const limit = Number(c.req.query('limit')) || 50;
const page = Number(c.req.query('page')) || 1; const page = Number(c.req.query('page')) || 1;
const date = c.req.query('date') || ''; const date = c.req.query('date') || '';
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
const filterParams = { const filterParams = {
search: c.req.query('search') || '', search: c.req.query('search') || '',
@@ -88,8 +116,21 @@ app.get('/', async (c) => {
let allVehicles: CachedVehicle[]; let allVehicles: CachedVehicle[];
let filters: MonitoringFilters; let filters: MonitoringFilters;
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
let dateRange: { start: string; end: string } | undefined;
if (date) { if (range) {
try {
const result = await queryRangeMileage(range.start, range.end);
allVehicles = result.vehicles;
rangeDailyTotals = result.dailyTotals;
dateRange = { start: result.start, end: result.end };
filters = buildDateFilters(allVehicles);
} catch (e: unknown) {
console.error('monitoring range query error:', e);
return c.json(EMPTY_RESPONSE, 500);
}
} else if (date) {
try { try {
allVehicles = await queryDateMileage(date); allVehicles = await queryDateMileage(date);
filters = buildDateFilters(allVehicles); filters = buildDateFilters(allVehicles);
@@ -118,6 +159,12 @@ app.get('/', async (c) => {
} }
const filtered = applyFilters(allVehicles, filterParams); const filtered = applyFilters(allVehicles, filterParams);
if (rangeDailyTotals && filtered.length !== allVehicles.length) {
rangeDailyTotals = rangeDailyTotals.map(item => ({
...item,
totalKm: filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0),
}));
}
const stats = { const stats = {
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0), totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
@@ -140,10 +187,12 @@ app.get('/', async (c) => {
vehicles: maskCustomerNames(paged), vehicles: maskCustomerNames(paged),
stats, stats,
filters, filters,
rangeDailyTotals,
dateRange,
total, total,
page, page,
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / limit),
updatedAt: date || getCache()?.updatedAt || new Date().toISOString(), updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
}); });
}); });

View File

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

View File

@@ -4,6 +4,7 @@ import type { VehicleInfoRow } from './types.js';
/** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */ /** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */
export const VEHICLE_INFO_SQL = `SELECT export const VEHICLE_INFO_SQL = `SELECT
vi.plate_number AS plate, vi.plate_number AS plate,
vi.vin AS vin,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer, 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_department_name, vor.business_dept) AS department,
COALESCE(c.business_manager_name, vor.business_manager) AS manager, COALESCE(c.business_manager_name, vor.business_manager) AS manager,

View File

@@ -468,6 +468,59 @@ function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds) {
const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`; const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`;
const WEEK_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`; const WEEK_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`;
type FlowType = 'delivered' | 'returned' | 'replaced';
interface FlowDetailRow {
id: string;
type: FlowType;
type_label: string;
stat_date: string;
truck_id: string;
plate_number: string;
event_time: string | null;
submit_time: string | null;
department: string | null;
manager: string | null;
customer_name: string | null;
}
function formatDateOnly(value: Date): string {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function isDateParam(value: string | undefined | null): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function addDateDays(date: string, days: number): string {
const d = new Date(`${date}T00:00:00`);
d.setDate(d.getDate() + days);
return formatDateOnly(d);
}
function listDateRange(start: string, end: string): string[] {
const dates: string[] = [];
let cursor = start;
while (cursor <= end && dates.length <= 370) {
dates.push(cursor);
cursor = addDateDays(cursor, 1);
}
return dates;
}
function normalizeDateRange(startRaw: string | undefined, endRaw: string | undefined): { start: string; end: string } {
const today = formatDateOnly(new Date());
const defaultStart = addDateDays(today, -29);
let start = isDateParam(startRaw) ? startRaw : defaultStart;
let end = isDateParam(endRaw) ? endRaw : today;
if (start > end) [start, end] = [end, start];
if (listDateRange(start, end).length > 370) start = addDateDays(end, -369);
return { start, end };
}
interface WeeklyStats { interface WeeklyStats {
pendingDelivery: number; pendingDelivery: number;
weeklyNew: number; weeklyNew: number;
@@ -1122,6 +1175,141 @@ app.get('/weekly-detail', async (c) => {
return c.json(masked); return c.json(masked);
}); });
// GET /api/vehicles/flow-stats?start=YYYY-MM-DD&end=YYYY-MM-DD
// 资产流转日报:按提交时间(create_time)统计交车、还车、替换车,并返回可点击明细。
app.get('/flow-stats', async (c) => {
const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end'));
const allowedVehicles = await getVehiclesForUser(c);
const allowedTruckIds = new Set(allowedVehicles.map((v) => String(v.id)));
const sql = `
SELECT *
FROM (
SELECT
CONCAT('delivered-', dv.id) AS id,
'delivered' AS type,
'交车' AS type_label,
DATE_FORMAT(dv.create_time, '%Y-%m-%d') AS stat_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id,
dv.plate_number,
DATE_FORMAT(dv.delivery_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(dv.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM delivery_vehicle dv
LEFT JOIN delivery_task_subject dts
ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = dv.contract_id
AND c.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL
AND dv.create_time IS NOT NULL
AND dv.delivery_status IN (2,3,5)
AND dv.create_time >= ?
AND dv.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('returned-', r.id) AS id,
'returned' AS type,
'还车' AS type_label,
DATE_FORMAT(r.create_time, '%Y-%m-%d') AS stat_date,
CAST(r.vehicle_id AS CHAR) AS truck_id,
r.plate_number,
DATE_FORMAT(r.arrival_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(r.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM return_vehicle_task r
LEFT JOIN delivery_task_subject dts
ON dts.id = r.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = r.contract_id
AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL
AND r.create_time IS NOT NULL
AND r.status IN (2,3,5)
AND r.create_time >= ?
AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('replaced-', vr.id) AS id,
'replaced' AS type,
'替换' AS type_label,
DATE_FORMAT(vr.create_time, '%Y-%m-%d') AS stat_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id,
vr.new_vehicle_plate AS plate_number,
DATE_FORMAT(vr.replace_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(vr.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM vehicle_replacement vr
LEFT JOIN delivery_task_subject dts
ON dts.id = vr.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.id = vr.contract_id
AND c.del_flag = '0'
WHERE vr.del_flag = '0'
AND vr.new_vehicle_id IS NOT NULL
AND vr.create_time IS NOT NULL
AND vr.status = 20
AND vr.create_time >= ?
AND vr.create_time < DATE_ADD(?, INTERVAL 1 DAY)
) flow
ORDER BY flow.submit_time DESC
`;
const [rows] = await pool.query<any[]>(sql, [start, end, start, end, start, end]);
const details = (rows as FlowDetailRow[])
.filter((row) => allowedTruckIds.has(String(row.truck_id)))
.map((row) => ({
id: row.id,
type: row.type,
typeLabel: row.type_label,
date: row.stat_date,
truckId: row.truck_id,
plateNumber: row.plate_number,
eventTime: row.event_time,
submitTime: row.submit_time,
department: row.department || '',
manager: row.manager || '',
customerName: maskCustomerName(row.customer_name),
}));
const dailyMap = new Map<string, { date: string; delivered: number; returned: number; replaced: number; total: number }>();
for (const date of listDateRange(start, end)) dailyMap.set(date, { date, delivered: 0, returned: 0, replaced: 0, total: 0 });
for (const item of details) {
const stat = dailyMap.get(item.date);
if (!stat) continue;
stat[item.type] += 1;
stat.total += 1;
}
const daily = Array.from(dailyMap.values());
const totals = daily.reduce(
(acc, item) => ({
delivered: acc.delivered + item.delivered,
returned: acc.returned + item.returned,
replaced: acc.replaced + item.replaced,
total: acc.total + item.total,
}),
{ delivered: 0, returned: 0, replaced: 0, total: 0 },
);
return c.json({ start, end, daily, totals, details });
});
// GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉 // GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉
app.get('/subjects', async (c) => { app.get('/subjects', async (c) => {
const all = await getVehicles(); const all = await getVehicles();