-
-
-
+
-
-
-
-
-
用户反馈管理
-
查看、回复、跟进用户提交的建议
-
-
-
+
+ )}
+ maxWidth="max-w-5xl"
+ >
{/* 状态过滤 */}
-
+
+
setStatusFilter('')}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
@@ -170,6 +171,7 @@ export default function FeedbackAdminPage() {
))}
+
{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 && }
+
+
默认上周六-本周五 · 提交时间口径
+
+
+ exportFlowDetails()}
+ disabled={!flowStats?.details.length}
+ className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
+ >
+
+ 导出
+
+
+
+
+
+
+
+
+
+
+
{flowStats?.totals.total ?? 0}
+
合计
+
+ {(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => (
+
+
+ {flowStats?.totals[type] ?? 0}
+
+
{FLOW_META[type].label}
+
+ ))}
+
+
+
setFlowDailyExpanded((prev) => !prev)}
+ className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
+ >
+ {flowDailyExpanded ? '收起每日明细' : '展开每日明细'}
+
+ {flowStats?.daily.length ?? 0} 天
+
+
+
+
+ {flowDailyExpanded && (
+
+
+ {flowLoading && (
+
正在加载交还车统计...
+ )}
+ {!flowLoading && flowStats?.daily.map((day) => (
+
+
+
+
+ {(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => {
+ const count = day[type];
+ return (
+
setSelectedFlow({ date: day.date, type })}
+ className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
+ >
+ {count}
+ {FLOW_META[type].label}
+
+ );
+ })}
+
+
+ ))}
+ {!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} 条,包含车牌、流转时间、提交时间、部门、负责人和客户
+
+
+
setSelectedFlow(null)}
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
+ >
+
+
+
+
+ exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
+ disabled={selectedFlowDetails.length === 0}
+ className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
+ >
+
+ 导出当前明细
+
+
+
+
+
+
+
+
+
+ | 车牌 |
+ {FLOW_META[selectedFlow.type].label}时间 |
+ 提交时间 |
+ 部门 |
+ 业务负责人 |
+ 客户 |
+
+
+
+ {selectedFlowDetails.map((item) => (
+
+ | {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 (
-
-
-
-
+
{
if (window.history.length > 1) window.history.back();
else { window.location.hash = '#mileage'; }
}}
- className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
+ className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
title="返回"
>
-
-
-
-
-
充电记录导入
-
每日上传 xlsx · 订单编号去重 · 系统车辆自动匹配
-
-
- {user?.userName || ''}
-
+
inputRef.current?.click()}
+ className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800"
+ >
+
+ 上传文件
+
+
+ )}
+ >
{/* 上传区 */}
-
+
);
}
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 (
- onChange(id)}
- className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
- isActive ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
- }`}
- >
-
- {label}
-
- );
- })}
-
-
+
+
);
}
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)}%
+
有里程
+
+
+
+
+
+
+
+
+
+
+ = 0 ? '+' : '-'}${fmtKm(Math.abs(dailyGap))}`}
+ unit="km"
+ helper={dailyGap >= 0 ? '今日高于日需目标' : '今日低于日需目标'}
+ tone={dailyGap >= 0 ? 'emerald' : 'rose'}
+ />
+
+
+
+
+
+ {data.trend.map(item => (
+
+ ))}
+
+
+
+
+
+ {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 */}
-
- setActiveSubTab('monitoring')}
- className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
- >
-
- 实时监控
- {activeSubTab === 'monitoring' && (
-
- )}
-
- setActiveSubTab('statistics')}
- className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
- >
-
- 统计报表
- {activeSubTab === 'statistics' && (
-
- )}
-
- setActiveSubTab('report')}
- className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
- >
-
- 每日汇报
- {activeSubTab === 'report' && (
-
- )}
-
-
-
- {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'}`}
>
- 今日
+ 区间
setSortBy('total')}
@@ -779,6 +833,35 @@ export default function MonitoringView() {
+
+
+ {([
+ ['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 (
+ 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}
+
+ );
+ })}
+
+ {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]) => (
+ applyRangePreset(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 => (
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() {
))}
+ {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 || '请选择车型'}
+
setIsTableFullscreen(true)}
@@ -258,7 +315,7 @@ export default function StatisticsView() {
- {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 */}
-
setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
- className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
- typeFilter === 'qualified'
- ? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
- : 'bg-gradient-to-br from-orange-50 to-amber-50 border border-orange-200/60'
- }`}
- >
-
- 已完成考核目标
-
-
- {loading && !data ? '-' : summary?.qualifiedCount ?? 0}
- 台
-
-
- 换下,腾位给待达标车
-
+
+
setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')} className={typeFilter === 'qualified' ? 'rounded-2xl ring-2 ring-orange-400 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
+
-
- {/* 里程低·换走 — cool blue */}
-
setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')}
- className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
- typeFilter === 'hopeless'
- ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/25'
- : 'bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200/60'
- }`}
- >
-
- 预估无法达标
-
-
- {loading && !data ? '-' : summary?.hopelessCount ?? 0}
- 台
-
-
- 换走,换上快达标的车
-
+ setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')} className={typeFilter === 'hopeless' ? 'rounded-2xl ring-2 ring-blue-500 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
+
-
- {/* 替换建议 — neutral dark */}
- setTypeFilter('all')}
- className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
- typeFilter === 'all'
- ? 'bg-slate-800 text-white shadow-lg shadow-slate-800/25'
- : 'bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200/60'
- }`}
- >
-
- 替换建议
-
-
- {loading && !data ? '-' : summary?.suggestionCount ?? 0}
- 条
-
-
- 执行后预计 +{summary?.estimatedGain ?? 0} 台达标
-
+ setTypeFilter('all')} className={typeFilter === 'all' ? 'rounded-2xl ring-2 ring-slate-700 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
+
-
- {/* 近期已干预 — emerald */}
- { setShowHistory(true); setHistoryRecentOnly(true); }}
- className="p-3.5 rounded-2xl text-left transition-all cursor-pointer bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200/60"
- >
-
- 近期已干预
-
-
- {loading && !data ? '-' : summary?.recentInterventionCount ?? 0}
- 条
-
-
- 最近 7 天 · 点击查看
-
+ { setShowHistory(true); setHistoryRecentOnly(true); }} className="rounded-2xl">
+
@@ -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();