feat: polish BI dashboards and bump version
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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<DailyReportData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchDailyReport()
|
||||
.then(result => { if (!cancelled) setData(result); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const models = data?.models ?? [];
|
||||
return models.reduce(
|
||||
(acc, item) => ({
|
||||
count: acc.count + item.count,
|
||||
today: acc.today + item.today,
|
||||
total: acc.total + item.total,
|
||||
active: acc.active + item.active,
|
||||
zero: acc.zero + item.zero,
|
||||
dailyNeed: acc.dailyNeed + item.dailyNeed,
|
||||
}),
|
||||
{ count: 0, today: 0, total: 0, active: 0, zero: 0, dailyNeed: 0 },
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
if (loading) return <LoadingState label="正在从数据库生成每日汇报" />;
|
||||
if (error) return <ErrorState message={error} />;
|
||||
if (!data) return <ErrorState message="日报数据为空" />;
|
||||
|
||||
const activeRate = totals.count > 0 ? (totals.active / totals.count) * 100 : 0;
|
||||
const dailyGap = totals.today - totals.dailyNeed;
|
||||
const maxTrend = Math.max(1, ...data.trend.map(item => item.value));
|
||||
const maxModel = Math.max(1, ...data.models.map(item => item.today));
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<FileText size={48} className="mx-auto text-gray-300 mb-4" />
|
||||
<h2 className="text-lg font-semibold text-gray-500">每日汇报</h2>
|
||||
<p className="text-sm text-gray-400 mt-2">开发中...</p>
|
||||
<div className="space-y-4">
|
||||
<SurfaceCard className="overflow-hidden">
|
||||
<div className="grid gap-3 p-4 md:grid-cols-[1.3fr_1fr] md:items-center">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-[11px] font-black text-blue-600">数据截止 {data.reportDate} 23:59 · 数据库口径</div>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-black text-emerald-600">
|
||||
<Database size={11} />
|
||||
DB LIVE
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-2 text-xl font-black tracking-tight text-slate-950">车辆里程每日汇报</h2>
|
||||
<p className="mt-2 text-xs font-bold leading-relaxed text-slate-500">
|
||||
基于里程考核目标、车辆日里程视图和车辆归属信息实时聚合,和统计报表/实时监控保持同一数据库口径。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 rounded-2xl bg-slate-50 p-2 text-center">
|
||||
<div className="rounded-xl bg-white px-2 py-2">
|
||||
<div className="text-lg font-black text-slate-950">{fmt(totals.count)}</div>
|
||||
<div className="text-[10px] font-black text-slate-400">车辆 台</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white px-2 py-2">
|
||||
<div className="text-lg font-black text-emerald-600">{activeRate.toFixed(1)}%</div>
|
||||
<div className="text-[10px] font-black text-slate-400">有里程</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white px-2 py-2">
|
||||
<div className="text-lg font-black text-rose-600">{totals.zero}</div>
|
||||
<div className="text-[10px] font-black text-slate-400">零里程</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Route} label="当日总里程" value={fmtKm(totals.today)} unit="km" helper={`${data.reportDate} 数据库合计`} />
|
||||
<MetricTile icon={Truck} label="当日有里程车辆" value={`${totals.active}/${totals.count}`} helper={`零里程 ${totals.zero} 台`} tone="emerald" />
|
||||
<MetricTile icon={Target} label="日需完成" value={fmtKm(totals.dailyNeed)} unit="km" helper="当前考核年度压力折算" tone="amber" />
|
||||
<MetricTile
|
||||
icon={ArrowDownRight}
|
||||
label="对比日需"
|
||||
value={`${dailyGap >= 0 ? '+' : '-'}${fmtKm(Math.abs(dailyGap))}`}
|
||||
unit="km"
|
||||
helper={dailyGap >= 0 ? '今日高于日需目标' : '今日低于日需目标'}
|
||||
tone={dailyGap >= 0 ? 'emerald' : 'rose'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
|
||||
<SurfaceCard title="近 7 日总里程趋势" subtitle="时间单位:日 · 单位 km · 数据来自 v_vehicle_daily_stats">
|
||||
<div className="flex h-64 items-end gap-2 px-4 pb-4 pt-6">
|
||||
{data.trend.map(item => (
|
||||
<div key={item.date} className="flex min-w-0 flex-1 flex-col items-center gap-2">
|
||||
<div className="relative flex h-44 w-full items-end rounded-xl bg-slate-50">
|
||||
<div
|
||||
className="w-full rounded-xl bg-gradient-to-t from-blue-600 to-cyan-400 shadow-sm"
|
||||
style={{ height: `${Math.max(4, (item.value / maxTrend) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] font-black text-slate-400">{item.date}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<SurfaceCard title="车型任务进度" subtitle={`时间单位:${data.reportDate} 单日 / 当前考核年度`}>
|
||||
<div className="space-y-3 p-4">
|
||||
{data.models.map(item => (
|
||||
<div key={item.id}>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-black text-slate-800">{item.name}</div>
|
||||
<div className="mt-0.5 text-[10px] font-bold text-slate-400">{item.active}/{item.count} 有里程 · 零里程 {item.zero}</div>
|
||||
</div>
|
||||
<div className="text-right text-xs font-black text-blue-600">{fmt(item.today, 1)} km</div>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div className="h-full rounded-full bg-blue-500" style={{ width: `${Math.max(4, (item.today / maxModel) * 100)}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[10px] font-bold text-slate-400">
|
||||
<span>年度完成 {item.completion.toFixed(1)}%</span>
|
||||
<span>日需 {fmt(item.dailyNeed, 1)} km</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ReportList title="当日里程 Top 5" subtitle={`时间单位:${data.reportDate} 单日 · 单位 km`} rows={data.topVehicles} mode="top" />
|
||||
<ReportList title="需要关注:运营中零里程" subtitle="筛选租赁/自营车辆,按累计完成率优先跟进" rows={data.zeroRisk} mode="risk" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<SurfaceCard>
|
||||
<div className="p-4">
|
||||
<CheckCircle2 className="mb-2 text-emerald-500" size={18} />
|
||||
<div className="text-sm font-black text-slate-900">{data.qualifiedCount} 台已达当前年度标准</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-400">{data.halfQualifiedCount} 台达到 50% 里程线</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<SurfaceCard>
|
||||
<div className="p-4">
|
||||
<BarChart3 className="mb-2 text-blue-500" size={18} />
|
||||
<div className="text-sm font-black text-slate-900">全量均值 {fmt(totals.count > 0 ? totals.today / totals.count : 0, 1)} km/台</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-400">有里程车辆均值 {fmt(totals.active > 0 ? totals.today / totals.active : 0, 1)} km/台</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<SurfaceCard>
|
||||
<div className="p-4">
|
||||
<AlertTriangle className="mb-2 text-amber-500" size={18} />
|
||||
<div className="text-sm font-black text-slate-900">缺口 {fmtKm(Math.abs(dailyGap))} km</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-400">建议关注零里程及低完成率车辆</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportList({
|
||||
title,
|
||||
subtitle,
|
||||
rows,
|
||||
mode,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
rows: DailyReportVehicle[];
|
||||
mode: 'top' | 'risk';
|
||||
}) {
|
||||
return (
|
||||
<SurfaceCard title={title} subtitle={subtitle}>
|
||||
<div className="divide-y divide-slate-50">
|
||||
{rows.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">暂无匹配车辆</div>
|
||||
) : rows.map(item => (
|
||||
<div key={`${item.plate}-${mode}`} className="grid grid-cols-[1fr_auto] gap-3 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-black text-slate-900">{item.plate}</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-black text-slate-500">{item.model}</span>
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-black text-blue-600">{item.status}</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[11px] font-bold text-slate-400">{item.customer}</div>
|
||||
</div>
|
||||
<div className={mode === 'top' ? 'text-right text-sm font-black text-blue-600' : 'text-right text-sm font-black text-amber-600'}>
|
||||
{mode === 'top' ? `${fmt(item.today ?? 0, 1)} km` : `${(item.completion ?? 0).toFixed(1)}%`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<MileageSubTab>('mileage', MILEAGE_SUB_IDS);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
|
||||
{/* Sub-navigation — sticky */}
|
||||
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-0 z-30">
|
||||
<button
|
||||
onClick={() => setActiveSubTab('monitoring')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
>
|
||||
<LayoutDashboard size={14} />
|
||||
<span className="text-[11px] font-bold">实时监控</span>
|
||||
{activeSubTab === 'monitoring' && (
|
||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('statistics')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
>
|
||||
<BarChart3 size={14} />
|
||||
<span className="text-[11px] font-bold">统计报表</span>
|
||||
{activeSubTab === 'statistics' && (
|
||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('report')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span className="text-[11px] font-bold">每日汇报</span>
|
||||
{activeSubTab === 'report' && (
|
||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeSubTab === 'monitoring' ? (
|
||||
<MonitoringView />
|
||||
) : activeSubTab === 'statistics' ? (
|
||||
<StatisticsView />
|
||||
) : (
|
||||
<DailyReportView />
|
||||
)}
|
||||
<RotatingFooterHint />
|
||||
<PageFrame
|
||||
title="车辆里程中心"
|
||||
subtitle="统一监控车辆日里程、累计里程、考核进度与日报经营口径,突出异常车辆和任务压力。"
|
||||
icon={LayoutDashboard}
|
||||
eyebrow="MILEAGE BI"
|
||||
meta="实时监控 · 统计报表 · 每日汇报"
|
||||
compactInfo
|
||||
>
|
||||
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
|
||||
<SegmentedNav tabs={MILEAGE_TABS} active={activeSubTab} onChange={setActiveSubTab} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<FadeIn key={activeSubTab}>
|
||||
{activeSubTab === 'monitoring' ? (
|
||||
<MonitoringView />
|
||||
) : activeSubTab === 'statistics' ? (
|
||||
<StatisticsView />
|
||||
) : (
|
||||
<DailyReportView />
|
||||
)}
|
||||
</FadeIn>
|
||||
</AnimatePresence>
|
||||
<RotatingFooterHint />
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<MonitoringVehicle | null>(null);
|
||||
const [filterDate, setFilterDate] = useState(() => {
|
||||
const now = new Date();
|
||||
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
});
|
||||
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
|
||||
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
|
||||
|
||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] });
|
||||
const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number }[]>([]);
|
||||
const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() }));
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
@@ -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<MonitoringVehicle | null>((best, vehicle) => (!best || vehicle.dailyKm > best.dailyKm ? vehicle : best), null),
|
||||
[vehicles],
|
||||
);
|
||||
const applyRangePreset = useCallback((preset: RangePreset) => {
|
||||
const range = getRangePreset(preset);
|
||||
setRangeStart(range.start);
|
||||
setRangeEnd(range.end);
|
||||
}, []);
|
||||
|
||||
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
|
||||
const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name))
|
||||
@@ -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() {
|
||||
<h2 className="text-white font-bold text-xs">全屏监控</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px]">
|
||||
<span className="text-slate-500">今日 <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-500">区间 <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">累计 <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-700">|</span>
|
||||
@@ -633,7 +687,7 @@ export default function MonitoringView() {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span>今日里程</span>
|
||||
<span>区间里程</span>
|
||||
{sortBy === 'today' && (
|
||||
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
|
||||
)}
|
||||
@@ -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'}`}
|
||||
>
|
||||
今日
|
||||
区间
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSortBy('total')}
|
||||
@@ -779,6 +833,35 @@ export default function MonitoringView() {
|
||||
<Filter size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
|
||||
{([
|
||||
['today', '今天'],
|
||||
['thisWeek', '本周'],
|
||||
['thisMonth', '本月'],
|
||||
['last15', '近15天'],
|
||||
] as Array<[RangePreset, string]>).map(([preset, label]) => {
|
||||
const range = getRangePreset(preset);
|
||||
const active = rangeStart === range.start && rangeEnd === range.end;
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => applyRangePreset(preset)}
|
||||
className={`shrink-0 rounded-lg border px-2.5 py-1.5 text-[10px] font-black transition-all ${
|
||||
active
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-slate-50 text-slate-500 hover:border-blue-100 hover:bg-blue-50 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">
|
||||
{rangeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Filter Panel */}
|
||||
@@ -791,15 +874,42 @@ export default function MonitoringView() {
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-2 space-y-4">
|
||||
{/* Date */}
|
||||
{/* Date range */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">查询日期</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={filterDate}
|
||||
onChange={(e) => setFilterDate(e.target.value)}
|
||||
/>
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">查询时间区间</label>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={rangeStart}
|
||||
onChange={(e) => setRangeStart(e.target.value)}
|
||||
/>
|
||||
<span className="text-[10px] font-bold text-slate-300">至</span>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={rangeEnd}
|
||||
onChange={(e) => setRangeEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 pt-1">
|
||||
{([
|
||||
['today', '今天'],
|
||||
['thisWeek', '本周'],
|
||||
['thisMonth', '本月'],
|
||||
['last7', '近7天'],
|
||||
['last15', '近15天'],
|
||||
] as Array<[RangePreset, string]>).map(([preset, label]) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
|
||||
onClick={() => applyRangePreset(preset)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -988,13 +1109,14 @@ export default function MonitoringView() {
|
||||
})()}
|
||||
|
||||
{/* Sticky header: KPI + 清单标题 */}
|
||||
<div className="sticky top-[44px] z-20 bg-[#F8F9FB] pt-1 pb-1 space-y-2">
|
||||
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
|
||||
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}>
|
||||
<div className="col-span-2 bg-slate-900 p-2.5 rounded-xl text-white relative overflow-hidden">
|
||||
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'today' ? '今日' : '累计'}总里程</div>
|
||||
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'today' ? (isRangeMode ? '区间' : '当日') : '累计'}总里程</div>
|
||||
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
|
||||
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
||||
</div>
|
||||
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
|
||||
<div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div>
|
||||
@@ -1007,6 +1129,66 @@ export default function MonitoringView() {
|
||||
<div className="text-[7px] text-slate-400">台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-100 bg-white shadow-sm overflow-hidden">
|
||||
{pageLoading ? (
|
||||
<div className="h-[74px] bg-slate-50 animate-pulse" />
|
||||
) : isRangeMode ? (
|
||||
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
|
||||
<CalendarDays size={12} className="text-blue-500" />
|
||||
区间走势
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] font-bold text-slate-400">{rangeDailyTotals.length} 天 · km</div>
|
||||
<div className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{rangeLabel}</div>
|
||||
</div>
|
||||
<div className="h-[58px] min-w-0">
|
||||
{rangeDailyTotals.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center rounded-lg bg-slate-50 text-[10px] font-bold text-slate-300">暂无趋势</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={58} minWidth={0}>
|
||||
<BarChart data={rangeDailyTotals} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}>
|
||||
<Tooltip
|
||||
formatter={(value) => [`${Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} km`, '当日里程']}
|
||||
labelFormatter={(label) => `日期 ${label}`}
|
||||
contentStyle={{ borderRadius: 10, borderColor: '#e2e8f0', fontSize: 11 }}
|
||||
cursor={{ fill: 'rgba(37, 99, 235, 0.06)' }}
|
||||
/>
|
||||
{averageDailyKm > 0 && <ReferenceLine y={averageDailyKm} stroke="#f59e0b" strokeDasharray="3 3" />}
|
||||
<Bar dataKey="totalKm" radius={[3, 3, 0, 0]} fill="#38bdf8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[9px] font-black text-slate-400">日均</div>
|
||||
<div className="text-xs font-black text-slate-800">{Math.round(averageDailyKm).toLocaleString()}</div>
|
||||
<div className="text-[9px] font-bold text-slate-400">km</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_84px] items-center gap-2 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
|
||||
<CalendarDays size={12} className="text-blue-500" />
|
||||
单日概览
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[9px] font-bold text-slate-400">{rangeLabel} · 单位 km</div>
|
||||
<div className="mt-1 truncate text-[10px] font-bold text-slate-500">
|
||||
当前列表最高 {topLoadedVehicle ? `${topLoadedVehicle.plate} · ${Math.round(topLoadedVehicle.dailyKm).toLocaleString()} km` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-blue-50 px-2 py-1.5 text-right">
|
||||
<div className="text-[9px] font-black text-blue-400">当日总计</div>
|
||||
<div className="text-xs font-black text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-slate-50 px-2 py-1.5 text-right">
|
||||
<div className="text-[9px] font-black text-slate-400">日均单车</div>
|
||||
<div className="text-xs font-black text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆详情清单</span>
|
||||
<span className="text-[9px] font-bold text-slate-300">{total} 条</span>
|
||||
@@ -1072,7 +1254,7 @@ export default function MonitoringView() {
|
||||
{!v.isDataSynced && v.totalKm == null && (
|
||||
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
|
||||
)}
|
||||
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none">今</span>
|
||||
<span className="text-[7px] font-black text-blue-600/50 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none" title={isRangeMode ? '区间内里程' : '当日里程'}>区</span>
|
||||
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
|
||||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>km</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
||||
</div>
|
||||
|
||||
@@ -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 => (
|
||||
<button
|
||||
key={target.id}
|
||||
onClick={() => setSelectedTargetId(target.id)}
|
||||
onClick={() => {
|
||||
setSelectedTargetId(target.id);
|
||||
setExpandedTargetId(target.id);
|
||||
}}
|
||||
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
|
||||
selectedTargetId === target.id
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
||||
@@ -133,6 +150,45 @@ export default function StatisticsView() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedTarget && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="grid grid-cols-2 gap-2 md:grid-cols-4"
|
||||
>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">考核压力</div>
|
||||
<div className={`mt-1 text-lg font-black ${pressureLevel === '健康' ? 'text-emerald-600' : pressureLevel === '关注' ? 'text-amber-600' : 'text-rose-600'}`}>
|
||||
{pressureLevel}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">完成率 {fmtPercent(selectedCompletion)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">剩余缺口</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">{fmtKm(Math.max(0, selectedRemaining))}<span className="ml-1 text-[10px] text-slate-400">km</span></div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">剩余 {selectedDaysLeft} 天</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">日均需完成</div>
|
||||
<div className="mt-1 text-lg font-black text-blue-600">
|
||||
{selectedDaysLeft > 0 ? (
|
||||
<>
|
||||
{fmtKm(selectedDailyTarget)}<span className="ml-1 text-[10px] text-slate-400">km</span>
|
||||
</>
|
||||
) : '已到期'}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">按当前考核口径</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">最新日变化</div>
|
||||
<div className={`mt-1 text-lg font-black ${trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(selectedQualifiedRate)}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
|
||||
{/* Left Side: Trend Chart / Dashboard Sidebar */}
|
||||
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
|
||||
@@ -193,9 +249,8 @@ export default function StatisticsView() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 w-full min-h-[250px] relative">
|
||||
<div className="absolute inset-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<div className="h-[280px] w-full min-w-0">
|
||||
<ResponsiveContainer width="100%" height={280} minWidth={0}>
|
||||
{chartType === 'bar' ? (
|
||||
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
||||
@@ -237,7 +292,6 @@ export default function StatisticsView() {
|
||||
</AreaChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,9 +299,12 @@ export default function StatisticsView() {
|
||||
{/* Right Side: Summary Section */}
|
||||
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
|
||||
<div className="flex items-center justify-between px-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest">车型考核里程汇总</h3>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-xs font-black text-slate-700">当前车型考核详情</h3>
|
||||
<p className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{selectedTarget?.targetName || '请选择车型'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsTableFullscreen(true)}
|
||||
@@ -258,7 +315,7 @@ export default function StatisticsView() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
|
||||
{targets.map((target, idx) => (
|
||||
{(selectedTarget ? [selectedTarget] : []).map((target, idx) => (
|
||||
(() => {
|
||||
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
|
||||
const primaryCompletion = assessment?.completionRate ?? target.avgCompletion;
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: expandedTargetId === target.id ? 180 : 0 }}
|
||||
animate={{ rotate: 180 }}
|
||||
className="text-slate-300"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
|
||||
@@ -22,6 +22,8 @@ export async function fetchMonitoring(params?: {
|
||||
mileageMin?: string;
|
||||
mileageMax?: string;
|
||||
date?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<MonitoringData> {
|
||||
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<MonitoringData>(`${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<DailyReportData> {
|
||||
const [targets, trend, monitoring, topMonitoring] = await Promise.all([
|
||||
fetchTargets(),
|
||||
fetchTrend(undefined, 7),
|
||||
fetchMonitoring({ limit: 1 }),
|
||||
fetchMonitoring({ sortBy: 'today', sortOrder: 'desc', limit: 5 }),
|
||||
]);
|
||||
|
||||
const targetVehiclesEntries = await Promise.all(
|
||||
targets.map(async target => {
|
||||
const vehicles = await fetchTargetVehicles(target.id);
|
||||
return [target.id, vehicles] as const;
|
||||
}),
|
||||
);
|
||||
const targetVehiclesMap = new Map(targetVehiclesEntries);
|
||||
|
||||
const models: DailyReportModel[] = targets.map(target => {
|
||||
const vehicles = targetVehiclesMap.get(target.id) ?? [];
|
||||
const active = vehicles.filter(vehicle => vehicle.todayMileage > 0).length;
|
||||
return {
|
||||
id: target.id,
|
||||
name: compactTargetName(target.targetName),
|
||||
count: target.vehicleCount,
|
||||
today: target.todayTotal,
|
||||
total: target.cumulativeTotal,
|
||||
completion: target.avgCompletion,
|
||||
active,
|
||||
zero: Math.max(0, target.vehicleCount - active),
|
||||
dailyNeed: target.dailyTarget,
|
||||
};
|
||||
});
|
||||
|
||||
const targetNameByPlate = new Map<string, string>();
|
||||
for (const target of targets) {
|
||||
const vehicles = targetVehiclesMap.get(target.id) ?? [];
|
||||
for (const vehicle of vehicles) targetNameByPlate.set(vehicle.plateNumber, compactTargetName(target.targetName));
|
||||
}
|
||||
|
||||
const topVehicles: DailyReportVehicle[] = topMonitoring.vehicles.map(vehicle => ({
|
||||
plate: vehicle.plate,
|
||||
model: targetNameByPlate.get(vehicle.plate) || vehicle.project || '未归入考核',
|
||||
status: normalizeStatus(vehicle.rentStatus),
|
||||
today: vehicle.dailyKm,
|
||||
customer: vehicle.customer || '未绑定客户',
|
||||
}));
|
||||
|
||||
const zeroRisk = targetVehiclesEntries
|
||||
.flatMap(([targetId, vehicles]) => {
|
||||
const target = targets.find(item => item.id === targetId);
|
||||
const model = target ? compactTargetName(target.targetName) : '未归入考核';
|
||||
return vehicles
|
||||
.filter(vehicle => vehicle.todayMileage <= 0 && ['自营', '租赁'].includes(normalizeStatus(vehicle.rentStatus)))
|
||||
.map(vehicle => ({
|
||||
plate: vehicle.plateNumber,
|
||||
model,
|
||||
status: normalizeStatus(vehicle.rentStatus),
|
||||
customer: vehicle.customer || '未绑定客户',
|
||||
completion: vehicle.completionRate,
|
||||
}));
|
||||
})
|
||||
.sort((a, b) => (b.completion ?? 0) - (a.completion ?? 0))
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
reportDate: reportDateFromUpdatedAt(monitoring.updatedAt),
|
||||
updatedAt: monitoring.updatedAt,
|
||||
models,
|
||||
trend: trend.map(item => ({ date: item.date, value: item.mileage })),
|
||||
topVehicles,
|
||||
zeroRisk,
|
||||
qualifiedCount: targets.reduce((sum, target) => sum + target.yearQualifiedCount, 0),
|
||||
halfQualifiedCount: targets.reduce((sum, target) => sum + target.halfQualifiedCount, 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface MonitoringVehicle {
|
||||
plate: string;
|
||||
vin: string;
|
||||
dailyKm: number;
|
||||
dailyMileage?: Record<string, number>;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
isOnline: boolean;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user