From b0caa5afcb490a53c670cfec0063d2b593d7ebd8 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 27 Jun 2026 21:59:33 +0800 Subject: [PATCH] feat: polish BI dashboards and bump version --- package-lock.json | 4 +- package.json | 2 +- src/App.tsx | 55 ++- src/auth/UnauthorizedPage.tsx | 31 +- src/components/Shell.tsx | 100 +++-- src/components/ui/surface.tsx | 287 ++++++++++++ src/index.css | 42 ++ src/lib/cn.ts | 3 + src/modules/admin/FeedbackAdminPage.tsx | 52 +-- src/modules/assets/AssetsModule.tsx | 458 +++++++++++++++++--- src/modules/assets/api.ts | 47 ++ src/modules/ele/EleImportPage.tsx | 38 +- src/modules/energy/ElectricDaily.tsx | 29 +- src/modules/energy/ElectricModule.tsx | 22 +- src/modules/energy/ElectricOverview.tsx | 67 ++- src/modules/energy/EtcModule.tsx | 16 +- src/modules/energy/HydrogenDaily.tsx | 81 +++- src/modules/energy/HydrogenModule.tsx | 22 +- src/modules/energy/HydrogenOverview.tsx | 63 ++- src/modules/energy/SubTabs.tsx | 23 +- src/modules/mileage/DailyReportView.tsx | 209 ++++++++- src/modules/mileage/MileageModule.tsx | 86 ++-- src/modules/mileage/MonitoringView.tsx | 244 +++++++++-- src/modules/mileage/StatisticsView.tsx | 77 +++- src/modules/mileage/api.ts | 136 ++++++ src/modules/mileage/types.ts | 3 + src/modules/mileage/xlsx-export.ts | 73 +++- src/modules/scheduling/SchedulingModule.tsx | 170 ++------ src/server/routes/mileage/cache.ts | 158 ++++++- src/server/routes/mileage/monitoring.ts | 55 ++- src/server/routes/mileage/types.ts | 4 + src/server/routes/mileage/vehicle-info.ts | 1 + src/server/routes/vehicles.ts | 188 ++++++++ 33 files changed, 2363 insertions(+), 483 deletions(-) create mode 100644 src/components/ui/surface.tsx create mode 100644 src/lib/cn.ts diff --git a/package-lock.json b/package-lock.json index f72ab8b..a8f1b09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ln-bi", - "version": "1.1.5", + "version": "1.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ln-bi", - "version": "1.1.5", + "version": "1.1.6", "dependencies": { "@hono/node-server": "^1.13.0", "@types/jsonwebtoken": "^9.0.10", diff --git a/package.json b/package.json index 7eb9280..85b75a2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ln-bi", "private": true, - "version": "1.1.5", + "version": "1.1.6", "type": "module", "scripts": { "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", diff --git a/src/App.tsx b/src/App.tsx index 9b7527e..911e947 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 { Shell, type ModuleConfig } from "./components/Shell"; -import AssetsModule from "./modules/assets/AssetsModule"; -import MileageModule from "./modules/mileage/MileageModule"; -import SchedulingModule from "./modules/scheduling/SchedulingModule"; -import HydrogenModule from "./modules/energy/HydrogenModule"; -import ElectricModule from "./modules/energy/ElectricModule"; -import EtcModule from "./modules/energy/EtcModule"; -import EleImportPage from "./modules/ele/EleImportPage"; -import FeedbackAdminPage from "./modules/admin/FeedbackAdminPage"; import AuthProvider from "./auth/AuthProvider"; import { useAuth } from "./auth/useAuth"; import UnauthorizedPage from "./auth/UnauthorizedPage"; import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles"; +import { 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 = { id: "assets", @@ -143,10 +145,21 @@ function AuthGate() { if (isLoading) { return ( -
-
-
-

正在验证身份...

+
+
+
+
LN BI ACCESS
+
正在进入羚牛氢能 BI
+
校验登录态、权限与本地开发环境配置
+
+ +
+ {[0, 1, 2, 3].map(item => )} +
+
+ +
+
); @@ -157,8 +170,20 @@ function AuthGate() { } // 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现 - if (routeKey === "ele/import") return ; - if (routeKey === "admin/feedback") return ; + if (routeKey === "ele/import") { + return ( + }> + + + ); + } + if (routeKey === "admin/feedback") { + return ( + }> + + + ); + } // /energy 整组按能源权限控制 if (pathSet === "energy" && !canAccessEnergy(user?.roles)) { diff --git a/src/auth/UnauthorizedPage.tsx b/src/auth/UnauthorizedPage.tsx index 1b11fa0..b4a1801 100644 --- a/src/auth/UnauthorizedPage.tsx +++ b/src/auth/UnauthorizedPage.tsx @@ -1,19 +1,24 @@ import { ShieldX, Monitor, Smartphone } from 'lucide-react'; +import { PageFrame, SurfaceCard } from '../components/ui/surface'; export default function UnauthorizedPage({ message }: { message?: string }) { return ( -
-
-
- + + +
+
+ +
+

请通过以下方式进入

-

未授权访问

-

- {message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'} -

- -
-

请通过以下方式进入

+
@@ -31,7 +36,7 @@ export default function UnauthorizedPage({ message }: { message?: string }) {
-
-
+ + ); } diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 863be5f..659ba9d 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -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 { DemoModeProvider } from './Blur'; import FeedbackFab from './FeedbackFab'; +import { cn } from '../lib/cn'; +import { LoadingState } from './ui/surface'; export interface ModuleConfig { id: string; label: string; icon: ComponentType<{ size?: number; className?: string }>; - component: ComponentType; + component: ElementType; } /** hash 一级段(`#` 或 `#/` 都只取 id) */ @@ -58,6 +62,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) { const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component; const { user } = useAuth(); + const activeLabel = modules.find((m) => m.id === activeModule)?.label ?? '业务看板'; const watermarkText = useMemo(() => { const name = user?.userName || '未登录'; const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-'); @@ -66,7 +71,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) { return ( -
+
{/* 全局水印 */}
{/* Web 侧边栏 (md 及以上) */} -
}> + {ActiveComponent && } + {/* 移动端底部导航 (md 以下) */} -
diff --git a/src/components/ui/surface.tsx b/src/components/ui/surface.tsx new file mode 100644 index 0000000..54ea15b --- /dev/null +++ b/src/components/ui/surface.tsx @@ -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 ( +
+
+ +
+ {compactInfo ? ( + <> +
+ {Icon ? ( + + + + ) : null} +
+
+ {eyebrow ? {eyebrow} : null} + {meta ? {meta} : null} +
+

{title}

+
+ {actions ?
{actions}
: null} + {subtitle ? ( + + ) : null} +
+ + {infoOpen && subtitle ? ( + +
+ {subtitle} +
+
+ ) : null} +
+ + ) : ( +
+
+
+ {Icon ? ( + + + + ) : null} + {eyebrow ? {eyebrow} : null} + {meta ? {meta} : null} +
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ {actions ?
{actions}
: null} +
+ )} + + {children} +
+
+ ); +} + +export function SurfaceCard({ + title, + subtitle, + actions, + children, + className, +}: { + title?: string; + subtitle?: string; + actions?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+ {(title || subtitle || actions) && ( +
+
+ {title ?

{title}

: null} + {subtitle ?

{subtitle}

: null} +
+ {actions ?
{actions}
: null} +
+ )} + {children} +
+ ); +} + +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 ( +
+
+
+
{label}
+
+ {value} + {unit ? {unit} : null} +
+
+ {Icon ? ( + + + + ) : null} +
+ {helper ?
{helper}
: null} +
+ ); +} + +export function SegmentedNav({ + tabs, + active, + onChange, + className, +}: { + tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[]; + active: T; + onChange: (id: T) => void; + className?: string; +}) { + return ( +
+
+ {tabs.map(({ id, label, icon: Icon }) => { + const isActive = active === id; + return ( + + ); + })} +
+
+ ); +} + +export function SkeletonBlock({ className }: { className?: string }) { + return
; +} + +export function LoadingState({ label = '数据加载中' }: { label?: string }) { + return ( +
+ +
{label}
+
+ ); +} + +export function EmptyState({ + title = '暂无数据', + description = '换个筛选条件或稍后再试', +}: { + title?: string; + description?: string; +}) { + return ( +
+ +
{title}
+
{description}
+
+ ); +} + +export function ErrorState({ message }: { message: string }) { + return ( +
+
+ +
+
加载失败
+
{message}
+
+
+
+ ); +} + +export function FadeIn({ children, className }: { children: ReactNode; className?: string }) { + return ( + + {children} + + ); +} diff --git a/src/index.css b/src/index.css index 8334740..187fe69 100644 --- a/src/index.css +++ b/src/index.css @@ -1,8 +1,15 @@ @import "tailwindcss"; +:root { + --app-bg: #f4f7fb; + --panel-bg: rgba(255, 255, 255, 0.9); + --hairline: rgba(148, 163, 184, 0.18); +} + html, body { overscroll-behavior: none; -webkit-overflow-scrolling: touch; + background: var(--app-bg); } html { @@ -20,6 +27,41 @@ body { to { transform: translateX(-50%); } } +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +@keyframes floatUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + @utility animate-marquee { animation: marquee 30s linear infinite; } + +@utility no-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { display: none; } +} + +.shimmer { + position: relative; +} + +.shimmer::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.72), transparent); + animation: shimmer 1.4s infinite; +} + +.enterprise-grid-bg { + background: + radial-gradient(circle at 16% 0%, rgba(59, 130, 246, 0.08), transparent 28%), + radial-gradient(circle at 90% 12%, rgba(20, 184, 166, 0.08), transparent 26%), + linear-gradient(180deg, #f8fbff 0%, var(--app-bg) 42%, #f7f9fc 100%); +} diff --git a/src/lib/cn.ts b/src/lib/cn.ts new file mode 100644 index 0000000..6db43d2 --- /dev/null +++ b/src/lib/cn.ts @@ -0,0 +1,3 @@ +export function cn(...parts: Array) { + return parts.filter(Boolean).join(' '); +} diff --git a/src/modules/admin/FeedbackAdminPage.tsx b/src/modules/admin/FeedbackAdminPage.tsx index e119e21..28b7b65 100644 --- a/src/modules/admin/FeedbackAdminPage.tsx +++ b/src/modules/admin/FeedbackAdminPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react'; import { fetchJson } from '../../auth/api-client'; +import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface'; interface FeedbackItem { id: number; @@ -20,10 +21,10 @@ interface FeedbackItem { } const TYPE_LABEL: Record = { - dimension: '💡 新维度', - bug: '🐛 Bug', - ux: '🎨 体验', - other: '📝 其他', + dimension: '新维度', + bug: 'Bug', + ux: '体验', + other: '其他', }; const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [ @@ -126,36 +127,36 @@ export default function FeedbackAdminPage() { }, {}); return ( -
-
-
-
+ -
- -
-
-

用户反馈管理

-

查看、回复、跟进用户提交的建议

-
-
- -
+
+ )} + maxWidth="max-w-5xl" + > {/* 状态过滤 */} -
+ +
))}
+
{error && (
@@ -192,9 +194,9 @@ export default function FeedbackAdminPage() { {/* 列表 */}
{loading && items.length === 0 ? ( -
加载中…
+ ) : items.length === 0 ? ( -
还没有反馈
+ ) : items.map(it => { const shots = parseScreenshots(it.screenshots); const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status); @@ -218,7 +220,7 @@ export default function FeedbackAdminPage() { {(shots.length > 0 || it.contact) && (
{shots.length > 0 && {shots.length} 张} - {it.contact && 📞 {it.contact}} + {it.contact && 联系 {it.contact}}
)} {it.reply_content && ( @@ -236,8 +238,6 @@ export default function FeedbackAdminPage() { ); })}
-
- {/* 详情 / 回复弹窗 */} {active && ( @@ -336,6 +336,6 @@ export default function FeedbackAdminPage() { )} -
+ ); } diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index cb1c101..28f7fe7 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -14,8 +14,12 @@ import { Filter, ArrowRightLeft, MapPin, + Download, + CalendarDays, + X, } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; +import * as XLSX from 'xlsx'; import { BarChart, Bar, @@ -30,12 +34,13 @@ import { LabelList, } from 'recharts'; import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types'; -import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, type SubjectOption } from './api'; -import type { WeeklyDetailItem } from './api'; +import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api'; +import type { FlowDetailItem, FlowStatsResponse, FlowType, WeeklyDetailItem } from './api'; import { SearchSelect } from '../../components/SearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect'; import Blur from '../../components/Blur'; import RotatingFooterHint from '../../components/RotatingFooterHint'; +import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface'; // --- Constants --- @@ -92,6 +97,33 @@ function formatLocalDateTime(date: Date): string { 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 = { + delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' }, + returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' }, + replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' }, +}; + export default function AssetsModule() { const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview'); const [tabReady, setTabReady] = useState(true); @@ -140,6 +172,11 @@ export default function AssetsModule() { const [error, setError] = useState(null); const [lastUpdate, setLastUpdate] = useState(() => formatLocalDateTime(new Date())); const [modalLoading, setModalLoading] = useState(false); + const [flowRange, setFlowRange] = useState(() => getWeeklyFlowRange()); + const [flowStats, setFlowStats] = useState(null); + const [flowLoading, setFlowLoading] = useState(false); + const [flowDailyExpanded, setFlowDailyExpanded] = useState(false); + const [selectedFlow, setSelectedFlow] = useState<{ date: string; type: FlowType } | null>(null); // Dept/Region/Customer data const [deptData, setDeptData] = useState([]); @@ -222,6 +259,24 @@ export default function AssetsModule() { return () => clearInterval(interval); }, [loadData]); + useEffect(() => { + let cancelled = false; + setFlowLoading(true); + fetchFlowStats({ start: flowRange.start, end: flowRange.end, subject: selectedSubject }) + .then((data) => { + if (!cancelled) setFlowStats(data); + }) + .catch(() => { + if (!cancelled) setFlowStats(null); + }) + .finally(() => { + if (!cancelled) setFlowLoading(false); + }); + return () => { + cancelled = true; + }; + }, [flowRange.start, flowRange.end, selectedSubject]); + // 归属公司列表(仅首次加载,公司集合相对稳定) useEffect(() => { fetchSubjects().then(setSubjects).catch(() => setSubjects([])); @@ -521,6 +576,40 @@ export default function AssetsModule() { return mp; }), [modalWeeklyDetail, modalFilters.plateNumber]); + const selectedFlowDetails = useMemo(() => { + if (!flowStats || !selectedFlow) return []; + return flowStats.details.filter((item) => item.date === selectedFlow.date && item.type === selectedFlow.type); + }, [flowStats, selectedFlow]); + + const exportFlowDetails = useCallback((rows?: FlowDetailItem[], title = '资产流转明细') => { + const source = rows ?? flowStats?.details ?? []; + if (source.length === 0) return; + const table = source.map((item) => ({ + 日期: item.date, + 类型: item.typeLabel, + 车牌: item.plateNumber, + 流转时间: item.eventTime || '', + 提交时间: item.submitTime || '', + 部门: item.department || '', + 业务负责人: item.manager || '', + 客户: item.customerName || '', + })); + const ws = XLSX.utils.json_to_sheet(table); + ws['!cols'] = [ + { wch: 14 }, + { wch: 8 }, + { wch: 14 }, + { wch: 20 }, + { wch: 20 }, + { wch: 16 }, + { wch: 14 }, + { wch: 24 }, + ]; + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, '明细'); + XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`); + }, [flowRange.end, flowRange.start, flowStats]); + const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]); useEffect(() => { if (customerChartView === 'province') { @@ -541,35 +630,57 @@ export default function AssetsModule() { if (loading && !summary) { return ( -
-
- - 正在加载数据... -
-
+ + +
+ {[0, 1, 2, 3].map(item => ( + + ))} +
+
+ +
+
+
); } if (error && !summary) { return ( -
-
-
加载失败
-
{error}
- -
-
+ + +
+ + +
+
+
); } 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 ( -
+
{/* Compact Header Bar */} -
+
{/* Title row */}

羚牛氢能-资产BI

@@ -753,11 +864,11 @@ export default function AssetsModule() { {tabReady && activeTab === 'overview' && ( <> {/* Header Summary - Ultra Compact */} -
+
{/* Total Assets */} -
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}> -
+
@@ -767,9 +878,9 @@ export default function AssetsModule() {
{/* Operating */} -
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}> -
+
@@ -782,9 +893,9 @@ export default function AssetsModule() {
{/* Inventory */} -
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}> -
+
@@ -797,9 +908,9 @@ export default function AssetsModule() {
{/* Pending */} -
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}> -
+
@@ -808,41 +919,161 @@ export default function AssetsModule() {
- {/* Dynamics */} -
-
-
本周动态
-
上周六-本周五
-
-
-
- {SUMMARY.weeklyNew} - 新增 +
+ +
+
+
运营概览
+
+
+
运营率
+
{operatingRate.toFixed(1)}%
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Delivered', source: 'asset', title: '本周交车' })}> - {SUMMARY.weeklyDelivered} - 交车 +
+
库存率
+
{inventoryRate.toFixed(1)}%
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Returned', source: 'asset', title: '本周还车' })}> - {SUMMARY.weeklyReturned} - 还车 -
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Replaced', source: 'asset', title: '本周替换' })}> - {SUMMARY.weeklyReplaced} - 替换 +
+
待交率
+
{pendingRate.toFixed(1)}%
+
+
+
+
+
+ + 交还车统计 + {flowLoading && } +
+
默认上周六-本周五 · 提交时间口径
+
+
+ +
+
+
+ + +
+
+
+
+
{flowStats?.totals.total ?? 0}
+
合计
+
+ {(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => ( +
+
+ {flowStats?.totals[type] ?? 0} +
+
{FLOW_META[type].label}
+
+ ))} +
+
+ + + {flowDailyExpanded && ( + +
+ {flowLoading && ( +
正在加载交还车统计...
+ )} + {!flowLoading && flowStats?.daily.map((day) => ( +
+
+
{day.date}
+
提交时间
+
+
+
+
{day.total}
+
合计
+
+ {(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => { + const count = day[type]; + return ( + + ); + })} +
+
+ ))} + {!flowLoading && !flowStats?.daily.length && ( +
暂无交换车数据
+ )} +
+
+ )} +
+
+
+ {/* Asset Summary Table */} -
+

资产数据实时汇总

@@ -2647,6 +2878,123 @@ export default function AssetsModule() { )} + {/* Flow Detail Modal */} + + {selectedFlow && ( +
+ +
+
+
+
提交时间口径
+

+ {selectedFlow.date} · {FLOW_META[selectedFlow.type].label}明细 +

+
+ 共 {selectedFlowDetails.length} 条,包含车牌、流转时间、提交时间、部门、负责人和客户 +
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + {selectedFlowDetails.map((item) => ( + + + + + + + + + ))} + +
车牌{FLOW_META[selectedFlow.type].label}时间提交时间部门业务负责人客户
{item.plateNumber}{item.eventTime || '-'}{item.submitTime || '-'}{item.department || '-'}{item.manager || '-'}{item.customerName || '-'}
+
+ +
+ {selectedFlowDetails.map((item) => ( +
+
+
+
{item.plateNumber}
+
+ {item.typeLabel} +
+
+
+
提交
+
{item.submitTime?.slice(5, 16) || '-'}
+
+
+
+
+
{item.typeLabel}时间
+
{item.eventTime || '-'}
+
+
+
负责人
+
{item.manager || '-'}
+
+
+
部门
+
{item.department || '-'}
+
+
+
客户
+
{item.customerName || '-'}
+
+
+
+ ))} +
+ + {selectedFlowDetails.length === 0 && ( +
当前日期暂无明细
+ )} +
+
+
+ )} +
+ {/* Vehicle Detail Modal */} {showPlateNumbers && ( diff --git a/src/modules/assets/api.ts b/src/modules/assets/api.ts index 092b291..d04d63a 100644 --- a/src/modules/assets/api.ts +++ b/src/modules/assets/api.ts @@ -78,6 +78,43 @@ export interface WeeklyDetailItem { customer_name: string | null; } +export type FlowType = 'delivered' | 'returned' | 'replaced'; + +export interface FlowDailyPoint { + date: string; + delivered: number; + returned: number; + replaced: number; + total: number; +} + +export interface FlowDetailItem { + id: string; + type: FlowType; + typeLabel: string; + date: string; + truckId: string; + plateNumber: string; + eventTime: string | null; + submitTime: string | null; + department: string; + manager: string; + customerName: string | null; +} + +export interface FlowStatsResponse { + start: string; + end: string; + daily: FlowDailyPoint[]; + totals: { + delivered: number; + returned: number; + replaced: number; + total: number; + }; + details: FlowDetailItem[]; +} + export async function fetchDeptStats(subject?: string | null): Promise { return fetchJson(withSubject(`${BASE}/dept-stats`, subject)); } @@ -125,3 +162,13 @@ export async function fetchWeeklyDetail( if (filters?.source) params.set('source', filters.source); return fetchJson(`${BASE}/weekly-detail?${params.toString()}`); } + +export async function fetchFlowStats(params: { + start: string; + end: string; + subject?: string | null; +}): Promise { + const query = new URLSearchParams({ start: params.start, end: params.end }); + if (params.subject) query.set('subject', params.subject); + return fetchJson(`${BASE}/flow-stats?${query.toString()}`); +} diff --git a/src/modules/ele/EleImportPage.tsx b/src/modules/ele/EleImportPage.tsx index 5d6bd00..8d86f9a 100644 --- a/src/modules/ele/EleImportPage.tsx +++ b/src/modules/ele/EleImportPage.tsx @@ -8,6 +8,7 @@ import { fetchJson } from '../../auth/api-client'; import { useAuth } from '../../auth/useAuth'; import RotatingFooterHint from '../../components/RotatingFooterHint'; import FeedbackFab from '../../components/FeedbackFab'; +import { PageFrame } from '../../components/ui/surface'; function getJwt(): string | null { return sessionStorage.getItem('bi_jwt'); @@ -135,30 +136,34 @@ export default function EleImportPage() { const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0); return ( -
-
-
-
+ -
- -
-
-

充电记录导入

-

每日上传 xlsx · 订单编号去重 · 系统车辆自动匹配

-
-
- {user?.userName || ''} -
+ +
+ )} + > {/* 上传区 */}
-
-
+ ); } diff --git a/src/modules/energy/ElectricDaily.tsx b/src/modules/energy/ElectricDaily.tsx index 8cafa95..9dd1918 100644 --- a/src/modules/energy/ElectricDaily.tsx +++ b/src/modules/energy/ElectricDaily.tsx @@ -1,10 +1,11 @@ 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 TrendBadge from './TrendBadge'; import { fetchElectricMonthly } from './api'; import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types'; import RotatingFooterHint from '../../components/RotatingFooterHint'; +import { EmptyState, ErrorState, LoadingState, MetricTile } from '../../components/ui/surface'; const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [ { 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 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; return (
+
+ + + + 0 ? 'rose' : 'slate'} /> +
+ {/* 日期速选 */}
{QUICK_PICK_OPTIONS.map(opt => ( @@ -106,11 +127,11 @@ export default function ElectricDaily() { 环比
{error ? ( -
加载失败:{error}
+
) : months === null ? ( -
加载中…
+
) : months.length === 0 ? ( -
暂无数据
+
) : months.map(m => { const open = openMonths.has(m.month); return ( diff --git a/src/modules/energy/ElectricModule.tsx b/src/modules/energy/ElectricModule.tsx index d629473..4f11699 100644 --- a/src/modules/energy/ElectricModule.tsx +++ b/src/modules/energy/ElectricModule.tsx @@ -1,7 +1,9 @@ import { LayoutDashboard, CalendarDays } from 'lucide-react'; +import { AnimatePresence } from 'motion/react'; import ElectricView, { type ElectricSubTab } from './ElectricView'; import SubTabs from './SubTabs'; import { useHashSubTab } from './useHashSubTab'; +import { FadeIn, PageFrame } from '../../components/ui/surface'; const SUB_TABS = [ { id: 'daily', label: '每日', icon: CalendarDays }, @@ -13,11 +15,19 @@ const SUB_IDS: readonly ElectricSubTab[] = ['daily', 'overview']; export default function ElectricModule() { const [sub, setSub] = useHashSubTab('electric', SUB_IDS); return ( -
-
- - -
-
+ + + + + + + + ); } diff --git a/src/modules/energy/ElectricOverview.tsx b/src/modules/energy/ElectricOverview.tsx index 2ff711b..db9609a 100644 --- a/src/modules/energy/ElectricOverview.tsx +++ b/src/modules/energy/ElectricOverview.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; -import { Wallet, CalendarClock } from 'lucide-react'; -import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts'; +import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react'; +import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts'; import { fetchElectricOverview, type ElectricOverviewResponse } from './api'; import RotatingFooterHint from '../../components/RotatingFooterHint'; +import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; function fmtYuan(yuan: number) { return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; @@ -24,10 +25,10 @@ export default function ElectricOverview() { }, []); if (error) { - return
加载失败:{error}
; + return ; } if (!data) { - return
加载中…
; + return ; } const k = data.kpi; const trendData = data.trend; @@ -37,6 +38,12 @@ export default function ElectricOverview() { const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth ? `${trendMonthLabel} 每日充电` : '本月每日充电'; + const activeDays = trendData.filter(item => item.kwh > 0).length; + const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0; + const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0; + const peakDay = trendData.reduce((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 (
@@ -44,28 +51,36 @@ export default function ElectricOverview() { 龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
{/* 横向 mini KPI 头 */} -
-
-
- 累计 -
-
{fmtYuan(k.totalFee)}
-
{fmtKwh(k.totalKwh)}
-
-
-
- 本月 -
-
{fmtYuan(k.monthFee)}
-
{fmtKwh(k.monthKwh)}
-
+
+ + + + = 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} /> +
+ +
+ +
有效充电日
+
{activeDays}
+
日均 {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度
+
+ +
峰值日
+
{peakDay ? peakDay.date.slice(5) : '—'}
+
{peakDay ? `${peakDay.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度 · ${fmtYuan(peakDay.fee)}` : '暂无数据'}
+
+ +
月度占比
+
{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%
+
本月费用 / 累计费用
+
{/* 本月每日充电柱图 */} -
+
{chartTitle} - 单位 元 + 时间单位:日 · 单位 元 · 均线为日均费用
@@ -85,6 +100,14 @@ export default function ElectricOverview() { contentStyle={{ borderRadius: 12, fontSize: 12 }} cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }} /> + {avgDailyFee > 0 && ( + + )} {trendData.map((_, i) => ( @@ -98,7 +121,7 @@ export default function ElectricOverview() { -
+
); diff --git a/src/modules/energy/EtcModule.tsx b/src/modules/energy/EtcModule.tsx index 626bf92..977e35b 100644 --- a/src/modules/energy/EtcModule.tsx +++ b/src/modules/energy/EtcModule.tsx @@ -1,11 +1,17 @@ +import { Receipt } from 'lucide-react'; import ETCView from './ETCView'; +import { PageFrame } from '../../components/ui/surface'; export default function EtcModule() { return ( -
-
- -
-
+ + + ); } diff --git a/src/modules/energy/HydrogenDaily.tsx b/src/modules/energy/HydrogenDaily.tsx index 5b381ca..75d3e2d 100644 --- a/src/modules/energy/HydrogenDaily.tsx +++ b/src/modules/energy/HydrogenDaily.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; -import { ChevronRight, Plug } from 'lucide-react'; +import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; -import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts'; +import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts'; import TrendBadge from './TrendBadge'; import { fetchHydrogenDaily } from './api'; import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types'; import RotatingFooterHint from '../../components/RotatingFooterHint'; +import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [ { id: 'thisWeek', label: '本周' }, @@ -32,6 +33,19 @@ export default function HydrogenDaily() { // 柱图:按日期升序,用于"从左到右时间流" const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]); const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0); + const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length; + const stationCount = useMemo(() => { + const names = new Set(); + (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((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null); + const lowDay = trendData + .filter(item => item.totalKg > 0) + .reduce((low, item) => (!low || item.totalKg < low.totalKg ? item : low), null); + const zeroDays = (rows ?? []).filter(r => r.totalKg === 0).length; const toggle = (date: string) => setExpanded(prev => { const next = new Set(prev); @@ -41,6 +55,13 @@ export default function HydrogenDaily() { return (
+
+ + + + +
+ {/* 日期速选 */}
{QUICK_PICK_OPTIONS.map(opt => ( @@ -96,13 +117,34 @@ export default function HydrogenDaily() { {/* 时段加氢量柱图(外部车辆无数据时不渲染) */} {!(customer === 'external' && totalKg === 0) && trendData.length > 0 && ( -
-
+ +
每日加氢量 - 单位 Kg + 时间单位:日 · 单位 Kg
- - +
+
+
峰值日
+
+ {peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'} +
+
+
+
低谷日
+
+ {lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'} +
+
+
+
零数据日
+
0 ? 'text-amber-600' : 'text-emerald-600'}`}> + {zeroDays} 天 +
+
+
+
+ + v.slice(5)} @@ -112,13 +154,27 @@ export default function HydrogenDaily() { interval="preserveStartEnd" minTickGap={8} /> - + v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`} + /> [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']} labelFormatter={(d) => `日期 ${d}`} contentStyle={{ borderRadius: 12, fontSize: 12 }} cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }} /> + {avgKg > 0 && ( + + )} {trendData.map((_, i) => ( @@ -132,7 +188,8 @@ export default function HydrogenDaily() { -
+
+ )} {/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */} @@ -154,11 +211,11 @@ export default function HydrogenDaily() {
{/* 主行 + 子行 */} {error ? ( -
加载失败:{error}
+
) : rows === null ? ( -
加载中…
+
) : rows.length === 0 ? ( -
暂无数据
+
) : rows.map(r => { const open = expanded.has(r.date); const isAbnormal = Math.abs(r.chainPct) >= 0.3; diff --git a/src/modules/energy/HydrogenModule.tsx b/src/modules/energy/HydrogenModule.tsx index bb3cdbe..47f6d90 100644 --- a/src/modules/energy/HydrogenModule.tsx +++ b/src/modules/energy/HydrogenModule.tsx @@ -1,7 +1,9 @@ import { LayoutDashboard, CalendarDays } from 'lucide-react'; +import { AnimatePresence } from 'motion/react'; import HydrogenView, { type HydrogenSubTab } from './HydrogenView'; import SubTabs from './SubTabs'; import { useHashSubTab } from './useHashSubTab'; +import { FadeIn, PageFrame } from '../../components/ui/surface'; const SUB_TABS = [ { id: 'daily', label: '每日', icon: CalendarDays }, @@ -13,11 +15,19 @@ const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview']; export default function HydrogenModule() { const [sub, setSub] = useHashSubTab('hydrogen', SUB_IDS); return ( -
-
- - -
-
+ + + + + + + + ); } diff --git a/src/modules/energy/HydrogenOverview.tsx b/src/modules/energy/HydrogenOverview.tsx index 14ad74b..200b44b 100644 --- a/src/modules/energy/HydrogenOverview.tsx +++ b/src/modules/energy/HydrogenOverview.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend, } from 'recharts'; -import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react'; +import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2 } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api'; import RotatingFooterHint from '../../components/RotatingFooterHint'; @@ -147,6 +147,14 @@ export default function HydrogenOverview() { const yearRevenueFmt = fmtYuan(k.yearRevenue); const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600'; + const monthAvgKg = monthly.length > 0 ? monthly.reduce((sum, m) => sum + m.kg, 0) / monthly.length : 0; + const bestMonth = monthly.reduce((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 => ({ @@ -247,6 +255,59 @@ export default function HydrogenOverview() { />
+
+
+
+
+
月度动能
+
+ {monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`} +
+
+ + + +
+
+ {latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'} + {bestMonth ? ` · 峰值 ${bestMonth.month}` : ''} + {monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''} +
+
+
+
+
+
站点集中度
+
Top5 {top5Share.toFixed(1)}%
+
+ + + +
+
+ 共 {stations.length} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit} + {top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'} +
+
+
+
+
+
收支健康度
+
= 0 ? 'text-emerald-600' : 'text-rose-600'}`}> + {profitYield.toFixed(1)}% +
+
+ = 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}> + + +
+
+ 时享获利 {yearProfitFmt.value}{yearProfitFmt.unit} · 客户收入 {yearRevenueFmt.value}{yearRevenueFmt.unit} + {profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'} +
+
+
+ {/* 月度趋势:年内每月加氢量 */} {monthly.length > 0 && (
diff --git a/src/modules/energy/SubTabs.tsx b/src/modules/energy/SubTabs.tsx index c323171..ff10646 100644 --- a/src/modules/energy/SubTabs.tsx +++ b/src/modules/energy/SubTabs.tsx @@ -1,4 +1,5 @@ import type { ComponentType } from 'react'; +import { SegmentedNav } from '../../components/ui/surface'; interface SubTab { id: T; @@ -14,26 +15,8 @@ interface Props { export default function SubTabs({ tabs, active, onChange }: Props) { return ( -
-
-
- {tabs.map(({ id, label, icon: Icon }) => { - const isActive = active === id; - return ( - - ); - })} -
-
+
+
); } diff --git a/src/modules/mileage/DailyReportView.tsx b/src/modules/mileage/DailyReportView.tsx index 38241a0..d160ba5 100644 --- a/src/modules/mileage/DailyReportView.tsx +++ b/src/modules/mileage/DailyReportView.tsx @@ -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() { + const [data, setData] = useState(null); + const [error, setError] = useState(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 ; + if (error) return ; + if (!data) return ; + + 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 ( -
-
- -

每日汇报

-

开发中...

+
+ +
+
+
+
数据截止 {data.reportDate} 23:59 · 数据库口径
+ + + DB LIVE + +
+

车辆里程每日汇报

+

+ 基于里程考核目标、车辆日里程视图和车辆归属信息实时聚合,和统计报表/实时监控保持同一数据库口径。 +

+
+
+
+
{fmt(totals.count)}
+
车辆 台
+
+
+
{activeRate.toFixed(1)}%
+
有里程
+
+
+
{totals.zero}
+
零里程
+
+
+
+
+ +
+ + + + = 0 ? '+' : '-'}${fmtKm(Math.abs(dailyGap))}`} + unit="km" + helper={dailyGap >= 0 ? '今日高于日需目标' : '今日低于日需目标'} + tone={dailyGap >= 0 ? 'emerald' : 'rose'} + /> +
+ +
+ +
+ {data.trend.map(item => ( +
+
+
+
+
{item.date}
+
+ ))} +
+ + + +
+ {data.models.map(item => ( +
+
+
+
{item.name}
+
{item.active}/{item.count} 有里程 · 零里程 {item.zero}
+
+
{fmt(item.today, 1)} km
+
+
+
+
+
+ 年度完成 {item.completion.toFixed(1)}% + 日需 {fmt(item.dailyNeed, 1)} km +
+
+ ))} +
+ +
+ +
+ + +
+ +
+ +
+ +
{data.qualifiedCount} 台已达当前年度标准
+
{data.halfQualifiedCount} 台达到 50% 里程线
+
+
+ +
+ +
全量均值 {fmt(totals.count > 0 ? totals.today / totals.count : 0, 1)} km/台
+
有里程车辆均值 {fmt(totals.active > 0 ? totals.today / totals.active : 0, 1)} km/台
+
+
+ +
+ +
缺口 {fmtKm(Math.abs(dailyGap))} km
+
建议关注零里程及低完成率车辆
+
+
); } + +function ReportList({ + title, + subtitle, + rows, + mode, +}: { + title: string; + subtitle: string; + rows: DailyReportVehicle[]; + mode: 'top' | 'risk'; +}) { + return ( + +
+ {rows.length === 0 ? ( +
暂无匹配车辆
+ ) : rows.map(item => ( +
+
+
+ {item.plate} + {item.model} + {item.status} +
+
{item.customer}
+
+
+ {mode === 'top' ? `${fmt(item.today ?? 0, 1)} km` : `${(item.completion ?? 0).toFixed(1)}%`} +
+
+ ))} +
+
+ ); +} diff --git a/src/modules/mileage/MileageModule.tsx b/src/modules/mileage/MileageModule.tsx index f5b1441..51fa9d5 100644 --- a/src/modules/mileage/MileageModule.tsx +++ b/src/modules/mileage/MileageModule.tsx @@ -1,60 +1,50 @@ -import { useState } from 'react'; import { LayoutDashboard, BarChart3, FileText } from 'lucide-react'; -import { motion } from 'motion/react'; +import { AnimatePresence } from 'motion/react'; import MonitoringView from './MonitoringView'; import StatisticsView from './StatisticsView'; import DailyReportView from './DailyReportView'; +import { useHashSubTab } from '../energy/useHashSubTab'; import RotatingFooterHint from '../../components/RotatingFooterHint'; +import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface'; + +type MileageSubTab = 'monitoring' | 'statistics' | 'report'; + +const MILEAGE_TABS = [ + { id: 'monitoring', label: '实时监控', icon: LayoutDashboard }, + { id: 'statistics', label: '统计报表', icon: BarChart3 }, + { id: 'report', label: '每日汇报', icon: FileText }, +] as const satisfies readonly { id: MileageSubTab; label: string; icon: typeof LayoutDashboard }[]; + +const MILEAGE_SUB_IDS: readonly MileageSubTab[] = ['monitoring', 'statistics', 'report']; export default function MileageModule() { - const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring'); + const [activeSubTab, setActiveSubTab] = useHashSubTab('mileage', MILEAGE_SUB_IDS); return ( -
-
- {/* Sub-navigation — sticky */} -
- - - -
- - {activeSubTab === 'monitoring' ? ( - - ) : activeSubTab === 'statistics' ? ( - - ) : ( - - )} - + +
+
-
+ + + + {activeSubTab === 'monitoring' ? ( + + ) : activeSubTab === 'statistics' ? ( + + ) : ( + + )} + + + + ); } diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index bec8190..41041e8 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -3,8 +3,9 @@ import { motion, AnimatePresence } from 'motion/react'; import { Truck, Filter, ChevronDown, Maximize2, Minimize2, RotateCcw, - ArrowUp, ArrowDown, ChevronsUp, Download, Check, + ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays, } from 'lucide-react'; +import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine } from 'recharts'; import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types'; import { fetchMonitoring } from './api'; import Blur from '../../components/Blur'; @@ -18,6 +19,40 @@ const HIGH_MILEAGE_ALERT_TARGETS = new Set([ ]); const HIGH_MILEAGE_ALERT_KM = 800; +function defaultMileageDate(): string { + const now = new Date(); + if (now.getHours() < 5) now.setDate(now.getDate() - 1); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; +} + +function normalizeRangeLabel(start: string, end: string): string { + if (!start && !end) return '最新数据'; + if (start && end && start === end) return start; + return `${start || end} 至 ${end || start}`; +} + +function fmtDate(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +type RangePreset = 'today' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15'; + +function getRangePreset(preset: RangePreset): { start: string; end: string } { + const end = new Date(`${defaultMileageDate()}T00:00:00`); + const start = new Date(end); + if (preset === 'thisWeek') { + const day = start.getDay() || 7; + start.setDate(start.getDate() - day + 1); + } else if (preset === 'thisMonth') { + start.setDate(1); + } else if (preset === 'last7') { + start.setDate(start.getDate() - 6); + } else if (preset === 'last15') { + start.setDate(start.getDate() - 14); + } + return { start: fmtDate(start), end: fmtDate(end) }; +} + const SearchableSelect = ({ options, value, @@ -246,15 +281,14 @@ export default function MonitoringView() { const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' }); const [exporting, setExporting] = useState(false); const [detailVehicle, setDetailVehicle] = useState(null); - const [filterDate, setFilterDate] = useState(() => { - const now = new Date(); - if (now.getHours() < 5) now.setDate(now.getDate() - 1); - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; - }); + const [rangeStart, setRangeStart] = useState(defaultMileageDate); + const [rangeEnd, setRangeEnd] = useState(defaultMileageDate); const [vehicles, setVehicles] = useState([]); const [stats, setStats] = useState({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }); const [filterOptions, setFilterOptions] = useState({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] }); + const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number }[]>([]); + const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() })); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); @@ -265,6 +299,20 @@ export default function MonitoringView() { const departments = filterOptions.departments; const plateNumbers = filterOptions.plates; + const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end); + const isRangeMode = !!effectiveRange.start && !!effectiveRange.end && effectiveRange.start !== effectiveRange.end; + const averageDailyKm = rangeDailyTotals.length > 0 + ? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length + : 0; + const topLoadedVehicle = useMemo( + () => vehicles.reduce((best, vehicle) => (!best || vehicle.dailyKm > best.dailyKm ? vehicle : best), null), + [vehicles], + ); + const applyRangePreset = useCallback((preset: RangePreset) => { + const range = getRangePreset(preset); + setRangeStart(range.start); + setRangeEnd(range.end); + }, []); const isHighMileageAlert = useCallback((v: MonitoringVehicle) => { const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name)) @@ -292,16 +340,19 @@ export default function MonitoringView() { plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, mileageMin: appliedMileageRange.min || undefined, mileageMax: appliedMileageRange.max || undefined, - date: filterDate || undefined, + startDate: rangeStart || undefined, + endDate: rangeEnd || undefined, }).then(d => { setVehicles(d.vehicles); setStats(d.stats); setFilterOptions(d.filters); + setRangeDailyTotals(d.rangeDailyTotals || []); + setEffectiveRange(d.dateRange || { start: rangeStart, end: rangeEnd }); setTotal(d.total); setPage(1); setHasMore(d.page < d.totalPages); }).catch(() => {}).finally(() => setPageLoading(false)); - }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate]); + }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd]); // 加载更多 const loadMore = useCallback(() => { @@ -325,13 +376,14 @@ export default function MonitoringView() { plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, mileageMin: appliedMileageRange.min || undefined, mileageMax: appliedMileageRange.max || undefined, - date: filterDate || undefined, + startDate: rangeStart || undefined, + endDate: rangeEnd || undefined, }).then(d => { setVehicles(prev => [...prev, ...d.vehicles]); setPage(nextPage); setHasMore(nextPage < d.totalPages); }).catch(() => {}).finally(() => setLoadingMore(false)); - }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, 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(() => { @@ -368,15 +420,16 @@ export default function MonitoringView() { plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, mileageMin: appliedMileageRange.min || undefined, mileageMax: appliedMileageRange.max || undefined, - date: filterDate || undefined, + startDate: rangeStart || undefined, + endDate: rangeEnd || undefined, }); - exportMileageXlsx(d.vehicles, { date: filterDate, sortBy }); + exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy }); } catch (err) { console.error('export failed', err); } finally { setExporting(false); } - }, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate]); + }, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd]); // 每分钟自动刷新 useEffect(() => { @@ -445,13 +498,14 @@ export default function MonitoringView() { targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined, region: filterRegion !== 'All' ? filterRegion : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, - date: filterDate || undefined, + startDate: rangeStart || undefined, + endDate: rangeEnd || undefined, }).then(d => { setFullscreenVehicles(d.vehicles); setFullscreenStats(d.stats); setFilterOptions(d.filters); }).catch(() => {}).finally(() => setFullscreenLoading(false)); - }, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, filterDate, fullscreenRefresh]); + }, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh]); // 全屏时禁止背景滚动 useEffect(() => { @@ -512,7 +566,7 @@ export default function MonitoringView() {

全屏监控

- 今日 {Math.round(fullscreenStats.totalToday).toLocaleString()} km + 区间 {Math.round(fullscreenStats.totalToday).toLocaleString()} km | 累计 {Math.round(fullscreenStats.totalAll).toLocaleString()} km | @@ -633,7 +687,7 @@ export default function MonitoringView() { }} >
- 今日里程 + 区间里程 {sortBy === 'today' && ( sortOrder === 'desc' ? : )} @@ -730,7 +784,7 @@ export default function MonitoringView() { onClick={() => setSortBy('today')} className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`} > - 今日 + 区间
+ +
+ {([ + ['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 ( + + ); + })} + + {rangeLabel} + +
{/* Expandable Filter Panel */} @@ -791,15 +874,42 @@ export default function MonitoringView() { className="overflow-hidden" >
- {/* Date */} + {/* Date range */}
- - setFilterDate(e.target.value)} - /> + +
+ setRangeStart(e.target.value)} + /> + + setRangeEnd(e.target.value)} + /> +
+
+ {([ + ['today', '今天'], + ['thisWeek', '本周'], + ['thisMonth', '本月'], + ['last7', '近7天'], + ['last15', '近15天'], + ] as Array<[RangePreset, string]>).map(([preset, label]) => ( + + ))} +
@@ -926,6 +1036,9 @@ export default function MonitoringView() { setFilterRegion('All'); setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' }); + const today = defaultMileageDate(); + setRangeStart(today); + setRangeEnd(today); }} className="text-[10px] font-bold text-slate-400 hover:text-slate-600" > @@ -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.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } }); if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') }); - if (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') }); + if (rangeStart || rangeEnd) tags.push({ + label: `区间: ${normalizeRangeLabel(rangeStart, rangeEnd)}`, + onClear: () => { + const today = defaultMileageDate(); + setRangeStart(today); + setRangeEnd(today); + } + }); if (tags.length === 0) return null; const clearAll = () => { setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All'); setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetNames([]); setFilterRegion('All'); setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' }); - setFilterDate(''); + const today = defaultMileageDate(); + setRangeStart(today); setRangeEnd(today); }; return (
@@ -988,13 +1109,14 @@ export default function MonitoringView() { })()} {/* Sticky header: KPI + 清单标题 */} -
+
-
{sortBy === 'today' ? '今日' : '累计'}总里程
+
{sortBy === 'today' ? (isRangeMode ? '区间' : '当日') : '累计'}总里程
{pageLoading ?
: <>{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()} km}
+
{rangeLabel}
平均单车
@@ -1007,6 +1129,66 @@ export default function MonitoringView() {
+
+ {pageLoading ? ( +
+ ) : isRangeMode ? ( +
+
+
+ + 区间走势 +
+
{rangeDailyTotals.length} 天 · km
+
{rangeLabel}
+
+
+ {rangeDailyTotals.length === 0 ? ( +
暂无趋势
+ ) : ( + + + [`${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 && } + + + + )} +
+
+
日均
+
{Math.round(averageDailyKm).toLocaleString()}
+
km
+
+
+ ) : ( +
+
+
+ + 单日概览 +
+
{rangeLabel} · 单位 km
+
+ 当前列表最高 {topLoadedVehicle ? `${topLoadedVehicle.plate} · ${Math.round(topLoadedVehicle.dailyKm).toLocaleString()} km` : '-'} +
+
+
+
当日总计
+
{Math.round(stats.totalToday).toLocaleString()}
+
+
+
日均单车
+
{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}
+
+
+ )} +
车辆详情清单 {total} 条 @@ -1072,7 +1254,7 @@ export default function MonitoringView() { {!v.isDataSynced && v.totalKm == null && (
)} - +
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} km : 未对接}
diff --git a/src/modules/mileage/StatisticsView.tsx b/src/modules/mileage/StatisticsView.tsx index 1036b6e..cbd3b27 100644 --- a/src/modules/mileage/StatisticsView.tsx +++ b/src/modules/mileage/StatisticsView.tsx @@ -79,6 +79,14 @@ export default function StatisticsView() { const selectedTarget = targets.find(t => t.id === selectedTargetId); const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null; const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0; + const selectedRemaining = selectedAssessment?.remaining ?? 0; + const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0; + const selectedDailyTarget = selectedAssessment?.dailyTarget ?? (selectedDaysLeft > 0 ? selectedRemaining / selectedDaysLeft : 0); + const selectedQualifiedRate = selectedAssessment?.qualifiedRate ?? (selectedTarget?.vehicleCount ? selectedTarget.yearQualifiedCount / selectedTarget.vehicleCount * 100 : 0); + const latestTrend = trendData[trendData.length - 1]; + const previousTrend = trendData[trendData.length - 2]; + const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0; + const pressureLevel = selectedCompletion >= 90 ? '健康' : selectedCompletion >= 70 ? '关注' : '高压'; // Load targets on mount useEffect(() => { @@ -102,6 +110,12 @@ export default function StatisticsView() { // Load trend when selectedTargetId changes useEffect(() => { if (selectedTargetId === null) return; + setExpandedTargetId(selectedTargetId); + if (!targetVehiclesMap[selectedTargetId]) { + fetchTargetVehicles(selectedTargetId).then(vehicles => { + setTargetVehiclesMap(prev => ({ ...prev, [selectedTargetId]: vehicles })); + }).catch(() => {}); + } fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([])); }, [selectedTargetId]); @@ -121,7 +135,10 @@ export default function StatisticsView() { {targets.map(target => (
+ {selectedTarget && ( + +
+
考核压力
+
+ {pressureLevel} +
+
完成率 {fmtPercent(selectedCompletion)}
+
+
+
剩余缺口
+
{fmtKm(Math.max(0, selectedRemaining))}km
+
剩余 {selectedDaysLeft} 天
+
+
+
日均需完成
+
+ {selectedDaysLeft > 0 ? ( + <> + {fmtKm(selectedDailyTarget)}km + + ) : '已到期'} +
+
按当前考核口径
+
+
+
最新日变化
+
= 0 ? 'text-emerald-600' : 'text-rose-600'}`}> + {trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)} +
+
达标率 {fmtPercent(selectedQualifiedRate)}
+
+
+ )} +
{/* Left Side: Trend Chart / Dashboard Sidebar */}
@@ -193,9 +249,8 @@ export default function StatisticsView() { ))}
-
-
- +
+ {chartType === 'bar' ? ( @@ -237,7 +292,6 @@ export default function StatisticsView() { )} -
@@ -245,9 +299,12 @@ export default function StatisticsView() { {/* Right Side: Summary Section */}
-
+
-

车型考核里程汇总

+
+

当前车型考核详情

+

{selectedTarget?.targetName || '请选择车型'}

+
- {targets.map((target, idx) => ( + {(selectedTarget ? [selectedTarget] : []).map((target, idx) => ( (() => { const assessment = getTargetAssessment(target, assessmentYearMap[target.id]); const primaryCompletion = assessment?.completionRate ?? target.avgCompletion; @@ -269,7 +326,7 @@ export default function StatisticsView() { key={idx} className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer" onClick={() => { - setExpandedTargetId(expandedTargetId === target.id ? null : target.id); + setExpandedTargetId(target.id); if (!targetVehiclesMap[target.id]) { fetchTargetVehicles(target.id).then(data => { setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data })); @@ -309,7 +366,7 @@ export default function StatisticsView() {
diff --git a/src/modules/mileage/api.ts b/src/modules/mileage/api.ts index d0e599f..8159197 100644 --- a/src/modules/mileage/api.ts +++ b/src/modules/mileage/api.ts @@ -22,6 +22,8 @@ export async function fetchMonitoring(params?: { mileageMin?: string; mileageMax?: string; date?: string; + startDate?: string; + endDate?: string; }): Promise { const query = new URLSearchParams(); 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?.mileageMax) query.set('mileageMax', params.mileageMax); if (params?.date) query.set('date', params.date); + if (params?.startDate) query.set('startDate', params.startDate); + if (params?.endDate) query.set('endDate', params.endDate); const qs = query.toString(); return fetchJson(`${BASE}/monitoring${qs ? `?${qs}` : ''}`); } @@ -93,3 +97,135 @@ export async function fetchVehicleRecent( `${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}` ); } + +export interface DailyReportModel { + id: number; + name: string; + count: number; + today: number; + total: number; + completion: number; + active: number; + zero: number; + dailyNeed: number; +} + +export interface DailyReportVehicle { + plate: string; + model: string; + status: string; + customer: string; + today?: number; + completion?: number; +} + +export interface DailyReportData { + reportDate: string; + updatedAt: string; + models: DailyReportModel[]; + trend: { date: string; value: number }[]; + topVehicles: DailyReportVehicle[]; + zeroRisk: DailyReportVehicle[]; + qualifiedCount: number; + halfQualifiedCount: number; +} + +function reportDateFromUpdatedAt(updatedAt: string): string { + if (/^\d{4}-\d{2}-\d{2}$/.test(updatedAt)) return updatedAt; + const d = new Date(updatedAt); + if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10); + return new Date().toISOString().slice(0, 10); +} + +function compactTargetName(name: string): string { + return name + .replace(/^羚牛/, '') + .replace(/辆/g, '台') + .replace(/4\.5T普货/g, '普货') + .replace(/4\.5T冷链车/g, '冷链车') + .replace(/4\.5T冷链/g, '冷链车'); +} + +function normalizeStatus(status: string | null): string { + if (!status) return '未标注'; + if (status === '自营' || status === '租赁') return status; + if (/租/.test(status)) return '租赁'; + if (/自/.test(status)) return '自营'; + if (/库|Inventory/i.test(status)) return '在库'; + return status; +} + +export async function fetchDailyReport(): Promise { + 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(); + 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), + }; +} diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts index 4207619..6971c71 100644 --- a/src/modules/mileage/types.ts +++ b/src/modules/mileage/types.ts @@ -2,6 +2,7 @@ export interface MonitoringVehicle { plate: string; vin: string; dailyKm: number; + dailyMileage?: Record; totalKm: number | null; source: string; isOnline: boolean; @@ -39,6 +40,8 @@ export interface MonitoringData { vehicles: MonitoringVehicle[]; stats: MonitoringStats; filters: MonitoringFilters; + rangeDailyTotals?: { date: string; totalKm: number }[]; + dateRange?: { start: string; end: string }; total: number; page: number; totalPages: number; diff --git a/src/modules/mileage/xlsx-export.ts b/src/modules/mileage/xlsx-export.ts index 18ee534..a274059 100644 --- a/src/modules/mileage/xlsx-export.ts +++ b/src/modules/mileage/xlsx-export.ts @@ -2,13 +2,15 @@ import * as XLSX from 'xlsx'; import type { MonitoringVehicle } from './types'; interface ExportContext { - date: string; + date?: string; + startDate?: string; + endDate?: string; sortBy: 'today' | 'total'; } const HEADERS = [ '状态', '车牌号', '客户', '业务部门', '项目', '租赁状态', - '运营区域', '今日里程(km)', '累计里程(km)', + '运营区域', '区间里程(km)', '累计里程(km)', ] as const; function statusLabel(v: MonitoringVehicle): string { @@ -18,7 +20,7 @@ function statusLabel(v: MonitoringVehicle): string { function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number { if (kind === 'today') { - // 当日未对接但有历史累计,视作今日 0;只有完全无数据才标「未对接」 + // 区间内未对接但有历史累计,视作区间 0;只有完全无数据才标「未对接」。 if (!v.isDataSynced && v.totalKm == null) return '未对接'; return Math.max(0, v.dailyKm || 0); } @@ -26,7 +28,10 @@ function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | nu } export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void { - const data: (string | number)[][] = [ + const start = ctx.startDate || ctx.date || ''; + const end = ctx.endDate || ctx.date || ''; + const isRange = !!start && !!end && start !== end; + const summaryData: (string | number)[][] = [ [...HEADERS], ...vehicles.map(v => [ statusLabel(v), @@ -41,7 +46,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont ]), ]; - const ws = XLSX.utils.aoa_to_sheet(data); + const ws = XLSX.utils.aoa_to_sheet(summaryData); ws['!cols'] = [ { wch: 8 }, // 状态 @@ -51,13 +56,13 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont { wch: 16 }, // 项目 { wch: 10 }, // 租赁状态 { wch: 12 }, // 运营区域 - { wch: 14 }, // 今日里程 + { wch: 14 }, // 区间里程 { wch: 14 }, // 累计里程 ]; ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never; - for (let r = 1; r < data.length; r++) { + for (let r = 1; r < summaryData.length; r++) { for (const c of [7, 8]) { const ref = XLSX.utils.encode_cell({ r, c }); if (ws[ref]?.t === 'n') ws[ref].z = '0.##########'; @@ -77,7 +82,53 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont } const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, '里程明细'); + XLSX.utils.book_append_sheet(wb, ws, '车辆汇总'); + + const dayKeys = Array.from( + new Set(vehicles.flatMap(v => Object.keys(v.dailyMileage || {}))) + ).sort(); + if (dayKeys.length > 0) { + const detailHeaders = [ + '车牌号', '客户', '业务部门', '项目', '租赁状态', '运营区域', + ...dayKeys.map(day => `${day}里程(km)`), + '区间合计(km)', + '累计里程(km)', + ]; + const detailData: (string | number)[][] = [ + detailHeaders, + ...vehicles.map(v => [ + v.plate, + v.customer || '', + v.department || '', + v.project || '', + v.rentStatus || '', + v.region || '', + ...dayKeys.map(day => v.dailyMileage?.[day] || 0), + Math.max(0, v.dailyKm || 0), + v.totalKm != null ? v.totalKm : '', + ]), + ]; + const detailWs = XLSX.utils.aoa_to_sheet(detailData); + detailWs['!cols'] = [ + { wch: 12 }, + { wch: 28 }, + { wch: 14 }, + { wch: 16 }, + { wch: 10 }, + { wch: 12 }, + ...dayKeys.map(() => ({ wch: 14 })), + { wch: 14 }, + { wch: 14 }, + ]; + detailWs['!freeze'] = { xSplit: 6, ySplit: 1 } as never; + for (let r = 1; r < detailData.length; r++) { + for (let c = 6; c < detailHeaders.length; c++) { + const ref = XLSX.utils.encode_cell({ r, c }); + if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########'; + } + } + XLSX.utils.book_append_sheet(wb, detailWs, '每日明细'); + } const now = new Date(); const y = now.getFullYear(); @@ -85,7 +136,9 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont const d = String(now.getDate()).padStart(2, '0'); const hh = String(now.getHours()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0'); - const dateTag = ctx.date ? ctx.date.replace(/-/g, '') : `${y}${m}${d}`; - const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? '今日' : '累计'}.xlsx`; + const dateTag = start && end + ? `${start.replace(/-/g, '')}-${end.replace(/-/g, '')}` + : `${y}${m}${d}`; + const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? (isRange ? '区间' : '今日') : '累计'}.xlsx`; XLSX.writeFile(wb, filename); } diff --git a/src/modules/scheduling/SchedulingModule.tsx b/src/modules/scheduling/SchedulingModule.tsx index 68a7d69..70739cc 100644 --- a/src/modules/scheduling/SchedulingModule.tsx +++ b/src/modules/scheduling/SchedulingModule.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download } from 'lucide-react'; +import { Activity, AlertTriangle, CheckCircle2, Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download, SendHorizonal } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { fetchSuggestions, sendNotifyBatch } from './api'; import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types'; @@ -9,6 +9,7 @@ import NotificationHistory from './NotificationHistory'; import { exportSuggestionsCsv } from './csv-export'; import Blur from '../../components/Blur'; import RotatingFooterHint from '../../components/RotatingFooterHint'; +import { MetricTile, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface'; type TypeFilter = 'all' | 'qualified' | 'hopeless'; @@ -87,63 +88,43 @@ function FilterSelect({ label, options, value, onChange, placeholder }: { ); } -/** Skeleton pulse block */ function Sk({ className }: { className?: string }) { return
; } function SkeletonPage() { return ( -
-
- {/* Cards skeleton */} -
- {[0, 1, 2].map(i => ( -
- - - -
- ))} -
- - {/* List card skeleton */} -
- {/* Header */} -
-
- -
-
-
- {[0, 1, 2, 3].map(i => )} -
+ +
+ {Array.from({ length: 4 }).map((_, i) => )} +
+ +
+ +
+ {[0, 1, 2, 3].map(i => )}
- - {/* Rows */}
{Array.from({ length: 8 }).map((_, i) => ( -
- +
+
-
- - - -
-
- - - -
+ +
- +
))}
-
-
+ + ); } @@ -275,89 +256,33 @@ export default function SchedulingModule() { if (loading && !data) return ; return ( -
-
+ + + 刷新建议 + + )} + > {/* ===== Summary Cards ===== */} -
- {/* 里程高·换下 — warm orange */} - - - {/* 里程低·换走 — cool blue */} - - - {/* 替换建议 — neutral dark */} - - - {/* 近期已干预 — emerald */} -
@@ -632,8 +557,7 @@ export default function SchedulingModule() {
)} -
-
+ ); } diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts index eb5b929..1cbdaf4 100644 --- a/src/server/routes/mileage/cache.ts +++ b/src/server/routes/mileage/cache.ts @@ -65,6 +65,21 @@ interface MileageRow { source: string; } +interface DailyMileageRow { + plate: string; + vin: string | null; + date: string; + daily_km: string | number | null; + source: string | null; +} + +export interface RangeMileageResult { + vehicles: CachedVehicle[]; + dailyTotals: { date: string; totalKm: number }[]; + start: string; + end: string; +} + interface TargetRow { id: number; target_name: string; @@ -160,31 +175,32 @@ function mergeVehicles( } } - return Array.from(mileageMap.values()).map(m => { - const info = infoMap.get(m.plate); - const dailyKm = Number(m.daily_km) || 0; - const source = m.source || 'NONE'; - const gpsTotal = m.total_km !== null ? Number(m.total_km) : null; - const latestPgTotal = latestPgTotalMap.get(m.plate); - const bizTotal = bizTotalMap.get(m.plate); + return Array.from(infoMap.values()).map(info => { + const m = mileageMap.get(info.plate); + const plate = info.plate; + const dailyKm = Number(m?.daily_km) || 0; + const source = m?.source || 'NONE'; + const gpsTotal = m?.total_km != null ? Number(m.total_km) : null; + const latestPgTotal = latestPgTotalMap.get(plate); + const bizTotal = bizTotalMap.get(plate); return { - plate: m.plate, - vin: m.vin, + plate, + vin: m?.vin || info.vin || '', dailyKm, totalKm: gpsTotal !== null ? gpsTotal : (latestPgTotal ?? bizTotal ?? null), source, isOnline: source !== 'NONE' && dailyKm > 0, isDataSynced: source !== 'NONE', - customer: info?.customer || null, - department: info?.department || null, - manager: info?.manager || null, - managerId: info?.manager_id || null, - rentStatus: info?.rent_status || null, - entity: info?.entity || null, - project: info?.project || null, - region: regionMap[m.plate] || null, - targetNames: targetNamesByPlate.get(m.plate) || [], - yesterdayKm: yesterdayMap.get(m.plate) || 0, + customer: info.customer || null, + department: info.department || null, + manager: info.manager || null, + managerId: info.manager_id || null, + rentStatus: info.rent_status || null, + entity: info.entity || null, + project: info.project || null, + region: regionMap[plate] || null, + targetNames: targetNamesByPlate.get(plate) || [], + yesterdayKm: yesterdayMap.get(plate) || 0, }; }); } @@ -281,6 +297,110 @@ export async function queryDateMileage(dateStr: string): Promise { + 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>(); + const perVehicleSum = new Map(); + const dailyTotals = new Map(); + const bestDailyRows = new Map(); + + 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(); + 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 = {}; + 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 { return buildFilters(vehicles, monitoringCache?.filters.targetNames || []); } diff --git a/src/server/routes/mileage/monitoring.ts b/src/server/routes/mileage/monitoring.ts index 5e366c0..8cf7f17 100644 --- a/src/server/routes/mileage/monitoring.ts +++ b/src/server/routes/mileage/monitoring.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { getCache, queryDateMileage, buildDateFilters } from './cache.js'; +import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from './cache.js'; import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js'; import type { AuthUser } from '../../auth/types.js'; import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js'; @@ -64,12 +64,40 @@ function parseTargetNames(reqUrl: string): string[] { return Array.from(new Set(names)); } +function parseYmd(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return null; + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + date.setHours(0, 0, 0, 0); + return Number.isFinite(date.getTime()) ? date : null; +} + +function fmtYmd(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function normalizeRange(startQuery: string, endQuery: string): { start: string; end: string } | null { + if (!startQuery && !endQuery) return null; + const start = parseYmd(startQuery || endQuery); + const end = parseYmd(endQuery || startQuery); + if (!start || !end) return null; + const a = start <= end ? start : end; + let b = start <= end ? end : start; + const span = Math.round((b.getTime() - a.getTime()) / 86400000) + 1; + if (span > 366) { + b = new Date(a); + b.setDate(a.getDate() + 365); + } + return { start: fmtYmd(a), end: fmtYmd(b) }; +} + app.get('/', async (c) => { const sortBy = c.req.query('sortBy') || 'today'; const sortOrder = c.req.query('sortOrder') || 'desc'; const limit = Number(c.req.query('limit')) || 50; const page = Number(c.req.query('page')) || 1; const date = c.req.query('date') || ''; + const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || ''); const filterParams = { search: c.req.query('search') || '', @@ -88,8 +116,21 @@ app.get('/', async (c) => { let allVehicles: CachedVehicle[]; let filters: MonitoringFilters; + let rangeDailyTotals: { date: string; totalKm: number }[] | undefined; + let dateRange: { start: string; end: string } | undefined; - if (date) { + if (range) { + try { + const result = await queryRangeMileage(range.start, range.end); + allVehicles = result.vehicles; + rangeDailyTotals = result.dailyTotals; + dateRange = { start: result.start, end: result.end }; + filters = buildDateFilters(allVehicles); + } catch (e: unknown) { + console.error('monitoring range query error:', e); + return c.json(EMPTY_RESPONSE, 500); + } + } else if (date) { try { allVehicles = await queryDateMileage(date); filters = buildDateFilters(allVehicles); @@ -118,6 +159,12 @@ app.get('/', async (c) => { } const filtered = applyFilters(allVehicles, filterParams); + if (rangeDailyTotals && filtered.length !== allVehicles.length) { + rangeDailyTotals = rangeDailyTotals.map(item => ({ + ...item, + totalKm: filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0), + })); + } const stats = { totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0), @@ -140,10 +187,12 @@ app.get('/', async (c) => { vehicles: maskCustomerNames(paged), stats, filters, + rangeDailyTotals, + dateRange, total, page, totalPages: Math.ceil(total / limit), - updatedAt: date || getCache()?.updatedAt || new Date().toISOString(), + updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(), }); }); diff --git a/src/server/routes/mileage/types.ts b/src/server/routes/mileage/types.ts index 0c953dd..c7d1abc 100644 --- a/src/server/routes/mileage/types.ts +++ b/src/server/routes/mileage/types.ts @@ -3,6 +3,7 @@ export interface CachedVehicle { plate: string; vin: string; dailyKm: number; + dailyMileage?: Record; totalKm: number | null; source: string; isOnline: boolean; @@ -60,6 +61,8 @@ export interface MonitoringResponse { vehicles: CachedVehicle[]; stats: MonitoringStats; filters: MonitoringFilters; + rangeDailyTotals?: { date: string; totalKm: number }[]; + dateRange?: { start: string; end: string }; total: number; page: number; totalPages: number; @@ -69,6 +72,7 @@ export interface MonitoringResponse { /** 车辆关联信息(从 lingniu_prod 查出的原始行) */ export interface VehicleInfoRow { plate: string; + vin: string | null; customer: string | null; department: string | null; manager: string | null; diff --git a/src/server/routes/mileage/vehicle-info.ts b/src/server/routes/mileage/vehicle-info.ts index cc949a0..52ca65d 100644 --- a/src/server/routes/mileage/vehicle-info.ts +++ b/src/server/routes/mileage/vehicle-info.ts @@ -4,6 +4,7 @@ import type { VehicleInfoRow } from './types.js'; /** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目) */ export const VEHICLE_INFO_SQL = `SELECT vi.plate_number AS plate, + vi.vin AS vin, COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer, COALESCE(c.business_department_name, vor.business_dept) AS department, COALESCE(c.business_manager_name, vor.business_manager) AS manager, diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts index 258312d..fb3491e 100644 --- a/src/server/routes/vehicles.ts +++ b/src/server/routes/vehicles.ts @@ -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_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`; +type FlowType = 'delivered' | 'returned' | 'replaced'; + +interface FlowDetailRow { + id: string; + type: FlowType; + type_label: string; + stat_date: string; + truck_id: string; + plate_number: string; + event_time: string | null; + submit_time: string | null; + department: string | null; + manager: string | null; + customer_name: string | null; +} + +function formatDateOnly(value: Date): string { + const y = value.getFullYear(); + const m = String(value.getMonth() + 1).padStart(2, '0'); + const d = String(value.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function isDateParam(value: string | undefined | null): value is string { + return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value)); +} + +function addDateDays(date: string, days: number): string { + const d = new Date(`${date}T00:00:00`); + d.setDate(d.getDate() + days); + return formatDateOnly(d); +} + +function listDateRange(start: string, end: string): string[] { + const dates: string[] = []; + let cursor = start; + while (cursor <= end && dates.length <= 370) { + dates.push(cursor); + cursor = addDateDays(cursor, 1); + } + return dates; +} + +function normalizeDateRange(startRaw: string | undefined, endRaw: string | undefined): { start: string; end: string } { + const today = formatDateOnly(new Date()); + const defaultStart = addDateDays(today, -29); + let start = isDateParam(startRaw) ? startRaw : defaultStart; + let end = isDateParam(endRaw) ? endRaw : today; + if (start > end) [start, end] = [end, start]; + if (listDateRange(start, end).length > 370) start = addDateDays(end, -369); + return { start, end }; +} + interface WeeklyStats { pendingDelivery: number; weeklyNew: number; @@ -1122,6 +1175,141 @@ app.get('/weekly-detail', async (c) => { return c.json(masked); }); +// GET /api/vehicles/flow-stats?start=YYYY-MM-DD&end=YYYY-MM-DD +// 资产流转日报:按提交时间(create_time)统计交车、还车、替换车,并返回可点击明细。 +app.get('/flow-stats', async (c) => { + const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end')); + const allowedVehicles = await getVehiclesForUser(c); + const allowedTruckIds = new Set(allowedVehicles.map((v) => String(v.id))); + + const sql = ` + SELECT * + FROM ( + SELECT + CONCAT('delivered-', dv.id) AS id, + 'delivered' AS type, + '交车' AS type_label, + DATE_FORMAT(dv.create_time, '%Y-%m-%d') AS stat_date, + CAST(dv.vehicle_id AS CHAR) AS truck_id, + dv.plate_number, + DATE_FORMAT(dv.delivery_time, '%Y-%m-%d %H:%i:%s') AS event_time, + DATE_FORMAT(dv.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time, + c.business_department_name AS department, + c.business_manager_name AS manager, + COALESCE(dts.customer_name, c.customer_name) AS customer_name + FROM delivery_vehicle dv + LEFT JOIN delivery_task_subject dts + ON dts.id = dv.delivery_task_subject_id + AND dts.del_flag = '0' + LEFT JOIN vehicle_lease_contract_info c + ON c.order_id = dv.contract_id + AND c.del_flag = '0' + WHERE dv.del_flag = '0' + AND dv.vehicle_id IS NOT NULL + AND dv.create_time IS NOT NULL + AND dv.delivery_status IN (2,3,5) + AND dv.create_time >= ? + AND dv.create_time < DATE_ADD(?, INTERVAL 1 DAY) + + UNION ALL + + SELECT + CONCAT('returned-', r.id) AS id, + 'returned' AS type, + '还车' AS type_label, + DATE_FORMAT(r.create_time, '%Y-%m-%d') AS stat_date, + CAST(r.vehicle_id AS CHAR) AS truck_id, + r.plate_number, + DATE_FORMAT(r.arrival_time, '%Y-%m-%d %H:%i:%s') AS event_time, + DATE_FORMAT(r.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time, + c.business_department_name AS department, + c.business_manager_name AS manager, + COALESCE(dts.customer_name, c.customer_name) AS customer_name + FROM return_vehicle_task r + LEFT JOIN delivery_task_subject dts + ON dts.id = r.delivery_task_subject_id + AND dts.del_flag = '0' + LEFT JOIN vehicle_lease_contract_info c + ON c.order_id = r.contract_id + AND c.del_flag = '0' + WHERE r.del_flag = '0' + AND r.vehicle_id IS NOT NULL + AND r.create_time IS NOT NULL + AND r.status IN (2,3,5) + AND r.create_time >= ? + AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY) + + UNION ALL + + SELECT + CONCAT('replaced-', vr.id) AS id, + 'replaced' AS type, + '替换' AS type_label, + DATE_FORMAT(vr.create_time, '%Y-%m-%d') AS stat_date, + CAST(vr.new_vehicle_id AS CHAR) AS truck_id, + vr.new_vehicle_plate AS plate_number, + DATE_FORMAT(vr.replace_time, '%Y-%m-%d %H:%i:%s') AS event_time, + DATE_FORMAT(vr.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time, + c.business_department_name AS department, + c.business_manager_name AS manager, + COALESCE(dts.customer_name, c.customer_name) AS customer_name + FROM vehicle_replacement vr + LEFT JOIN delivery_task_subject dts + ON dts.id = vr.delivery_task_subject_id + AND dts.del_flag = '0' + LEFT JOIN vehicle_lease_contract_info c + ON c.id = vr.contract_id + AND c.del_flag = '0' + WHERE vr.del_flag = '0' + AND vr.new_vehicle_id IS NOT NULL + AND vr.create_time IS NOT NULL + AND vr.status = 20 + AND vr.create_time >= ? + AND vr.create_time < DATE_ADD(?, INTERVAL 1 DAY) + ) flow + ORDER BY flow.submit_time DESC + `; + + const [rows] = await pool.query(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(); + for (const date of listDateRange(start, end)) dailyMap.set(date, { date, delivered: 0, returned: 0, replaced: 0, total: 0 }); + for (const item of details) { + const stat = dailyMap.get(item.date); + if (!stat) continue; + stat[item.type] += 1; + stat.total += 1; + } + + const daily = Array.from(dailyMap.values()); + const totals = daily.reduce( + (acc, item) => ({ + delivered: acc.delivered + item.delivered, + returned: acc.returned + item.returned, + replaced: acc.replaced + item.replaced, + total: acc.total + item.total, + }), + { delivered: 0, returned: 0, replaced: 0, total: 0 }, + ); + + return c.json({ start, end, daily, totals, details }); +}); + // GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉 app.get('/subjects', async (c) => { const all = await getVehicles();