feat: 多项优化 - 全屏加载全部数据、无值筛选、刷新按钮、加载动画、负值显示为0
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- 全屏监控一次加载全部车辆数据,支持完整滚动和筛选
- 客户/部门筛选增加"无值"选项筛选空数据
- 全屏刷新按钮实际触发数据重新加载,带旋转动画
- 全屏筛选时显示加载遮罩
- 负值里程前端显示为0
- 未对接车机显示"未对接"替代"-"
- 删除"未同步"标签
- 统计报表配色统一为白色主题、KPI联动选中项目
- 统计报表全屏表格列合并优化
- 车辆明细面板增加日期选择、租赁状态/部门/客户信息、里程合计
- 每分钟自动刷新数据
- 清除按钮修复租赁状态重置

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-02 10:52:45 +08:00
parent 06a2edc470
commit adc9c3a9db
5 changed files with 338 additions and 224 deletions

View File

@@ -33,7 +33,7 @@ const SearchableSelect = ({
<input
type="text"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20 placeholder:text-slate-400"
placeholder={value === 'All' ? placeholder : value}
placeholder={value === 'All' ? placeholder : value === '__EMPTY__' ? '无值' : value}
value={search}
onFocus={() => setIsOpen(true)}
onChange={(e) => setSearch(e.target.value)}
@@ -73,7 +73,7 @@ const SearchableSelect = ({
setIsOpen(false);
}}
>
{opt}
{opt === '__EMPTY__' ? '无值' : opt}
</div>
))}
{filtered.length === 0 && (
@@ -95,6 +95,10 @@ export default function MonitoringView() {
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
const [fullscreenLoading, setFullscreenLoading] = useState(false);
// New filters from image
const [filterPlate, setFilterPlate] = useState('All');
@@ -183,6 +187,12 @@ export default function MonitoringView() {
loadFirstPage();
}, [loadFirstPage]);
// 每分钟自动刷新
useEffect(() => {
const timer = setInterval(loadFirstPage, 60 * 1000);
return () => clearInterval(timer);
}, [loadFirstPage]);
// 触底检测:用 IntersectionObserver 监听哨兵元素
const loadMoreRef = useRef(loadMore);
loadMoreRef.current = loadMore;
@@ -226,6 +236,28 @@ export default function MonitoringView() {
setIsFullscreen(!isFullscreen);
};
// 全屏时加载全部数据(无分页),筛选变化时重新加载
useEffect(() => {
if (!isFullscreen) return;
setFullscreenLoading(true);
fetchMonitoring({
sortBy,
sortOrder,
limit: 9999,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
plate: filterPlate !== 'All' ? filterPlate : undefined,
date: filterDate || undefined,
}).then(d => {
setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats);
setFilterOptions(d.filters);
}).catch(() => {}).finally(() => setFullscreenLoading(false));
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlate, filterDate, fullscreenRefresh]);
// 全屏时禁止背景滚动
useEffect(() => {
if (isFullscreen) {
@@ -258,19 +290,19 @@ 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(stats.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(stats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{stats.vehicleCount}</span> </span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> </span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{(stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-500"> <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'today' ? fullscreenStats.totalToday : fullscreenStats.totalAll) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterPlate('All'); setSearchTerm(''); }}
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
onClick={() => { setFullscreenRefresh(n => n + 1); }}
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
>
<RotateCcw size={13} />
</button>
@@ -300,7 +332,15 @@ export default function MonitoringView() {
</div>
</div>
<div className="flex-1 overflow-auto">
<div className="flex-1 overflow-auto relative">
{fullscreenLoading && (
<div className="absolute inset-0 bg-slate-950/60 z-20 flex items-center justify-center">
<div className="flex items-center gap-2 text-slate-400 text-xs font-bold">
<RotateCcw size={14} className="animate-spin" />
...
</div>
</div>
)}
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-slate-900 z-10">
<tr className="border-b border-slate-800/60">
@@ -327,6 +367,7 @@ export default function MonitoringView() {
onChange={(e) => setFilterCustomer(e.target.value)}
>
<option value="All"></option>
<option value="__EMPTY__"></option>
{filterOptions.customers.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
@@ -353,6 +394,7 @@ export default function MonitoringView() {
onChange={(e) => setFilterDept(e.target.value)}
>
<option value="All"></option>
<option value="__EMPTY__"></option>
{departments.map(d => <option key={d} value={d}>{d.replace('业务', '')}</option>)}
</select>
</div>
@@ -396,7 +438,7 @@ export default function MonitoringView() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{filteredVehicles.map((v) => (
{fullscreenVehicles.map((v) => (
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-2 text-center">
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : v.isDataSynced ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
@@ -407,12 +449,12 @@ export default function MonitoringView() {
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
<td className="px-3 py-2 text-right">
<span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-blue-400' : 'text-amber-400'}`}>
{v.isDataSynced ? <>{v.dailyKm?.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : '-'}
{v.isDataSynced ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50"></span>}
</span>
</td>
<td className="px-3 py-2 text-right">
<span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-slate-300' : 'text-slate-600'}`}>
{v.isDataSynced && v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : '-'}
{v.isDataSynced && v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50"></span>}
</span>
</td>
</tr>
@@ -478,13 +520,13 @@ export default function MonitoringView() {
<div className="flex items-center gap-2">
<div className="flex-1 grid grid-cols-3 gap-1.5">
<SearchableSelect
options={departments}
options={['__EMPTY__', ...departments]}
value={filterDept}
onChange={setFilterDept}
placeholder="按部门"
/>
<SearchableSelect
options={filterOptions.customers}
options={['__EMPTY__', ...filterOptions.customers]}
value={filterCustomer}
onChange={setFilterCustomer}
placeholder="按客户"
@@ -550,6 +592,7 @@ export default function MonitoringView() {
onChange={(e) => setFilterDept(e.target.value)}
>
<option value="All"></option>
<option value="__EMPTY__"></option>
{departments.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
@@ -658,8 +701,8 @@ export default function MonitoringView() {
{(() => {
const tags: { label: string; onClear: () => void }[] = [];
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept}`, onClear: () => setFilterDept('All') });
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer}`, onClear: () => setFilterCustomer('All') });
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('All') });
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => setFilterCustomer('All') });
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') });
if (filterPlate !== 'All') tags.push({ label: `车牌: ${filterPlate}`, onClear: () => setFilterPlate('All') });
@@ -670,7 +713,7 @@ export default function MonitoringView() {
if (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') });
if (tags.length === 0) return null;
const clearAll = () => {
setFilterDept('All'); setFilterCustomer('All'); setFilterProject('All'); setFilterEntity('All');
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
setFilterPlate('All'); setSearchTerm(''); setFilterRegionCode('All');
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
setFilterDate('');
@@ -763,17 +806,14 @@ export default function MonitoringView() {
)}
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none"></span>
<div className={`text-sm font-black leading-none ${v.isDataSynced ? 'text-blue-600' : 'text-amber-600'}`}>
{v.isDataSynced ? <>{v.dailyKm?.toLocaleString()} <span className="text-[8px] text-slate-400">km</span></> : '-'}
{v.isDataSynced ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></> : <span className="text-[7px] text-amber-500/70"></span>}
</div>
</div>
<div className="flex items-center gap-1">
<span className="text-[7px] font-black text-slate-400/60 bg-slate-100 w-3 h-3 rounded flex items-center justify-center leading-none"></span>
<span className="text-[8px] font-bold text-slate-300">
{v.isDataSynced && v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '-'}
{v.isDataSynced && v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '未对接'}
</span>
{!v.isDataSynced && (
<span className="text-[7px] font-bold text-amber-500/70 bg-amber-50 px-1 rounded"></span>
)}
</div>
</div>
</motion.div>

View File

@@ -7,11 +7,17 @@ import {
} from 'recharts';
import {
Truck, ChevronDown, Maximize2, Minimize2,
Search, ArrowUpDown, X,
Search, ArrowUpDown, X, RotateCcw, Calendar,
} from 'lucide-react';
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
function getDefaultDate(): 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 fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
return value.toLocaleString();
@@ -44,6 +50,8 @@ export default function StatisticsView() {
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
const [viewAllSearch, setViewAllSearch] = useState('');
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
const [viewAllLoading, setViewAllLoading] = useState(false);
// Load targets on mount
useEffect(() => {
@@ -61,10 +69,19 @@ export default function StatisticsView() {
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
}, [selectedTargetId]);
// Re-fetch target vehicles when viewAllDate changes
useEffect(() => {
if (viewAllTargetId === null) return;
setViewAllLoading(true);
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
}).catch(() => {}).finally(() => setViewAllLoading(false));
}, [viewAllTargetId, viewAllDate]);
return (
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1" style={{ overflowX: 'clip' }}>
{/* Project Selector - Full width even in landscape */}
<div className="bg-white landscape:bg-slate-900/50 landscape:border-slate-800 p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
{/* Project Selector */}
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
{targets.map(target => (
<button
key={target.id}
@@ -72,7 +89,7 @@ export default function StatisticsView() {
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'
: 'bg-slate-50 landscape:bg-slate-800 text-slate-500 landscape:text-slate-400 hover:bg-slate-100 landscape:hover:bg-slate-700'
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
}`}
>
{shortTargetName(target.targetName)}
@@ -83,51 +100,56 @@ export default function StatisticsView() {
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
{/* Left Side: Trend Chart / Dashboard Sidebar */}
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
{/* KPI Cards in Landscape */}
<div className="hidden landscape:grid grid-cols-4 gap-4 flex-shrink-0">
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-xl font-black text-white tracking-tighter">
{fmtKm(targets.reduce((sum, t) => sum + t.todayTotal, 0))}
<span className="text-blue-400 text-[10px] ml-1">KM</span>
{/* KPI Cards in Landscape — linked to selected target */}
{(() => {
const sel = targets.find(t => t.id === selectedTargetId);
return (
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{fmtKm(sel?.todayTotal ?? 0)}
<span className="text-blue-500 text-[10px] ml-1">KM</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{fmtKm(sel?.cumulativeTotal ?? 0)}
<span className="text-blue-500 text-[10px] ml-1">KM</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{sel?.vehicleCount ?? 0}
<span className="text-blue-500 text-[10px] ml-1"></span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{(sel?.avgCompletion ?? 0).toFixed(1)}
<span className="text-blue-500 text-[10px] ml-1">%</span>
</div>
</div>
</div>
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-xl font-black text-white tracking-tighter">
{fmtKm(targets.reduce((sum, t) => sum + t.cumulativeTotal, 0))}
<span className="text-blue-400 text-[10px] ml-1">KM</span>
</div>
</div>
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-xl font-black text-white tracking-tighter">
{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}
<span className="text-blue-400 text-[10px] ml-1"></span>
</div>
</div>
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-xl font-black text-white tracking-tighter">
{targets.length > 0 ? (targets.reduce((sum, t) => sum + t.avgCompletion, 0) / targets.length).toFixed(1) : '0.0'}
<span className="text-blue-400 text-[10px] ml-1">%</span>
</div>
</div>
</div>
);
})()}
<div className="bg-white landscape:bg-slate-900/40 landscape:border-slate-800 p-4 rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-[300px] overflow-hidden">
<div className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-[300px] overflow-hidden">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" />
<h3 className="text-sm font-bold text-slate-800 landscape:text-white">7</h3>
<h3 className="text-sm font-bold text-slate-800">7</h3>
</div>
<div className="flex bg-slate-50 landscape:bg-slate-800 p-1 rounded-lg">
<div className="flex bg-slate-50 p-1 rounded-lg">
{(['bar', 'line', 'area'] as const).map(type => (
<button
key={type}
onClick={() => setChartType(type)}
className={`px-2 py-1 rounded-md text-[10px] font-bold transition-all ${
chartType === type ? 'bg-white landscape:bg-slate-700 text-blue-600 landscape:text-blue-400 shadow-sm' : 'text-slate-400'
chartType === type ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'
}`}
>
{type === 'bar' ? '柱状' : type === 'line' ? '折线' : '面积'}
@@ -140,7 +162,7 @@ export default function StatisticsView() {
<ResponsiveContainer width="100%" height="100%">
{chartType === 'bar' ? (
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" strokeOpacity={0.1} />
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip cursor={{ fill: '#f8fafc', fillOpacity: 0.1 }} contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
@@ -153,7 +175,7 @@ export default function StatisticsView() {
</BarChart>
) : chartType === 'line' ? (
<LineChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" strokeOpacity={0.1} />
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
@@ -169,7 +191,7 @@ export default function StatisticsView() {
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" strokeOpacity={0.1} />
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
@@ -193,7 +215,7 @@ export default function StatisticsView() {
</div>
<button
onClick={() => setIsTableFullscreen(true)}
className="p-1.5 bg-white landscape:bg-slate-800 text-slate-400 rounded-lg border border-slate-100 landscape:border-slate-700 shadow-sm hover:text-blue-600 transition-colors"
className="p-1.5 bg-white text-slate-400 rounded-lg border border-slate-100 shadow-sm hover:text-blue-600 transition-colors"
>
<Maximize2 size={14} />
</button>
@@ -203,7 +225,7 @@ export default function StatisticsView() {
{targets.map((target, idx) => (
<div
key={idx}
className="bg-white landscape:bg-slate-900/50 px-3 py-2 rounded-xl border border-slate-50 landscape:border-slate-800 shadow-sm flex flex-col active:bg-slate-50 landscape:active:bg-slate-800 transition-all cursor-pointer"
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
onClick={() => {
const name = target.targetName;
setExpandedModel(expandedModel === name ? null : name);
@@ -216,13 +238,13 @@ export default function StatisticsView() {
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="w-8 h-8 rounded-lg bg-slate-50 landscape:bg-slate-800 flex items-center justify-center flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0">
<Truck size={14} className="text-slate-400" />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 landscape:text-white">{target.targetName}</span>
<span className="text-[8px] px-1 rounded bg-blue-50 landscape:bg-blue-900/30 text-blue-600 landscape:text-blue-400 font-bold">{target.vehicleCount}</span>
<span className="text-xs font-black text-slate-900">{target.targetName}</span>
<span className="text-[8px] px-1 rounded bg-blue-50 text-blue-600 font-bold">{target.vehicleCount}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
@@ -231,14 +253,14 @@ export default function StatisticsView() {
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">:</span>
<span className="text-[9px] font-bold text-slate-600 landscape:text-slate-400">{target.yearQualifiedCount}</span>
<span className="text-[9px] font-bold text-slate-600">{target.yearQualifiedCount}</span>
</div>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2 flex items-center gap-3">
<div className="flex flex-col items-end">
<div className="text-sm font-black text-slate-900 landscape:text-white leading-none mb-0.5">
<div className="text-sm font-black text-slate-900 leading-none mb-0.5">
{fmtKm(target.todayTotal)} <span className="text-[8px] text-slate-300 font-bold uppercase">KM</span>
</div>
<div className="text-[8px] font-bold text-slate-300">
@@ -262,28 +284,28 @@ export default function StatisticsView() {
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="pt-3 mt-2 border-t border-slate-50 landscape:border-slate-800 grid grid-cols-2 gap-x-4 gap-y-3">
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
{target.periods.map((p, i) => (
<p key={i} className="text-[10px] font-black text-slate-700 landscape:text-slate-300">{p}</p>
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-slate-700 landscape:text-slate-300">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">/</p>
<p className="text-[10px] font-black text-slate-700 landscape:text-slate-300">{fmtKm(target.annualMileagePerVehicle)} km</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%</p>
<p className="text-[10px] font-black text-blue-600 landscape:text-blue-400">{target.halfQualifiedCount} </p>
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} </p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-slate-700 landscape:text-slate-300">{fmtKm(target.currentYearTarget)} km</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">(3.31)</p>
@@ -297,9 +319,9 @@ export default function StatisticsView() {
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</p>
</div>
<div className="col-span-2 flex items-center justify-between bg-slate-50 landscape:bg-slate-800 p-2 rounded-lg">
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
<span className="text-[9px] font-bold text-slate-500"></span>
<span className="text-[10px] font-black text-slate-900 landscape:text-white">{target.daysLeft} </span>
<span className="text-[10px] font-black text-slate-900">{target.daysLeft} </span>
</div>
{/* Vehicle List Detail */}
@@ -311,11 +333,7 @@ export default function StatisticsView() {
e.stopPropagation();
setViewAllTargetId(target.id);
setViewAllTargetName(target.targetName);
if (!targetVehiclesMap[target.id]) {
fetchTargetVehicles(target.id).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
}).catch(() => {});
}
setViewAllDate(getDefaultDate());
}}
className="text-[8px] text-blue-500 font-bold hover:underline"
>
@@ -324,15 +342,15 @@ export default function StatisticsView() {
</div>
<div className="space-y-1">
{(targetVehiclesMap[target.id] || []).slice(0, 5).map(tv => (
<div key={tv.plateNumber} className="bg-slate-50/50 landscape:bg-slate-800/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
<div key={tv.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono font-bold text-slate-700 landscape:text-slate-300">{tv.plateNumber}</span>
<span className="text-[7px] px-1 rounded bg-green-100 landscape:bg-green-900/30 text-green-600 landscape:text-green-400 font-bold">
<span className="text-[10px] font-mono font-bold text-slate-700">{tv.plateNumber}</span>
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
线
</span>
</div>
<div className="text-right">
<span className="text-[10px] font-black text-blue-600 landscape:text-blue-400">{tv.todayMileage}</span>
<span className="text-[10px] font-black text-blue-600">{tv.todayMileage}</span>
<span className="text-[8px] text-slate-400 ml-1">KM</span>
</div>
</div>
@@ -356,115 +374,96 @@ export default function StatisticsView() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-slate-950 flex flex-col p-4 landscape:flex-row gap-4 overflow-hidden"
className="fixed inset-0 z-[100] bg-slate-950 flex flex-col overflow-hidden"
>
{/* Sidebar with KPI Cards */}
<div className="flex flex-col gap-4 w-full landscape:w-72 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
<h2 className="text-white font-bold text-lg"></h2>
{/* Top bar: compact inline KPI */}
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
<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">{fmtKm(targets.reduce((sum, t) => sum + t.todayTotal, 0))}</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">{fmtKm(targets.reduce((sum, t) => sum + t.cumulativeTotal, 0))}</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">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> </span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + t.avgCompletion, 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { fetchTargets().then(data => { setTargets(data); }).catch(() => {}); }}
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
>
<RotateCcw size={13} />
</button>
<button
onClick={() => setIsTableFullscreen(false)}
className="p-2 bg-slate-800 text-slate-400 rounded-full hover:text-white transition-colors"
className="p-1.5 text-slate-500 hover:text-white transition-colors"
>
<Minimize2 size={20} />
<Minimize2 size={14} />
</button>
</div>
<div className="grid grid-cols-2 landscape:grid-cols-1 gap-3">
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-2xl font-black text-white tracking-tighter">
{fmtKm(targets.reduce((sum, t) => sum + t.todayTotal, 0))}
<span className="text-blue-400 text-xs ml-2">KM</span>
</div>
</div>
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-2xl font-black text-white tracking-tighter">
{fmtKm(targets.reduce((sum, t) => sum + t.cumulativeTotal, 0))}
<span className="text-blue-400 text-xs ml-2">KM</span>
</div>
</div>
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-2xl font-black text-white tracking-tighter">
{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}
<span className="text-blue-400 text-xs ml-2"></span>
</div>
</div>
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-2xl font-black text-white tracking-tighter">
{targets.length > 0 ? (targets.reduce((sum, t) => sum + t.avgCompletion, 0) / targets.length).toFixed(1) : '0.0'}
<span className="text-blue-400 text-xs ml-2">%</span>
</div>
</div>
</div>
</div>
<div className="flex-1 bg-slate-900/30 border border-slate-800 rounded-2xl overflow-hidden flex flex-col">
<div className="p-4 border-b border-slate-800 flex justify-between items-center bg-slate-900/50">
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest"></span>
<span className="text-[10px] text-slate-500">: {new Date().toLocaleTimeString()}</span>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full text-left border-collapse min-w-[1200px]">
<thead className="sticky top-0 bg-slate-900 z-10">
<tr className="border-b border-slate-800">
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-10"></th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase"></th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase"></th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase"></th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase"></th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase"></th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">/</th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase text-emerald-400"></th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase text-blue-400">50%</th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase text-white"></th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase"></th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">(3.31)</th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase text-rose-400"></th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase"></th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase text-blue-400"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{targets.map((target, idx) => (
<tr key={idx} className="hover:bg-slate-800/30 transition-colors">
<td className="p-4 text-sm font-bold text-white sticky left-0 bg-slate-900 z-10 border-r border-slate-800">{target.targetName}</td>
<td className="p-4 text-xs text-slate-300">{target.vehicleCount}</td>
<td className="p-4 text-xs text-slate-300">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</td>
<td className="p-4 text-xs text-slate-300">{fmtKm(target.cumulativeTotal)} km</td>
<td className="p-4">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden min-w-[60px]">
<div
className={`h-full rounded-full ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 80 ? 'bg-blue-500' : 'bg-amber-500'}`}
style={{ width: `${target.avgCompletion}%` }}
/>
</div>
<span className="text-[10px] font-bold text-white">{target.avgCompletion.toFixed(1)}%</span>
{/* Table Area */}
<div className="flex-1 overflow-auto">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-slate-900 z-10">
<tr className="border-b border-slate-800/60">
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">/</th>
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center"></th>
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%</th>
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14"></th>
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{targets.map((target, idx) => (
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
</td>
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
<td className="px-3 py-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
style={{ width: `${Math.min(target.avgCompletion, 100)}%` }}
/>
</div>
</td>
<td className="p-4 text-[10px] text-slate-400">{target.periods.join('\n')}</td>
<td className="p-4 text-xs text-slate-300">{fmtKm(target.annualMileagePerVehicle)} km</td>
<td className="p-4 text-xs font-bold text-emerald-400">{target.yearQualifiedCount}</td>
<td className="p-4 text-xs font-bold text-blue-400">{target.halfQualifiedCount}</td>
<td className="p-4 text-xs font-bold text-white">{fmtKm(target.todayTotal)} km</td>
<td className="p-4 text-xs text-slate-300">{fmtKm(target.currentYearTarget)} km</td>
<td className="p-4 text-xs text-slate-300">{fmtKm(target.currentYearCompleted)} km</td>
<td className="p-4 text-xs font-bold text-rose-400">{fmtKm(target.remaining)} km</td>
<td className="p-4 text-xs text-slate-300">{target.daysLeft}</td>
<td className="p-4 text-xs font-bold text-blue-400">{target.dailyTarget} km</td>
</tr>
))}
</tbody>
</table>
</div>
<span className="text-[10px] font-black text-white w-10 text-right">{target.avgCompletion.toFixed(1)}%</span>
</div>
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
<span>{fmtKm(target.cumulativeTotal)}</span>
<span>/ {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</span>
</div>
</td>
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(target.annualMileagePerVehicle)} km</td>
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{target.yearQualifiedCount}</td>
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{target.halfQualifiedCount}</td>
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(target.currentYearTarget)} km</td>
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(target.currentYearCompleted)} km</td>
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(target.remaining)} km</td>
<td className="px-3 py-3 text-xs text-slate-300 text-center">{target.daysLeft}</td>
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">{fmtKm(target.dailyTarget)} km</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
)}
@@ -504,19 +503,36 @@ export default function StatisticsView() {
</button>
</div>
<div className="px-6 py-4 border-b border-slate-50 space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
<input
type="text"
placeholder="搜索车牌号..."
value={viewAllSearch}
onChange={(e) => setViewAllSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
/>
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
<input
type="text"
placeholder="搜索车牌号..."
value={viewAllSearch}
onChange={(e) => setViewAllSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
/>
</div>
<div className="relative flex-shrink-0">
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={13} />
<input
type="date"
value={viewAllDate}
onChange={(e) => setViewAllDate(e.target.value)}
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]"
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">排序方式: 今日里程</span>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{viewAllLoading ? '加载中...' : (() => {
const filtered = (viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv => tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase()));
const totalKm = filtered.reduce((sum, tv) => sum + (tv.todayMileage || 0), 0);
return `${filtered.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
})()}
</span>
<button
onClick={() => setViewAllSort(prev => prev === 'desc' ? 'asc' : 'desc')}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-black active:scale-95 transition-all"
@@ -535,24 +551,30 @@ export default function StatisticsView() {
const valB = b.todayMileage || 0;
return viewAllSort === 'desc' ? valB - valA : valA - valB;
}).map(tv => (
<div key={tv.plateNumber} className="bg-slate-50 p-4 rounded-2xl border border-slate-100 flex items-center justify-between group hover:border-blue-200 transition-all">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-white border border-slate-100 flex items-center justify-center shadow-sm">
<Truck size={18} className="text-slate-400" />
<div key={tv.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Truck size={14} className="text-slate-400" />
</div>
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-black text-slate-900 font-mono">{tv.plateNumber}</span>
<span className="text-[8px] px-1.5 py-0.5 rounded-full font-bold bg-green-100 text-green-600">
线
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono">{tv.plateNumber}</span>
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{tv.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="text-[10px] font-bold text-slate-400 mt-0.5">&nbsp;</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span>
</div>
</div>
</div>
<div className="text-right">
<div className="text-sm font-black text-blue-600">{tv.todayMileage} <span className="text-[9px] text-slate-400">KM</span></div>
<div className="text-[9px] font-bold text-slate-400 mt-0.5">: {fmtKm(tv.totalMileage || 0)} km</div>
<div className="text-right flex-shrink-0 ml-2">
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
<div className="text-[9px] font-bold text-slate-300 mt-0.5">: {fmtKm(tv.totalMileage || 0)} km</div>
</div>
</div>
))}

View File

@@ -47,8 +47,11 @@ export async function fetchTargets(): Promise<TargetSummary[]> {
return fetchJson<TargetSummary[]>(`${BASE}/targets`);
}
export async function fetchTargetVehicles(targetId: number): Promise<TargetVehicle[]> {
return fetchJson<TargetVehicle[]>(`${BASE}/target/${targetId}/vehicles`);
export async function fetchTargetVehicles(targetId: number, date?: string): Promise<TargetVehicle[]> {
const params = new URLSearchParams();
if (date) params.set('date', date);
const qs = params.toString();
return fetchJson<TargetVehicle[]>(`${BASE}/target/${targetId}/vehicles${qs ? `?${qs}` : ''}`);
}
export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoint[]> {

View File

@@ -69,6 +69,10 @@ export interface TargetVehicle {
isQualified: boolean;
currentYearIsQualified: boolean;
dailyRequiredMileage: number;
rentStatus: string | null;
department: string | null;
customer: string | null;
isOnline: boolean;
}
export interface TrendPoint {

View File

@@ -159,7 +159,7 @@ async function refreshMonitoringCache() {
// 启动时立即刷新之后每2分钟刷新
refreshMonitoringCache();
setInterval(refreshMonitoringCache, 2 * 60 * 1000);
setInterval(refreshMonitoringCache, 60 * 1000);
// 查询指定日期的里程数据(非缓存)
async function queryDateMileage(dateStr: string): Promise<{ vehicles: CachedVehicle[] }> {
@@ -258,8 +258,8 @@ app.get('/monitoring', async (c) => {
(v.project || '').toLowerCase().includes(q)
);
}
if (dept) vehicles = vehicles.filter(v => v.department === dept);
if (customer) vehicles = vehicles.filter(v => v.customer === customer);
if (dept) vehicles = vehicles.filter(v => dept === '__EMPTY__' ? !v.department : v.department === dept);
if (customer) vehicles = vehicles.filter(v => customer === '__EMPTY__' ? !v.customer : v.customer === customer);
if (project) vehicles = vehicles.filter(v => v.project === project);
if (entity) vehicles = vehicles.filter(v => v.entity === entity);
if (rentStatus) vehicles = vehicles.filter(v => v.rentStatus === rentStatus);
@@ -398,10 +398,12 @@ app.get('/targets', async (c) => {
}
});
// GET /target/:id/vehicles — 某项目的车辆明细
// GET /target/:id/vehicles — 某项目的车辆明细(支持 ?date= 查询指定日期里程)
app.get('/target/:id/vehicles', async (c) => {
const targetId = c.req.param('id');
const date = c.req.query('date') || '';
try {
// 获取考核车辆基本信息
const [rows] = await pool.execute(
`SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified,
@@ -412,15 +414,58 @@ app.get('/target/:id/vehicles', async (c) => {
[targetId]
) as any;
const result = rows.map((r: any) => ({
plateNumber: r.plate_number,
todayMileage: Number(r.today_mileage) || 0,
totalMileage: Number(r.vehicle_total_mileage) || 0,
completionRate: Number(r.completion_rate) || 0,
isQualified: r.is_qualified === 1,
currentYearIsQualified: r.current_year_is_qualified === 1,
dailyRequiredMileage: Number(r.daily_required_mileage) || 0,
}));
// 获取车辆关联信息(租赁状态、部门、客户)
const plates = rows.map((r: any) => r.plate_number);
const infoMap = new Map<string, any>();
if (plates.length > 0) {
const [infoRows] = await pool.execute(
`${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`,
plates
) as any;
for (const row of infoRows) {
infoMap.set(row.plate, row);
}
}
// 如果指定了日期,从里程数据库查询该日期的里程
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
if (date && plates.length > 0) {
const [mileageRows] = await mileagePool.execute(
`SELECT plate, daily_km, total_km, source FROM v_vehicle_daily_stats
WHERE stat_date = ? AND plate IN (${plates.map(() => '?').join(',')})`,
[date, ...plates]
) as any;
for (const m of mileageRows) {
const existing = dateMileageMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
if (!existing || dailyKm > existing.dailyKm) {
const source = m.source || 'NONE';
dateMileageMap.set(m.plate, {
dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
isOnline: source !== 'NONE' && dailyKm > 0,
});
}
}
}
const result = rows.map((r: any) => {
const info = infoMap.get(r.plate_number);
const dateMileage = date ? dateMileageMap.get(r.plate_number) : null;
return {
plateNumber: r.plate_number,
todayMileage: dateMileage ? dateMileage.dailyKm : (Number(r.today_mileage) || 0),
totalMileage: dateMileage?.totalKm ?? (Number(r.vehicle_total_mileage) || 0),
completionRate: Number(r.completion_rate) || 0,
isQualified: r.is_qualified === 1,
currentYearIsQualified: r.current_year_is_qualified === 1,
dailyRequiredMileage: Number(r.daily_required_mileage) || 0,
rentStatus: info?.rent_status || null,
department: info?.department || null,
customer: info?.customer || null,
isOnline: dateMileage ? dateMileage.isOnline : true,
};
});
return c.json(result);
} catch (e) {