fix(mileage): 交投高里程车辆标红
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
kkfluous
2026-06-03 15:10:36 +08:00
parent cc778f3701
commit 5bb3ceb47a
4 changed files with 79 additions and 23 deletions

View File

@@ -12,6 +12,12 @@ import PlateMultiSelect from './PlateMultiSelect';
import { exportMileageXlsx } from './xlsx-export'; import { exportMileageXlsx } from './xlsx-export';
import VehicleDetailModal from './VehicleDetailModal'; import VehicleDetailModal from './VehicleDetailModal';
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
'交投40辆4.5T普货',
'交投190辆4.5T冷链车',
]);
const HIGH_MILEAGE_ALERT_KM = 800;
const SearchableSelect = ({ const SearchableSelect = ({
options, options,
value, value,
@@ -137,6 +143,12 @@ export default function MonitoringView() {
const departments = filterOptions.departments; const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates; const plateNumbers = filterOptions.plates;
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name))
|| HIGH_MILEAGE_ALERT_TARGETS.has(filterTargetName);
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
}, [filterTargetName]);
// 加载首页数据 // 加载首页数据
const loadFirstPage = useCallback(() => { const loadFirstPage = useCallback(() => {
setPageLoading(true); setPageLoading(true);
@@ -525,7 +537,9 @@ export default function MonitoringView() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/30"> <tbody className="divide-y divide-slate-800/30">
{fullscreenVehicles.map((v) => ( {fullscreenVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
return (
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors"> <tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-2 text-center"> <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 || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div> <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 || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
@@ -535,8 +549,8 @@ export default function MonitoringView() {
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td> <td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td> <td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? 'text-blue-400' : 'text-amber-400'}`}> <span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
{(v.isDataSynced || v.totalKm != null) ? <>{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>} {(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>km</span></> : <span className="text-[8px] text-amber-500/50"></span>}
</span> </span>
</td> </td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
@@ -545,7 +559,8 @@ export default function MonitoringView() {
</span> </span>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -896,7 +911,9 @@ export default function MonitoringView() {
)} )}
<div className="grid grid-cols-1 gap-1.5"> <div className="grid grid-cols-1 gap-1.5">
{filteredVehicles.map((v) => ( {filteredVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
@@ -930,8 +947,8 @@ export default function MonitoringView() {
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div> <div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
)} )}
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none"></span> <span className="text-[7px] font-black text-blue-600/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 || v.totalKm != null) ? 'text-blue-600' : 'text-amber-600'}`}> <div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></> : <span className="text-[7px] text-amber-500/70"></span>} {(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>km</span></> : <span className="text-[7px] text-amber-500/70"></span>}
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -942,7 +959,8 @@ export default function MonitoringView() {
</div> </div>
</div> </div>
</motion.div> </motion.div>
))} );
})}
</div> </div>
{filteredVehicles.length === 0 && !loadingMore && ( {filteredVehicles.length === 0 && !loadingMore && (

View File

@@ -13,6 +13,7 @@ export interface MonitoringVehicle {
entity: string | null; entity: string | null;
project: string | null; project: string | null;
region: string | null; region: string | null;
targetNames: string[];
} }
export interface MonitoringStats { export interface MonitoringStats {

View File

@@ -65,6 +65,41 @@ interface MileageRow {
source: string; source: string;
} }
interface TargetRow {
id: number;
target_name: string;
plate_number: string;
}
async function fetchTargetRows(): Promise<TargetRow[]> {
return pool.execute(
`SELECT t.id, t.target_name, v.plate_number
FROM tab_mileage_assessment_target t
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`
).then(([rows]) => rows as TargetRow[]);
}
function buildTargetPlatesMap(targetRows: TargetRow[]): Map<string, Set<string>> {
const targetPlatesMap = new Map<string, Set<string>>();
for (const r of targetRows) {
const set = targetPlatesMap.get(r.target_name) || new Set();
set.add(r.plate_number);
targetPlatesMap.set(r.target_name, set);
}
return targetPlatesMap;
}
function buildPlateTargetNamesMap(targetRows: TargetRow[]): Map<string, string[]> {
const map = new Map<string, string[]>();
for (const r of targetRows) {
const list = map.get(r.plate_number) || [];
list.push(r.target_name);
map.set(r.plate_number, list);
}
return map;
}
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> { async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULLG7 只回传日增量), // v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULLG7 只回传日增量),
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值, // 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
@@ -115,6 +150,7 @@ function mergeVehicles(
yesterdayMap: Map<string, number>, yesterdayMap: Map<string, number>,
bizTotalMap: Map<string, number>, bizTotalMap: Map<string, number>,
latestPgTotalMap: Map<string, number>, latestPgTotalMap: Map<string, number>,
targetNamesByPlate: Map<string, string[]>,
): CachedVehicle[] { ): CachedVehicle[] {
const mileageMap = new Map<string, MileageRow>(); const mileageMap = new Map<string, MileageRow>();
for (const row of mileageRows) { for (const row of mileageRows) {
@@ -147,6 +183,7 @@ function mergeVehicles(
entity: info?.entity || null, entity: info?.entity || null,
project: info?.project || null, project: info?.project || null,
region: regionMap[m.plate] || null, region: regionMap[m.plate] || null,
targetNames: targetNamesByPlate.get(m.plate) || [],
yesterdayKm: yesterdayMap.get(m.plate) || 0, yesterdayKm: yesterdayMap.get(m.plate) || 0,
}; };
}); });
@@ -184,25 +221,16 @@ export async function refreshMonitoringCache(): Promise<void> {
return map; return map;
})(), })(),
fetchVehicleInfoMap(), fetchVehicleInfoMap(),
pool.execute( fetchTargetRows(),
`SELECT t.id, t.target_name, v.plate_number
FROM tab_mileage_assessment_target t
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
fetchBizTotalMileageMap(), fetchBizTotalMileageMap(),
fetchLatestPgTotalMileageMap(), fetchLatestPgTotalMileageMap(),
]); ]);
const targetPlatesMap = new Map<string, Set<string>>(); const targetPlatesMap = buildTargetPlatesMap(targetRows);
for (const r of targetRows) { const targetNamesByPlate = buildPlateTargetNamesMap(targetRows);
const set = targetPlatesMap.get(r.target_name) || new Set();
set.add(r.plate_number);
targetPlatesMap.set(r.target_name, set);
}
const targetNames = Array.from(targetPlatesMap.keys()); const targetNames = Array.from(targetPlatesMap.keys());
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap); const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap, targetNamesByPlate);
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0); const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0); const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
@@ -221,7 +249,7 @@ export async function refreshMonitoringCache(): Promise<void> {
} }
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> { export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
const [mileageRows, yesterdayRows, infoMap, bizTotalMap, latestPgTotalMap] = await Promise.all([ const [mileageRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
mileagePool.execute( mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?', 'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[dateStr] [dateStr]
@@ -231,6 +259,7 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
[dateStr] [dateStr]
).then(([r]) => r as { plate: string; daily_km: string }[]), ).then(([r]) => r as { plate: string; daily_km: string }[]),
fetchVehicleInfoMap(), fetchVehicleInfoMap(),
fetchTargetRows(),
fetchBizTotalMileageMap(), fetchBizTotalMileageMap(),
fetchLatestPgTotalMileageMap(dateStr), fetchLatestPgTotalMileageMap(dateStr),
]); ]);
@@ -242,7 +271,14 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
if (km > existing) yesterdayMap.set(r.plate, km); if (km > existing) yesterdayMap.set(r.plate, km);
} }
return mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap); return mergeVehicles(
mileageRows,
infoMap,
yesterdayMap,
bizTotalMap,
latestPgTotalMap,
buildPlateTargetNamesMap(targetRows),
);
} }
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters { export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {

View File

@@ -15,6 +15,7 @@ export interface CachedVehicle {
entity: string | null; entity: string | null;
project: string | null; project: string | null;
region: string | null; region: string | null;
targetNames: string[];
yesterdayKm: number; yesterdayKm: number;
} }