Compare commits
10
Commits
bca0f7fe82
...
1674f8b931
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1674f8b931 | ||
|
|
559094cdc5 | ||
|
|
c0d504cb7b | ||
|
|
d958f3b28f | ||
|
|
f8e926876c | ||
|
|
6bf88a25e3 | ||
|
|
017001e889 | ||
|
|
b11187aba7 | ||
|
|
a344497505 | ||
|
|
d1ed3903fe |
@@ -160,6 +160,8 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
|||||||
|
|
||||||
- 已完成里程考核归因、车辆详情和每日汇报跳转。
|
- 已完成里程考核归因、车辆详情和每日汇报跳转。
|
||||||
- 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。
|
- 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。
|
||||||
|
- 车辆级里程详情继承 `mileageStart`、`mileageEnd` 和数据源优先级;显式下钻日期严格展示所选日,预设区间继续排除尚未完整的当天数据,请求故障显示为不可用并支持重试。
|
||||||
|
- 里程实时监控的日期、来源、批次、区域、车牌、归属、高级筛选和排序已纳入独立 URL 状态;分享或刷新链接可恢复完整查询,车辆详情前进后退保留列表上下文且不把下钻车牌误写为筛选车牌。
|
||||||
- 已完成氢能站点、客户下钻代码。
|
- 已完成氢能站点、客户下钻代码。
|
||||||
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
|
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
|
||||||
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
|
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
|
||||||
@@ -171,6 +173,14 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
|||||||
|
|
||||||
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
|
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
|
||||||
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
|
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
|
||||||
|
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
|
||||||
|
- 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。
|
||||||
|
- 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。
|
||||||
|
- 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。
|
||||||
|
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
|
||||||
|
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
|
||||||
|
- 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。
|
||||||
|
- 里程历史区间监控已复用权限过滤前的分钟级基础快照;筛选、排序、分页和并发同参请求不再重复拉取 OneOS 区间数据及车辆元数据,强制刷新仍可绕过已完成快照,失败请求不进入缓存。
|
||||||
- 已完成电能数据截至时间和实际趋势月展示。
|
- 已完成电能数据截至时间和实际趋势月展示。
|
||||||
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
||||||
- 已完成氢能结构化故障状态与重试体验。
|
- 已完成氢能结构化故障状态与重试体验。
|
||||||
|
|||||||
@@ -5,17 +5,20 @@ import {
|
|||||||
reconcileMileageAttribution,
|
reconcileMileageAttribution,
|
||||||
type AttributionDimension,
|
type AttributionDimension,
|
||||||
} from './attribution';
|
} from './attribution';
|
||||||
|
import { ErrorState } from '../../components/ui/surface';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vehicles: TargetVehicle[];
|
vehicles: TargetVehicle[];
|
||||||
dimension: AttributionDimension;
|
dimension: AttributionDimension;
|
||||||
selectedValue?: string;
|
selectedValue?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
targetMileagePerVehicle: number;
|
targetMileagePerVehicle: number;
|
||||||
expectedShortfallMileage?: number;
|
expectedShortfallMileage?: number;
|
||||||
assessmentLabel?: string;
|
assessmentLabel?: string;
|
||||||
onDimensionChange: (dimension: AttributionDimension) => void;
|
onDimensionChange: (dimension: AttributionDimension) => void;
|
||||||
onSelect: (value: string) => void;
|
onSelect: (value: string) => void;
|
||||||
|
onRetry?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtKm(value: number): string {
|
function fmtKm(value: number): string {
|
||||||
@@ -27,11 +30,13 @@ export default function MileageAttributionView({
|
|||||||
dimension,
|
dimension,
|
||||||
selectedValue,
|
selectedValue,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
error,
|
||||||
targetMileagePerVehicle,
|
targetMileagePerVehicle,
|
||||||
expectedShortfallMileage,
|
expectedShortfallMileage,
|
||||||
assessmentLabel,
|
assessmentLabel,
|
||||||
onDimensionChange,
|
onDimensionChange,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onRetry,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const groups = buildMileageAttribution(vehicles, dimension, targetMileagePerVehicle);
|
const groups = buildMileageAttribution(vehicles, dimension, targetMileagePerVehicle);
|
||||||
const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0);
|
const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0);
|
||||||
@@ -49,9 +54,14 @@ export default function MileageAttributionView({
|
|||||||
<h2 className="text-sm font-black text-slate-800">考核缺口归因</h2>
|
<h2 className="text-sm font-black text-slate-800">考核缺口归因</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[10px] font-bold text-slate-400">
|
<p className="mt-1 text-[10px] font-bold text-slate-400">
|
||||||
{assessmentLabel ? `${assessmentLabel} · ` : ''}{totalBehind} 台未达标,累计缺口 {fmtKm(totalShortfall)} km
|
{assessmentLabel ? `${assessmentLabel} · ` : ''}
|
||||||
|
{loading
|
||||||
|
? '正在加载归因车辆'
|
||||||
|
: error
|
||||||
|
? '归因数据暂不可用'
|
||||||
|
: `${totalBehind} 台未达标,累计缺口 ${fmtKm(totalShortfall)} km`}
|
||||||
</p>
|
</p>
|
||||||
{!loading && reconciliation && (
|
{!loading && !error && reconciliation && (
|
||||||
<p
|
<p
|
||||||
role={reconciliation.matches ? 'status' : 'alert'}
|
role={reconciliation.matches ? 'status' : 'alert'}
|
||||||
className={`mt-1 text-[10px] font-black ${reconciliation.matches ? 'text-emerald-600' : 'text-rose-600'}`}
|
className={`mt-1 text-[10px] font-black ${reconciliation.matches ? 'text-emerald-600' : 'text-rose-600'}`}
|
||||||
@@ -84,6 +94,8 @@ export default function MileageAttributionView({
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">正在计算归因...</div>
|
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">正在计算归因...</div>
|
||||||
|
) : error ? (
|
||||||
|
<ErrorState message={error} onRetry={onRetry} variant="inline" />
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">暂无可归因车辆</div>
|
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">暂无可归因车辆</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ import {
|
|||||||
type MileageDrillContext,
|
type MileageDrillContext,
|
||||||
} from './drill-context';
|
} from './drill-context';
|
||||||
import { formatMileageSnapshotTime } from './data-freshness';
|
import { formatMileageSnapshotTime } from './data-freshness';
|
||||||
|
import {
|
||||||
|
buildMileageMonitoringUrl,
|
||||||
|
parseMileageMonitoringContext,
|
||||||
|
type MileageMonitoringContext,
|
||||||
|
} from './monitoring-context';
|
||||||
|
import { buildMonitoringKpis } from './monitoring-kpis';
|
||||||
|
|
||||||
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
||||||
'交投40辆4.5T普货',
|
'交投40辆4.5T普货',
|
||||||
@@ -31,6 +37,7 @@ const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
|||||||
]);
|
]);
|
||||||
const HIGH_MILEAGE_ALERT_KM = 800;
|
const HIGH_MILEAGE_ALERT_KM = 800;
|
||||||
const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
|
const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
|
||||||
|
type MonitoringRequestParams = NonNullable<Parameters<typeof fetchMonitoring>[0]>;
|
||||||
|
|
||||||
const MILEAGE_SOURCE_META = {
|
const MILEAGE_SOURCE_META = {
|
||||||
instrument: { label: '仪表数据', protocol: null },
|
instrument: { label: '仪表数据', protocol: null },
|
||||||
@@ -453,35 +460,48 @@ export default function MonitoringView() {
|
|||||||
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
||||||
parseMileageDrillContext(window.location.search)
|
parseMileageDrillContext(window.location.search)
|
||||||
));
|
));
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [initialMonitoringContext] = useState(() => parseMileageMonitoringContext(window.location.search));
|
||||||
const [filterDept, setFilterDept] = useState('All');
|
const [searchTerm, setSearchTerm] = useState(initialMonitoringContext.search || '');
|
||||||
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>('today');
|
const [filterDept, setFilterDept] = useState(initialMonitoringContext.department || 'All');
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>(initialMonitoringContext.sortBy);
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(initialMonitoringContext.sortOrder);
|
||||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
|
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
|
||||||
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||||
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
|
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
|
||||||
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
||||||
|
const [fullscreenError, setFullscreenError] = useState<string | null>(null);
|
||||||
|
const [fullscreenSnapshotKey, setFullscreenSnapshotKey] = useState<string | null>(null);
|
||||||
|
const [fullscreenUpdatedAt, setFullscreenUpdatedAt] = useState<string | null>(null);
|
||||||
|
const [fullscreenDateRange, setFullscreenDateRange] = useState<{ start: string; end: string } | undefined>();
|
||||||
|
|
||||||
// New filters from image
|
// New filters from image
|
||||||
const [filterPlates, setFilterPlates] = useState<string[]>(() => (
|
const [filterPlates, setFilterPlates] = useState<string[]>(initialMonitoringContext.plates);
|
||||||
drillContext.level === 'vehicle' && drillContext.plate ? [drillContext.plate] : []
|
const [drillPlateFallback, setDrillPlateFallback] = useState<string | undefined>(() => (
|
||||||
|
drillContext.level === 'vehicle' && drillContext.plate ? drillContext.plate : undefined
|
||||||
));
|
));
|
||||||
const [filterCustomer, setFilterCustomer] = useState('All');
|
const [filterCustomer, setFilterCustomer] = useState(initialMonitoringContext.customer || 'All');
|
||||||
const [filterProject, setFilterProject] = useState('All');
|
const [filterProject, setFilterProject] = useState(initialMonitoringContext.project || 'All');
|
||||||
const [filterEntity, setFilterEntity] = useState('All');
|
const [filterEntity, setFilterEntity] = useState(initialMonitoringContext.entity || 'All');
|
||||||
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
const [filterRentStatus, setFilterRentStatus] = useState(initialMonitoringContext.rentStatus || 'All');
|
||||||
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
const [filterPlatePrefix, setFilterPlatePrefix] = useState(initialMonitoringContext.platePrefix || 'All');
|
||||||
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
|
const [filterTargetNames, setFilterTargetNames] = useState<string[]>(initialMonitoringContext.targetNames);
|
||||||
const [filterRegion, setFilterRegion] = useState('All');
|
const [filterRegion, setFilterRegion] = useState(initialMonitoringContext.region || 'All');
|
||||||
const [filterBrands, setFilterBrands] = useState<string[]>([]);
|
const [filterBrands, setFilterBrands] = useState<string[]>(initialMonitoringContext.brands);
|
||||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
const [filterMileageRange, setFilterMileageRange] = useState({
|
||||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
min: initialMonitoringContext.mileageMin || '',
|
||||||
|
max: initialMonitoringContext.mileageMax || '',
|
||||||
|
});
|
||||||
|
const [appliedMileageRange, setAppliedMileageRange] = useState({
|
||||||
|
min: initialMonitoringContext.mileageMin || '',
|
||||||
|
max: initialMonitoringContext.mileageMax || '',
|
||||||
|
});
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [rangeStart, setRangeStart] = useState(() => drillContext.startDate || defaultMileageDate());
|
const [exportStatus, setExportStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
const [rangeEnd, setRangeEnd] = useState(() => drillContext.endDate || defaultMileageDate());
|
const [rangeStart, setRangeStart] = useState(() => initialMonitoringContext.startDate || drillContext.startDate || defaultMileageDate());
|
||||||
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(drillContext.sourcePriority);
|
const [rangeEnd, setRangeEnd] = useState(() => initialMonitoringContext.endDate || drillContext.endDate || defaultMileageDate());
|
||||||
|
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(initialMonitoringContext.sourcePriority);
|
||||||
|
|
||||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||||
@@ -494,14 +514,14 @@ export default function MonitoringView() {
|
|||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
const [pageError, setPageError] = useState<{ message: string; scope: 'first' | 'more' } | null>(null);
|
const [pageError, setPageError] = useState<{ message: string; scope: 'first' | 'more' } | null>(null);
|
||||||
|
const [pageSnapshotKey, setPageSnapshotKey] = useState<string | null>(null);
|
||||||
const [manualRefreshing, setManualRefreshing] = useState(false);
|
const [manualRefreshing, setManualRefreshing] = useState(false);
|
||||||
const [dataUpdatedAt, setDataUpdatedAt] = useState<string | null>(null);
|
const [dataUpdatedAt, setDataUpdatedAt] = useState<string | null>(null);
|
||||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||||
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
||||||
|
const firstPageRequestRef = useRef(0);
|
||||||
|
const loadMoreRequestRef = useRef(0);
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
const detailVehicle = drillContext.level === 'vehicle' && drillContext.plate
|
|
||||||
? vehicles.find(vehicle => vehicle.plate === drillContext.plate) || null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const commitDrillContext = useCallback((next: MileageDrillContext, mode: 'push' | 'replace') => {
|
const commitDrillContext = useCallback((next: MileageDrillContext, mode: 'push' | 'replace') => {
|
||||||
const url = buildMileageDrillUrl(window.location, next);
|
const url = buildMileageDrillUrl(window.location, next);
|
||||||
@@ -510,6 +530,7 @@ export default function MonitoringView() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openVehicleDetail = useCallback((vehicle: MonitoringVehicle) => {
|
const openVehicleDetail = useCallback((vehicle: MonitoringVehicle) => {
|
||||||
|
setDrillPlateFallback(undefined);
|
||||||
commitDrillContext({
|
commitDrillContext({
|
||||||
level: 'vehicle',
|
level: 'vehicle',
|
||||||
plate: vehicle.plate,
|
plate: vehicle.plate,
|
||||||
@@ -520,6 +541,7 @@ export default function MonitoringView() {
|
|||||||
}, [commitDrillContext, effectiveRange.end, effectiveRange.start, sourcePriority]);
|
}, [commitDrillContext, effectiveRange.end, effectiveRange.start, sourcePriority]);
|
||||||
|
|
||||||
const closeVehicleDetail = useCallback(() => {
|
const closeVehicleDetail = useCallback(() => {
|
||||||
|
setDrillPlateFallback(undefined);
|
||||||
commitDrillContext({
|
commitDrillContext({
|
||||||
level: 'overview',
|
level: 'overview',
|
||||||
startDate: effectiveRange.start,
|
startDate: effectiveRange.start,
|
||||||
@@ -536,11 +558,31 @@ export default function MonitoringView() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
const next = parseMileageDrillContext(window.location.search);
|
const next = parseMileageDrillContext(window.location.search);
|
||||||
|
const nextMonitoring = parseMileageMonitoringContext(window.location.search);
|
||||||
setDrillContext(next);
|
setDrillContext(next);
|
||||||
if (next.startDate) setRangeStart(next.startDate);
|
setRangeStart(nextMonitoring.startDate || defaultMileageDate());
|
||||||
if (next.endDate) setRangeEnd(next.endDate);
|
setRangeEnd(nextMonitoring.endDate || defaultMileageDate());
|
||||||
setSourcePriority(next.sourcePriority);
|
setSourcePriority(nextMonitoring.sourcePriority);
|
||||||
if (next.level === 'vehicle' && next.plate) setFilterPlates([next.plate]);
|
setFilterTargetNames(nextMonitoring.targetNames);
|
||||||
|
setFilterRegion(nextMonitoring.region || 'All');
|
||||||
|
setFilterPlates(nextMonitoring.plates);
|
||||||
|
setDrillPlateFallback(next.level === 'vehicle' && next.plate ? next.plate : undefined);
|
||||||
|
setFilterDept(nextMonitoring.department || 'All');
|
||||||
|
setFilterCustomer(nextMonitoring.customer || 'All');
|
||||||
|
setFilterProject(nextMonitoring.project || 'All');
|
||||||
|
setFilterEntity(nextMonitoring.entity || 'All');
|
||||||
|
setFilterRentStatus(nextMonitoring.rentStatus || 'All');
|
||||||
|
setFilterBrands(nextMonitoring.brands);
|
||||||
|
setFilterPlatePrefix(nextMonitoring.platePrefix || 'All');
|
||||||
|
setSearchTerm(nextMonitoring.search || '');
|
||||||
|
const nextMileageRange = {
|
||||||
|
min: nextMonitoring.mileageMin || '',
|
||||||
|
max: nextMonitoring.mileageMax || '',
|
||||||
|
};
|
||||||
|
setFilterMileageRange(nextMileageRange);
|
||||||
|
setAppliedMileageRange(nextMileageRange);
|
||||||
|
setSortBy(nextMonitoring.sortBy);
|
||||||
|
setSortOrder(nextMonitoring.sortOrder);
|
||||||
};
|
};
|
||||||
window.addEventListener('popstate', handlePopState);
|
window.addEventListener('popstate', handlePopState);
|
||||||
return () => window.removeEventListener('popstate', handlePopState);
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
@@ -548,11 +590,6 @@ export default function MonitoringView() {
|
|||||||
|
|
||||||
const departments = filterOptions.departments;
|
const departments = filterOptions.departments;
|
||||||
const plateNumbers = filterOptions.plates;
|
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 applyRangePreset = useCallback((preset: RangePreset) => {
|
const applyRangePreset = useCallback((preset: RangePreset) => {
|
||||||
const range = getRangePreset(preset);
|
const range = getRangePreset(preset);
|
||||||
setRangeStart(range.start);
|
setRangeStart(range.start);
|
||||||
@@ -565,15 +602,12 @@ export default function MonitoringView() {
|
|||||||
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
|
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
|
||||||
}, [filterTargetNames]);
|
}, [filterTargetNames]);
|
||||||
|
|
||||||
// 加载首页数据
|
const requestPlates = useMemo(() => Array.from(new Set([
|
||||||
const loadFirstPage = useCallback((showPageLoading = true, force = false) => {
|
...filterPlates,
|
||||||
if (showPageLoading) setPageLoading(true);
|
...(drillPlateFallback ? [drillPlateFallback] : []),
|
||||||
setPageError(null);
|
])), [drillPlateFallback, filterPlates]);
|
||||||
return fetchMonitoring({
|
|
||||||
sortBy,
|
const monitoringFilters = useMemo<MonitoringRequestParams>(() => ({
|
||||||
sortOrder,
|
|
||||||
limit: PAGE_SIZE,
|
|
||||||
page: 1,
|
|
||||||
search: searchTerm || undefined,
|
search: searchTerm || undefined,
|
||||||
dept: filterDept !== 'All' ? filterDept : undefined,
|
dept: filterDept !== 'All' ? filterDept : undefined,
|
||||||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||||||
@@ -583,15 +617,118 @@ export default function MonitoringView() {
|
|||||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||||
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
||||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
plate: requestPlates.length > 0 ? requestPlates.join(',') : undefined,
|
||||||
mileageMin: appliedMileageRange.min || undefined,
|
mileageMin: appliedMileageRange.min || undefined,
|
||||||
mileageMax: appliedMileageRange.max || undefined,
|
mileageMax: appliedMileageRange.max || undefined,
|
||||||
startDate: rangeStart || undefined,
|
startDate: rangeStart || undefined,
|
||||||
endDate: rangeEnd || undefined,
|
endDate: rangeEnd || undefined,
|
||||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||||
sourcePriority,
|
sourcePriority,
|
||||||
|
}), [
|
||||||
|
appliedMileageRange.max,
|
||||||
|
appliedMileageRange.min,
|
||||||
|
filterBrands,
|
||||||
|
filterCustomer,
|
||||||
|
filterDept,
|
||||||
|
filterEntity,
|
||||||
|
filterPlatePrefix,
|
||||||
|
filterProject,
|
||||||
|
filterRegion,
|
||||||
|
filterRentStatus,
|
||||||
|
filterTargetNames,
|
||||||
|
rangeEnd,
|
||||||
|
rangeStart,
|
||||||
|
requestPlates,
|
||||||
|
searchTerm,
|
||||||
|
sourcePriority,
|
||||||
|
]);
|
||||||
|
const monitoringUrlContext = useMemo<MileageMonitoringContext>(() => ({
|
||||||
|
startDate: rangeStart || undefined,
|
||||||
|
endDate: rangeEnd || undefined,
|
||||||
|
sourcePriority,
|
||||||
|
targetNames: filterTargetNames,
|
||||||
|
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||||
|
plates: filterPlates,
|
||||||
|
department: filterDept !== 'All' ? filterDept : undefined,
|
||||||
|
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||||||
|
project: filterProject !== 'All' ? filterProject : undefined,
|
||||||
|
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||||||
|
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||||
|
brands: filterBrands,
|
||||||
|
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
mileageMin: appliedMileageRange.min || undefined,
|
||||||
|
mileageMax: appliedMileageRange.max || undefined,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
}), [
|
||||||
|
appliedMileageRange.max,
|
||||||
|
appliedMileageRange.min,
|
||||||
|
filterBrands,
|
||||||
|
filterCustomer,
|
||||||
|
filterDept,
|
||||||
|
filterEntity,
|
||||||
|
filterPlatePrefix,
|
||||||
|
filterPlates,
|
||||||
|
filterProject,
|
||||||
|
filterRegion,
|
||||||
|
filterRentStatus,
|
||||||
|
filterTargetNames,
|
||||||
|
rangeEnd,
|
||||||
|
rangeStart,
|
||||||
|
searchTerm,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
sourcePriority,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextUrl = buildMileageMonitoringUrl(window.location, monitoringUrlContext);
|
||||||
|
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
if (nextUrl !== currentUrl) window.history.replaceState(null, '', nextUrl);
|
||||||
|
}, [monitoringUrlContext]);
|
||||||
|
|
||||||
|
const monitoringRequestKey = useMemo(
|
||||||
|
() => JSON.stringify({ ...monitoringFilters, sortBy, sortOrder }),
|
||||||
|
[monitoringFilters, sortBy, sortOrder],
|
||||||
|
);
|
||||||
|
const pageHasSnapshot = pageSnapshotKey === monitoringRequestKey;
|
||||||
|
const fullscreenHasSnapshot = fullscreenSnapshotKey === monitoringRequestKey;
|
||||||
|
const pageUpdatedAt = pageHasSnapshot ? dataUpdatedAt : null;
|
||||||
|
const detailVehicle = pageHasSnapshot && drillContext.level === 'vehicle' && drillContext.plate
|
||||||
|
? vehicles.find(vehicle => vehicle.plate === drillContext.plate) || null
|
||||||
|
: null;
|
||||||
|
const displayedRange = pageHasSnapshot
|
||||||
|
? effectiveRange
|
||||||
|
: { start: rangeStart, end: rangeEnd };
|
||||||
|
const rangeLabel = normalizeRangeLabel(displayedRange.start, displayedRange.end);
|
||||||
|
const isRangeMode = !!displayedRange.start && !!displayedRange.end && displayedRange.start !== displayedRange.end;
|
||||||
|
const averageDailyKm = pageHasSnapshot && rangeDailyTotals.length > 0
|
||||||
|
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
|
||||||
|
: 0;
|
||||||
|
const pageKpis = buildMonitoringKpis(stats, displayedRange);
|
||||||
|
const fullscreenKpis = buildMonitoringKpis(
|
||||||
|
fullscreenStats,
|
||||||
|
fullscreenHasSnapshot ? fullscreenDateRange : { start: rangeStart, end: rangeEnd },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 加载首页数据
|
||||||
|
const loadFirstPage = useCallback((showPageLoading = true, force = false) => {
|
||||||
|
const requestId = ++firstPageRequestRef.current;
|
||||||
|
const requestKey = monitoringRequestKey;
|
||||||
|
++loadMoreRequestRef.current;
|
||||||
|
setLoadingMore(false);
|
||||||
|
if (showPageLoading) setPageLoading(true);
|
||||||
|
setPageError(null);
|
||||||
|
return fetchMonitoring({
|
||||||
|
...monitoringFilters,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
page: 1,
|
||||||
force,
|
force,
|
||||||
}).then(d => {
|
}).then(d => {
|
||||||
|
if (requestId !== firstPageRequestRef.current) return;
|
||||||
setVehicles(d.vehicles);
|
setVehicles(d.vehicles);
|
||||||
setStats(d.stats);
|
setStats(d.stats);
|
||||||
setFilterOptions(d.filters);
|
setFilterOptions(d.filters);
|
||||||
@@ -601,15 +738,17 @@ export default function MonitoringView() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
setHasMore(d.page < d.totalPages);
|
setHasMore(d.page < d.totalPages);
|
||||||
setDataUpdatedAt(d.updatedAt);
|
setDataUpdatedAt(d.updatedAt);
|
||||||
|
setPageSnapshotKey(requestKey);
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
|
if (requestId !== firstPageRequestRef.current) return;
|
||||||
setPageError({
|
setPageError({
|
||||||
message: error instanceof Error ? error.message : '里程数据加载失败,请稍后重试',
|
message: error instanceof Error ? error.message : '里程数据加载失败,请稍后重试',
|
||||||
scope: 'first',
|
scope: 'first',
|
||||||
});
|
});
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
if (showPageLoading) setPageLoading(false);
|
if (requestId === firstPageRequestRef.current) setPageLoading(false);
|
||||||
});
|
});
|
||||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
|
}, [monitoringFilters, monitoringRequestKey, rangeEnd, rangeStart, sortBy, sortOrder]);
|
||||||
|
|
||||||
const handleManualRefresh = useCallback(async () => {
|
const handleManualRefresh = useCallback(async () => {
|
||||||
if (manualRefreshing) return;
|
if (manualRefreshing) return;
|
||||||
@@ -625,41 +764,31 @@ export default function MonitoringView() {
|
|||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (loadingMore || !hasMore) return;
|
if (loadingMore || !hasMore) return;
|
||||||
const nextPage = page + 1;
|
const nextPage = page + 1;
|
||||||
|
const requestId = ++loadMoreRequestRef.current;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
setPageError(null);
|
setPageError(null);
|
||||||
fetchMonitoring({
|
fetchMonitoring({
|
||||||
|
...monitoringFilters,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
page: nextPage,
|
page: nextPage,
|
||||||
search: searchTerm || undefined,
|
|
||||||
dept: filterDept !== 'All' ? filterDept : undefined,
|
|
||||||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
|
||||||
project: filterProject !== 'All' ? filterProject : undefined,
|
|
||||||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
|
||||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
|
||||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
|
||||||
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
|
||||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
|
||||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
|
||||||
mileageMin: appliedMileageRange.min || undefined,
|
|
||||||
mileageMax: appliedMileageRange.max || undefined,
|
|
||||||
startDate: rangeStart || undefined,
|
|
||||||
endDate: rangeEnd || undefined,
|
|
||||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
|
||||||
sourcePriority,
|
|
||||||
}).then(d => {
|
}).then(d => {
|
||||||
|
if (requestId !== loadMoreRequestRef.current) return;
|
||||||
setVehicles(prev => [...prev, ...d.vehicles]);
|
setVehicles(prev => [...prev, ...d.vehicles]);
|
||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
setHasMore(nextPage < d.totalPages);
|
setHasMore(nextPage < d.totalPages);
|
||||||
setDataUpdatedAt(d.updatedAt);
|
setDataUpdatedAt(d.updatedAt);
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
|
if (requestId !== loadMoreRequestRef.current) return;
|
||||||
setPageError({
|
setPageError({
|
||||||
message: error instanceof Error ? error.message : '更多车辆加载失败,请稍后重试',
|
message: error instanceof Error ? error.message : '更多车辆加载失败,请稍后重试',
|
||||||
scope: 'more',
|
scope: 'more',
|
||||||
});
|
});
|
||||||
}).finally(() => setLoadingMore(false));
|
}).finally(() => {
|
||||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands, sourcePriority]);
|
if (requestId === loadMoreRequestRef.current) setLoadingMore(false);
|
||||||
|
});
|
||||||
|
}, [hasMore, loadingMore, monitoringFilters, page, sortBy, sortOrder]);
|
||||||
|
|
||||||
// 筛选/排序变化时重新加载
|
// 筛选/排序变化时重新加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -669,6 +798,7 @@ export default function MonitoringView() {
|
|||||||
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
|
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filterPlates.length === 0) return;
|
if (filterPlates.length === 0) return;
|
||||||
|
if (filterOptions.plates.length === 0) return;
|
||||||
const valid = new Set(filterOptions.plates);
|
const valid = new Set(filterOptions.plates);
|
||||||
const next = filterPlates.filter(p => valid.has(p));
|
const next = filterPlates.filter(p => valid.has(p));
|
||||||
if (next.length !== filterPlates.length) setFilterPlates(next);
|
if (next.length !== filterPlates.length) setFilterPlates(next);
|
||||||
@@ -678,36 +808,27 @@ export default function MonitoringView() {
|
|||||||
const handleDownload = useCallback(async () => {
|
const handleDownload = useCallback(async () => {
|
||||||
if (exporting) return;
|
if (exporting) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
|
setExportStatus(null);
|
||||||
try {
|
try {
|
||||||
const d = await fetchMonitoring({
|
const d = await fetchMonitoring({
|
||||||
|
...monitoringFilters,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
limit: 9999,
|
limit: 9999,
|
||||||
page: 1,
|
page: 1,
|
||||||
search: searchTerm || undefined,
|
|
||||||
dept: filterDept !== 'All' ? filterDept : undefined,
|
|
||||||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
|
||||||
project: filterProject !== 'All' ? filterProject : undefined,
|
|
||||||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
|
||||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
|
||||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
|
||||||
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
|
||||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
|
||||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
|
||||||
mileageMin: appliedMileageRange.min || undefined,
|
|
||||||
mileageMax: appliedMileageRange.max || undefined,
|
|
||||||
startDate: rangeStart || undefined,
|
|
||||||
endDate: rangeEnd || undefined,
|
|
||||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
|
||||||
sourcePriority,
|
|
||||||
});
|
});
|
||||||
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
|
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
|
||||||
|
setExportStatus({ type: 'success', message: `已导出 ${d.vehicles.length} 辆车辆` });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('export failed', err);
|
console.error('export failed', err);
|
||||||
|
setExportStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: err instanceof Error ? err.message : '导出失败,请稍后重试',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
|
}, [exporting, monitoringFilters, rangeEnd, rangeStart, sortBy, sortOrder]);
|
||||||
|
|
||||||
// 每分钟自动刷新
|
// 每分钟自动刷新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -733,7 +854,7 @@ export default function MonitoringView() {
|
|||||||
document.body.scrollTop = 0;
|
document.body.scrollTop = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredVehicles = vehicles;
|
const filteredVehicles = pageHasSnapshot ? vehicles : [];
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
setIsFullscreen(!isFullscreen);
|
setIsFullscreen(!isFullscreen);
|
||||||
@@ -742,31 +863,31 @@ export default function MonitoringView() {
|
|||||||
// 全屏时加载全部数据(无分页),筛选变化时重新加载
|
// 全屏时加载全部数据(无分页),筛选变化时重新加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFullscreen) return;
|
if (!isFullscreen) return;
|
||||||
|
let cancelled = false;
|
||||||
setFullscreenLoading(true);
|
setFullscreenLoading(true);
|
||||||
|
setFullscreenError(null);
|
||||||
fetchMonitoring({
|
fetchMonitoring({
|
||||||
|
...monitoringFilters,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
limit: 9999,
|
limit: 9999,
|
||||||
page: 1,
|
page: 1,
|
||||||
search: searchTerm || undefined,
|
|
||||||
dept: filterDept !== 'All' ? filterDept : undefined,
|
|
||||||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
|
||||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
|
||||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
|
||||||
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
|
||||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
|
||||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
|
||||||
startDate: rangeStart || undefined,
|
|
||||||
endDate: rangeEnd || undefined,
|
|
||||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
|
||||||
sourcePriority,
|
|
||||||
}).then(d => {
|
}).then(d => {
|
||||||
|
if (cancelled) return;
|
||||||
setFullscreenVehicles(d.vehicles);
|
setFullscreenVehicles(d.vehicles);
|
||||||
setFullscreenStats(d.stats);
|
setFullscreenStats(d.stats);
|
||||||
setFilterOptions(d.filters);
|
setFilterOptions(d.filters);
|
||||||
setDataUpdatedAt(d.updatedAt);
|
setFullscreenUpdatedAt(d.updatedAt);
|
||||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
setFullscreenDateRange(d.dateRange || { start: rangeStart, end: rangeEnd });
|
||||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands, sourcePriority]);
|
setFullscreenSnapshotKey(monitoringRequestKey);
|
||||||
|
}).catch(error => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setFullscreenError(error instanceof Error ? error.message : '全屏监控加载失败,请稍后重试');
|
||||||
|
}).finally(() => {
|
||||||
|
if (!cancelled) setFullscreenLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [fullscreenRefresh, isFullscreen, monitoringFilters, monitoringRequestKey, rangeEnd, rangeStart, sortBy, sortOrder]);
|
||||||
|
|
||||||
// 全屏时禁止背景滚动
|
// 全屏时禁止背景滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -804,6 +925,9 @@ export default function MonitoringView() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="里程全屏监控"
|
||||||
className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden"
|
className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden"
|
||||||
style={
|
style={
|
||||||
forceLandscape
|
forceLandscape
|
||||||
@@ -827,13 +951,13 @@ export default function MonitoringView() {
|
|||||||
<h2 className="text-white font-bold text-xs">全屏监控</h2>
|
<h2 className="text-white font-bold text-xs">全屏监控</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-[10px]">
|
<div className="flex items-center gap-3 text-[10px]">
|
||||||
<span className="text-slate-500">区间 <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
<span className="text-slate-500">{fullscreenKpis.distanceShortLabel} <span className="text-white font-black">{fullscreenHasSnapshot ? Math.round(fullscreenKpis.distanceKm).toLocaleString() : '—'}</span> <span className="text-blue-400">km</span></span>
|
||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">累计 <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
<span className="text-slate-500">累计 <span className="text-white font-black">{fullscreenHasSnapshot ? Math.round(fullscreenKpis.cumulativeOdometerKm).toLocaleString() : '—'}</span> <span className="text-blue-400">km</span></span>
|
||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> 台</span>
|
<span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenHasSnapshot ? fullscreenKpis.vehicleCount : '—'}</span> 台</span>
|
||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">均 <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
|
<span className="text-slate-500">单车均 <span className="text-white font-black">{fullscreenHasSnapshot ? fullscreenKpis.averagePerVehicleKm.toFixed(0) : '—'}</span> <span className="text-blue-400">km</span></span>
|
||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">
|
<span className="text-slate-500">
|
||||||
来源 <span className="text-white font-black">
|
来源 <span className="text-white font-black">
|
||||||
@@ -845,11 +969,14 @@ export default function MonitoringView() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setFullscreenRefresh(n => n + 1); }}
|
onClick={() => { setFullscreenRefresh(n => n + 1); }}
|
||||||
|
disabled={fullscreenLoading}
|
||||||
|
aria-label={fullscreenLoading ? '正在刷新全屏监控' : '刷新全屏监控'}
|
||||||
|
title={fullscreenLoading ? '刷新中' : '刷新全屏监控'}
|
||||||
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
|
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
|
||||||
>
|
>
|
||||||
<RotateCcw size={13} />
|
<RotateCcw size={13} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={toggleFullscreen} className="p-1.5 text-slate-500 hover:text-white transition-colors">
|
<button onClick={toggleFullscreen} aria-label="退出全屏监控" title="退出全屏" className="p-1.5 text-slate-500 hover:text-white transition-colors">
|
||||||
<Minimize2 size={14} />
|
<Minimize2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -879,9 +1006,24 @@ export default function MonitoringView() {
|
|||||||
|
|
||||||
{/* Table Area */}
|
{/* Table Area */}
|
||||||
<div className="flex-1 overflow-hidden flex flex-col">
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
{fullscreenError && fullscreenHasSnapshot && (
|
||||||
|
<div role="alert" className="flex flex-shrink-0 items-center justify-between gap-3 border-b border-amber-500/20 bg-amber-500/10 px-3 py-1.5 text-[10px] font-bold text-amber-300">
|
||||||
|
<span className="truncate" title={fullscreenError}>
|
||||||
|
刷新失败,仍展示截至 {formatMileageSnapshotTime(fullscreenUpdatedAt)} 的数据
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFullscreenRefresh(value => value + 1)}
|
||||||
|
disabled={fullscreenLoading}
|
||||||
|
className="shrink-0 text-amber-200 underline underline-offset-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto relative">
|
<div className="flex-1 overflow-auto relative">
|
||||||
{fullscreenLoading && (
|
{fullscreenLoading && fullscreenHasSnapshot && (
|
||||||
<div className="absolute inset-0 bg-slate-950/60 z-20 flex items-center justify-center">
|
<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">
|
<div className="flex items-center gap-2 text-slate-400 text-xs font-bold">
|
||||||
<RotateCcw size={14} className="animate-spin" />
|
<RotateCcw size={14} className="animate-spin" />
|
||||||
@@ -889,7 +1031,29 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<table className="w-full text-left border-collapse">
|
{!fullscreenHasSnapshot && (
|
||||||
|
<div className="flex min-h-full items-center justify-center px-6 py-20">
|
||||||
|
{fullscreenLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-slate-400" role="status">
|
||||||
|
<RotateCcw size={14} className="animate-spin" />
|
||||||
|
加载全屏监控数据...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-md text-center" role="alert">
|
||||||
|
<p className="text-sm font-bold text-rose-300">全屏监控暂不可用</p>
|
||||||
|
<p className="mt-1 text-[10px] text-slate-500">{fullscreenError || '未取得当前筛选条件下的数据'}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFullscreenRefresh(value => value + 1)}
|
||||||
|
className="mt-3 rounded border border-slate-700 px-3 py-1.5 text-[10px] font-bold text-slate-300 hover:border-blue-500 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fullscreenHasSnapshot && <table className="w-full text-left border-collapse">
|
||||||
<thead className="sticky top-0 bg-slate-900 z-10">
|
<thead className="sticky top-0 bg-slate-900 z-10">
|
||||||
<tr className="border-b border-slate-800/60">
|
<tr className="border-b border-slate-800/60">
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase w-12 text-center">状态</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase w-12 text-center">状态</th>
|
||||||
@@ -1017,6 +1181,13 @@ 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.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-3 py-20 text-center text-xs font-bold text-slate-500">
|
||||||
|
当前筛选条件下暂无车辆数据
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
{fullscreenVehicles.map((v) => {
|
{fullscreenVehicles.map((v) => {
|
||||||
const highMileageAlert = isHighMileageAlert(v);
|
const highMileageAlert = isHighMileageAlert(v);
|
||||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||||
@@ -1058,7 +1229,7 @@ export default function MonitoringView() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -1072,10 +1243,16 @@ export default function MonitoringView() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="truncate text-lg font-black leading-none text-slate-900">里程看板</h1>
|
<h1 className="truncate text-lg font-black leading-none text-slate-900">里程看板</h1>
|
||||||
<button onClick={toggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
|
<button onClick={toggleFullscreen} aria-label="打开全屏监控" className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
|
||||||
<Maximize2 size={14} />
|
<Maximize2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleDownload} disabled={exporting} className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果">
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={exporting}
|
||||||
|
aria-label={exporting ? '正在导出当前筛选结果' : '导出当前筛选结果'}
|
||||||
|
className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200"
|
||||||
|
title={exporting ? '导出中' : '下载当前筛选结果'}
|
||||||
|
>
|
||||||
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
|
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1083,9 +1260,9 @@ export default function MonitoringView() {
|
|||||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||||
<span
|
<span
|
||||||
className="truncate text-[9px] font-bold tracking-tight text-slate-400"
|
className="truncate text-[9px] font-bold tracking-tight text-slate-400"
|
||||||
title={`OneOS 分钟级快照 · 数据截至 ${formatMileageSnapshotTime(dataUpdatedAt)}`}
|
title={`OneOS 分钟级快照 · 数据截至 ${formatMileageSnapshotTime(pageUpdatedAt)}`}
|
||||||
>
|
>
|
||||||
OneOS 分钟级快照 · 截至 {formatMileageSnapshotTime(dataUpdatedAt)}
|
OneOS 分钟级快照 · 截至 {formatMileageSnapshotTime(pageUpdatedAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1093,7 +1270,7 @@ export default function MonitoringView() {
|
|||||||
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||||
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
|
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
|
||||||
数据截至 {formatMileageSnapshotTime(dataUpdatedAt)}
|
数据截至 {formatMileageSnapshotTime(pageUpdatedAt)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1106,9 +1283,9 @@ export default function MonitoringView() {
|
|||||||
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
|
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-0.5">
|
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-0.5">
|
||||||
<button onClick={() => setSortBy('today')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>区间</button>
|
<button aria-pressed={sortBy === 'today'} title="按所选日期区间里程排序" onClick={() => setSortBy('today')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>区间</button>
|
||||||
<button onClick={() => setSortBy('total')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>累计</button>
|
<button aria-pressed={sortBy === 'total'} title="按最新累计仪表里程排序" onClick={() => setSortBy('total')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>累计</button>
|
||||||
<button onClick={() => setSortBy('statisticTime')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'statisticTime' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>统计时间</button>
|
<button aria-pressed={sortBy === 'statisticTime'} title="按最后有效数据时间排序" onClick={() => setSortBy('statisticTime')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'statisticTime' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>统计时间</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
|
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
|
||||||
className="rounded-md p-1 text-blue-600 transition-all hover:bg-white"
|
className="rounded-md p-1 text-blue-600 transition-all hover:bg-white"
|
||||||
@@ -1120,6 +1297,32 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{exportStatus && (
|
||||||
|
<div
|
||||||
|
role={exportStatus.type === 'error' ? 'alert' : 'status'}
|
||||||
|
className={`flex items-center justify-between gap-3 border-t pt-2 text-[10px] font-bold ${
|
||||||
|
exportStatus.type === 'error'
|
||||||
|
? 'border-rose-100 text-rose-600'
|
||||||
|
: 'border-emerald-100 text-emerald-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
{exportStatus.type === 'success' && <Check size={12} className="shrink-0" />}
|
||||||
|
<span className="truncate" title={exportStatus.message}>{exportStatus.message}</span>
|
||||||
|
</span>
|
||||||
|
{exportStatus.type === 'error' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={exporting}
|
||||||
|
className="shrink-0 underline underline-offset-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
重试导出
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SourcePriorityControl priority={sourcePriority} onChange={setSourcePriority} />
|
<SourcePriorityControl priority={sourcePriority} onChange={setSourcePriority} />
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1422,30 +1625,73 @@ export default function MonitoringView() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{pageError?.scope === 'first' && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2 ${
|
||||||
|
pageHasSnapshot
|
||||||
|
? 'border-amber-200 bg-amber-50 text-amber-700'
|
||||||
|
: 'border-rose-200 bg-rose-50 text-rose-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[10px] font-black" title={pageError.message}>
|
||||||
|
{pageHasSnapshot
|
||||||
|
? `刷新失败,仍展示截至 ${formatMileageSnapshotTime(pageUpdatedAt)} 的数据`
|
||||||
|
: '当前筛选数据暂不可用'}
|
||||||
|
</p>
|
||||||
|
{!pageHasSnapshot && <p className="mt-0.5 truncate text-[9px] opacity-70">{pageError.message}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => pageHasSnapshot ? handleManualRefresh() : loadFirstPage()}
|
||||||
|
disabled={pageLoading || manualRefreshing}
|
||||||
|
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-lg border border-current/20 bg-white/70 px-2 text-[9px] font-black disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={11} className={pageLoading || manualRefreshing ? 'animate-spin' : ''} />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sticky header: KPI + 清单标题 */}
|
{/* Sticky header: KPI + 清单标题 */}
|
||||||
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
|
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
|
||||||
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}>
|
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading && pageHasSnapshot ? 'opacity-60' : ''}`}>
|
||||||
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
|
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
|
||||||
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}总里程</div>
|
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{pageKpis.distanceLabel}</div>
|
||||||
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
|
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
|
||||||
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
|
{pageLoading && !pageHasSnapshot
|
||||||
|
? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div>
|
||||||
|
: <>{pageHasSnapshot ? Math.round(pageKpis.distanceKm).toLocaleString() : '—'} <span className="text-[8px] text-slate-400">km</span></>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
||||||
<div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div>
|
<div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div>
|
||||||
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0)}</div>
|
<div className="text-sm font-black text-slate-800 leading-tight">
|
||||||
|
{pageLoading && !pageHasSnapshot
|
||||||
|
? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div>
|
||||||
|
: pageHasSnapshot ? pageKpis.averagePerVehicleKm.toFixed(0) : '—'}
|
||||||
|
</div>
|
||||||
<div className="text-[7px] text-slate-400">km/台</div>
|
<div className="text-[7px] text-slate-400">km/台</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
||||||
<div className="text-[7px] font-bold text-slate-400 uppercase">监控台数</div>
|
<div className="text-[7px] font-bold text-slate-400 uppercase">监控台数</div>
|
||||||
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : stats.vehicleCount}</div>
|
<div className="text-sm font-black text-slate-800 leading-tight">
|
||||||
|
{pageLoading && !pageHasSnapshot
|
||||||
|
? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div>
|
||||||
|
: pageHasSnapshot ? pageKpis.vehicleCount : '—'}
|
||||||
|
</div>
|
||||||
<div className="text-[7px] text-slate-400">台</div>
|
<div className="text-[7px] text-slate-400">台</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-slate-100 bg-white shadow-sm overflow-hidden">
|
<div className="rounded-xl border border-slate-100 bg-white shadow-sm overflow-hidden">
|
||||||
{pageLoading ? (
|
{pageLoading && !pageHasSnapshot ? (
|
||||||
<div className="h-[74px] bg-slate-50 animate-pulse" />
|
<div className="h-[74px] bg-slate-50 animate-pulse" />
|
||||||
|
) : !pageHasSnapshot ? (
|
||||||
|
<div className="flex h-[74px] items-center justify-center px-3 text-[10px] font-bold text-slate-400">
|
||||||
|
当前筛选尚无可用快照
|
||||||
|
</div>
|
||||||
) : isRangeMode ? (
|
) : isRangeMode ? (
|
||||||
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
|
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -1491,11 +1737,11 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
|
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
|
||||||
<div className="text-[9px] font-black text-blue-400">当日总计</div>
|
<div className="text-[9px] font-black text-blue-400">当日总计</div>
|
||||||
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
|
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(pageKpis.distanceKm).toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
|
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
|
||||||
<div className="text-[9px] font-black text-slate-400">日均单车</div>
|
<div className="text-[9px] font-black text-slate-400">日均单车</div>
|
||||||
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
|
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{Math.round(pageKpis.averagePerVehicleKm).toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1503,7 +1749,7 @@ export default function MonitoringView() {
|
|||||||
<div className="flex items-center justify-between px-1">
|
<div className="flex items-center justify-between px-1">
|
||||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆详情清单</span>
|
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆详情清单</span>
|
||||||
<span className="text-[9px] font-bold text-slate-400">
|
<span className="text-[9px] font-bold text-slate-400">
|
||||||
已加载 {filteredVehicles.length} / 共 {total} 条
|
{pageHasSnapshot ? `已加载 ${filteredVehicles.length} / 共 ${total} 条` : '数据不可用'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-4 py-2 text-[10px] font-bold text-slate-400 md:grid">
|
<div className="hidden grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-4 py-2 text-[10px] font-bold text-slate-400 md:grid">
|
||||||
@@ -1517,7 +1763,7 @@ export default function MonitoringView() {
|
|||||||
{/* Vehicle List */}
|
{/* Vehicle List */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
||||||
{pageLoading && (
|
{pageLoading && !pageHasSnapshot && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div key={i} className="bg-white px-3 py-3 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between animate-pulse">
|
<div key={i} className="bg-white px-3 py-3 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between animate-pulse">
|
||||||
@@ -1619,18 +1865,18 @@ export default function MonitoringView() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredVehicles.length === 0 && !loadingMore && !pageError && (
|
{pageHasSnapshot && filteredVehicles.length === 0 && !loadingMore && !pageError && (
|
||||||
<div className="py-10 text-center bg-white rounded-2xl border border-dashed border-slate-100">
|
<div className="py-10 text-center bg-white rounded-2xl border border-dashed border-slate-100">
|
||||||
<p className="text-xs font-bold text-slate-300">未找到匹配数据</p>
|
<p className="text-xs font-bold text-slate-300">未找到匹配数据</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageError && (
|
{pageError?.scope === 'more' && pageHasSnapshot && (
|
||||||
<div className="rounded-xl border border-rose-100 bg-rose-50 px-3 py-3 text-center">
|
<div className="rounded-xl border border-rose-100 bg-rose-50 px-3 py-3 text-center">
|
||||||
<p className="text-[10px] font-bold text-rose-600">{pageError.message}</p>
|
<p className="text-[10px] font-bold text-rose-600">{pageError.message}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => pageError.scope === 'more' ? loadMore() : loadFirstPage()}
|
onClick={loadMore}
|
||||||
disabled={pageLoading || loadingMore}
|
disabled={pageLoading || loadingMore}
|
||||||
className="mt-2 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 text-[10px] font-black text-rose-700 transition-colors hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
|
className="mt-2 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 text-[10px] font-black text-rose-700 transition-colors hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
@@ -1639,7 +1885,7 @@ export default function MonitoringView() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasMore && !pageError && filteredVehicles.length > 0 && (
|
{pageHasSnapshot && hasMore && !pageError && filteredVehicles.length > 0 && (
|
||||||
<div className="flex flex-col items-center gap-1.5 py-4">
|
<div className="flex flex-col items-center gap-1.5 py-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1655,7 +1901,7 @@ export default function MonitoringView() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasMore && filteredVehicles.length > 0 && (
|
{pageHasSnapshot && !hasMore && filteredVehicles.length > 0 && (
|
||||||
<div className="py-4 text-center">
|
<div className="py-4 text-center">
|
||||||
<span className="text-[10px] font-bold text-slate-300">已加载全部 {total} 条</span>
|
<span className="text-[10px] font-bold text-slate-300">已加载全部 {total} 条</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1663,9 +1909,14 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VehicleDetailModal
|
<VehicleDetailModal
|
||||||
|
key={detailVehicle
|
||||||
|
? `${detailVehicle.plate}|${drillContext.startDate || effectiveRange.start}|${drillContext.endDate || effectiveRange.end}`
|
||||||
|
: 'closed'}
|
||||||
vehicle={detailVehicle}
|
vehicle={detailVehicle}
|
||||||
onClose={closeVehicleDetail}
|
onClose={closeVehicleDetail}
|
||||||
sourcePriority={sourcePriority}
|
sourcePriority={sourcePriority}
|
||||||
|
initialStartDate={drillContext.startDate || effectiveRange.start}
|
||||||
|
initialEndDate={drillContext.endDate || effectiveRange.end}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* 回到顶部按钮 */}
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ import {
|
|||||||
Cell, LabelList,
|
Cell, LabelList,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import {
|
import {
|
||||||
Truck, ChevronDown, Maximize2, Minimize2,
|
AlertTriangle, Truck, ChevronDown, Maximize2, Minimize2,
|
||||||
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
||||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
|
||||||
import { weightedCompletionRate } from '../../shared/analytics/metrics';
|
import { weightedCompletionRate } from '../../shared/analytics/metrics';
|
||||||
import { formatMileageSnapshotDateTime } from './data-freshness';
|
import { formatMileageSnapshotDateTime } from './data-freshness';
|
||||||
|
import { assessVehicleCoverage } from './assessment-coverage';
|
||||||
import MileageAttributionView from './MileageAttributionView';
|
import MileageAttributionView from './MileageAttributionView';
|
||||||
import {
|
import {
|
||||||
filterAttributionVehicles,
|
filterAttributionVehicles,
|
||||||
@@ -63,13 +65,28 @@ function shortTargetName(name: string): string {
|
|||||||
return `${count}台${desc}`;
|
return `${count}台${desc}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRequestErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message && error.message !== 'Failed to fetch') {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export default function StatisticsView() {
|
export default function StatisticsView() {
|
||||||
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
||||||
parseMileageDrillContext(window.location.search)
|
parseMileageDrillContext(window.location.search)
|
||||||
));
|
));
|
||||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||||
|
const [targetsLoading, setTargetsLoading] = useState(true);
|
||||||
|
const [targetsError, setTargetsError] = useState<string | null>(null);
|
||||||
|
const [targetsRequestVersion, setTargetsRequestVersion] = useState(0);
|
||||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||||
|
const [trendLoading, setTrendLoading] = useState(false);
|
||||||
|
const [trendError, setTrendError] = useState<string | null>(null);
|
||||||
|
const [trendRequestVersion, setTrendRequestVersion] = useState(0);
|
||||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||||
|
const [targetVehicleLoadingMap, setTargetVehicleLoadingMap] = useState<Record<number, boolean>>({});
|
||||||
|
const [targetVehicleErrorMap, setTargetVehicleErrorMap] = useState<Record<number, string>>({});
|
||||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(drillContext.targetId || null);
|
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(drillContext.targetId || null);
|
||||||
const [attributionDimension, setAttributionDimension] = useState<AttributionDimension>(() => (
|
const [attributionDimension, setAttributionDimension] = useState<AttributionDimension>(() => (
|
||||||
drillContext.level === 'breakdown' && drillContext.dimension === 'customer' ? 'customer' : 'department'
|
drillContext.level === 'breakdown' && drillContext.dimension === 'customer' ? 'customer' : 'department'
|
||||||
@@ -87,13 +104,20 @@ export default function StatisticsView() {
|
|||||||
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
|
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
|
||||||
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
||||||
const [viewAllLoading, setViewAllLoading] = useState(false);
|
const [viewAllLoading, setViewAllLoading] = useState(false);
|
||||||
|
const [viewAllError, setViewAllError] = useState<string | null>(null);
|
||||||
|
const [viewAllRequestVersion, setViewAllRequestVersion] = useState(0);
|
||||||
const [viewAllHistoricalVehicles, setViewAllHistoricalVehicles] = useState<TargetVehicle[] | null>(null);
|
const [viewAllHistoricalVehicles, setViewAllHistoricalVehicles] = useState<TargetVehicle[] | null>(null);
|
||||||
|
const [viewAllLoadedDate, setViewAllLoadedDate] = useState<string | null>(null);
|
||||||
|
const [viewAllLoadedTargetId, setViewAllLoadedTargetId] = useState<number | null>(null);
|
||||||
const targetVehicleRequestsRef = useRef(new Set<number>());
|
const targetVehicleRequestsRef = useRef(new Set<number>());
|
||||||
const initialDrillContextRef = useRef(drillContext);
|
const initialDrillContextRef = useRef(drillContext);
|
||||||
|
|
||||||
const selectedTarget = targets.find(t => t.id === selectedTargetId);
|
const selectedTarget = targets.find(t => t.id === selectedTargetId);
|
||||||
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
|
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
|
||||||
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
||||||
|
const selectedCoverage = selectedTarget
|
||||||
|
? assessVehicleCoverage(selectedTarget.declaredVehicleCount, selectedTarget.vehicleCount)
|
||||||
|
: null;
|
||||||
const selectedAssessmentYear = selectedAssessment?.yearNumber;
|
const selectedAssessmentYear = selectedAssessment?.yearNumber;
|
||||||
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
||||||
const selectedRemaining = selectedAssessment?.remaining ?? 0;
|
const selectedRemaining = selectedAssessment?.remaining ?? 0;
|
||||||
@@ -120,12 +144,36 @@ export default function StatisticsView() {
|
|||||||
setDrillContext(next);
|
setDrillContext(next);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const retryTargets = useCallback(() => {
|
||||||
|
setTargetsRequestVersion(version => version + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const retryTrend = useCallback(() => {
|
||||||
|
setTrendRequestVersion(version => version + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const retryViewAllVehicles = useCallback(() => {
|
||||||
|
setViewAllRequestVersion(version => version + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadCurrentTargetVehicles = useCallback((targetId: number) => {
|
const loadCurrentTargetVehicles = useCallback((targetId: number) => {
|
||||||
if (targetVehicleRequestsRef.current.has(targetId)) return;
|
if (targetVehicleRequestsRef.current.has(targetId)) return;
|
||||||
targetVehicleRequestsRef.current.add(targetId);
|
targetVehicleRequestsRef.current.add(targetId);
|
||||||
|
setTargetVehicleLoadingMap(prev => ({ ...prev, [targetId]: true }));
|
||||||
|
setTargetVehicleErrorMap(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[targetId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
fetchTargetVehicles(targetId).then(vehicles => {
|
fetchTargetVehicles(targetId).then(vehicles => {
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [targetId]: vehicles }));
|
setTargetVehiclesMap(prev => ({ ...prev, [targetId]: vehicles }));
|
||||||
}).catch(() => {}).finally(() => {
|
}).catch(error => {
|
||||||
|
setTargetVehicleErrorMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[targetId]: getRequestErrorMessage(error, '无法获取考核车辆数据,请稍后重试。'),
|
||||||
|
}));
|
||||||
|
}).finally(() => {
|
||||||
|
setTargetVehicleLoadingMap(prev => ({ ...prev, [targetId]: false }));
|
||||||
targetVehicleRequestsRef.current.delete(targetId);
|
targetVehicleRequestsRef.current.delete(targetId);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -154,6 +202,9 @@ export default function StatisticsView() {
|
|||||||
setViewAllSearch('');
|
setViewAllSearch('');
|
||||||
setViewAllDate(getDefaultDate());
|
setViewAllDate(getDefaultDate());
|
||||||
setViewAllHistoricalVehicles(null);
|
setViewAllHistoricalVehicles(null);
|
||||||
|
setViewAllLoadedDate(null);
|
||||||
|
setViewAllLoadedTargetId(null);
|
||||||
|
setViewAllError(null);
|
||||||
commitDrillContext({
|
commitDrillContext({
|
||||||
level: 'breakdown',
|
level: 'breakdown',
|
||||||
targetId: selectedTargetId,
|
targetId: selectedTargetId,
|
||||||
@@ -171,6 +222,9 @@ export default function StatisticsView() {
|
|||||||
setViewAllSearch('');
|
setViewAllSearch('');
|
||||||
setViewAllDate(getDefaultDate());
|
setViewAllDate(getDefaultDate());
|
||||||
setViewAllHistoricalVehicles(null);
|
setViewAllHistoricalVehicles(null);
|
||||||
|
setViewAllLoadedDate(null);
|
||||||
|
setViewAllLoadedTargetId(null);
|
||||||
|
setViewAllError(null);
|
||||||
commitDrillContext({
|
commitDrillContext({
|
||||||
level: 'breakdown',
|
level: 'breakdown',
|
||||||
targetId: target.id,
|
targetId: target.id,
|
||||||
@@ -211,9 +265,13 @@ export default function StatisticsView() {
|
|||||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||||
}, [selectedAssessmentYear, selectedTargetId, viewAllDate, viewAllTargetId]);
|
}, [selectedAssessmentYear, selectedTargetId, viewAllDate, viewAllTargetId]);
|
||||||
|
|
||||||
// Load targets on mount
|
// Load targets on mount and through the shared retry path.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setTargetsLoading(true);
|
||||||
|
setTargetsError(null);
|
||||||
fetchTargets().then(data => {
|
fetchTargets().then(data => {
|
||||||
|
if (cancelled) return;
|
||||||
const initialContext = initialDrillContextRef.current;
|
const initialContext = initialDrillContextRef.current;
|
||||||
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
||||||
const ordered = focused
|
const ordered = focused
|
||||||
@@ -238,28 +296,75 @@ export default function StatisticsView() {
|
|||||||
setDrillContext(normalizedContext);
|
setDrillContext(normalizedContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedTargetId(null);
|
||||||
|
setExpandedTargetId(null);
|
||||||
|
setAssessmentYearMap({});
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(error => {
|
||||||
}, []);
|
if (!cancelled) {
|
||||||
|
setTargetsError(getRequestErrorMessage(error, '无法获取考核目标数据,请检查服务连接后重试。'));
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
if (!cancelled) setTargetsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [targetsRequestVersion]);
|
||||||
|
|
||||||
// Load trend when selectedTargetId changes
|
// Load trend when selectedTargetId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTargetId === null) return;
|
if (selectedTargetId === null) {
|
||||||
|
setTrendData([]);
|
||||||
|
setTrendError(null);
|
||||||
|
setTrendLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
setExpandedTargetId(selectedTargetId);
|
setExpandedTargetId(selectedTargetId);
|
||||||
if (!targetVehiclesMap[selectedTargetId]) {
|
if (!targetVehiclesMap[selectedTargetId]) {
|
||||||
loadCurrentTargetVehicles(selectedTargetId);
|
loadCurrentTargetVehicles(selectedTargetId);
|
||||||
}
|
}
|
||||||
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
setTrendLoading(true);
|
||||||
}, [selectedTargetId]);
|
setTrendError(null);
|
||||||
|
fetchTrend(selectedTargetId).then(data => {
|
||||||
|
if (!cancelled) setTrendData(data);
|
||||||
|
}).catch(error => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setTrendData([]);
|
||||||
|
setTrendError(getRequestErrorMessage(error, '无法获取里程趋势,请稍后重试。'));
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
if (!cancelled) setTrendLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedTargetId, trendRequestVersion]);
|
||||||
|
|
||||||
// Re-fetch target vehicles when viewAllDate changes
|
// Re-fetch target vehicles when viewAllDate changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewAllTargetId === null) return;
|
if (viewAllTargetId === null) return;
|
||||||
|
let cancelled = false;
|
||||||
setViewAllLoading(true);
|
setViewAllLoading(true);
|
||||||
|
setViewAllError(null);
|
||||||
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
||||||
|
if (cancelled) return;
|
||||||
setViewAllHistoricalVehicles(data);
|
setViewAllHistoricalVehicles(data);
|
||||||
}).catch(() => {}).finally(() => setViewAllLoading(false));
|
setViewAllLoadedDate(viewAllDate);
|
||||||
}, [viewAllTargetId, viewAllDate]);
|
setViewAllLoadedTargetId(viewAllTargetId);
|
||||||
|
}).catch(error => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setViewAllError(getRequestErrorMessage(error, '无法获取所选日期的车辆明细,请稍后重试。'));
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
if (!cancelled) setViewAllLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [viewAllTargetId, viewAllDate, viewAllRequestVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
@@ -284,9 +389,10 @@ export default function StatisticsView() {
|
|||||||
return () => window.removeEventListener('popstate', handlePopState);
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
}, [targets]);
|
}, [targets]);
|
||||||
|
|
||||||
|
const hasViewAllSnapshot = viewAllHistoricalVehicles !== null
|
||||||
|
&& viewAllLoadedTargetId === viewAllTargetId;
|
||||||
const viewAllVehicles = useMemo(() => {
|
const viewAllVehicles = useMemo(() => {
|
||||||
const vehicles = viewAllHistoricalVehicles
|
const vehicles = hasViewAllSnapshot ? viewAllHistoricalVehicles : [];
|
||||||
|| (viewAllTargetId === null ? [] : targetVehiclesMap[viewAllTargetId] || []);
|
|
||||||
if (
|
if (
|
||||||
drillContext.level === 'breakdown'
|
drillContext.level === 'breakdown'
|
||||||
&& drillContext.dimensionValue
|
&& drillContext.dimensionValue
|
||||||
@@ -295,7 +401,7 @@ export default function StatisticsView() {
|
|||||||
return filterAttributionVehicles(vehicles, drillContext.dimension, drillContext.dimensionValue);
|
return filterAttributionVehicles(vehicles, drillContext.dimension, drillContext.dimensionValue);
|
||||||
}
|
}
|
||||||
return vehicles;
|
return vehicles;
|
||||||
}, [drillContext, targetVehiclesMap, viewAllHistoricalVehicles, viewAllTargetId]);
|
}, [drillContext, hasViewAllSnapshot, viewAllHistoricalVehicles]);
|
||||||
|
|
||||||
const searchedViewAllVehicles = useMemo(() => (
|
const searchedViewAllVehicles = useMemo(() => (
|
||||||
viewAllVehicles.filter(vehicle => (
|
viewAllVehicles.filter(vehicle => (
|
||||||
@@ -303,6 +409,30 @@ export default function StatisticsView() {
|
|||||||
))
|
))
|
||||||
), [viewAllSearch, viewAllVehicles]);
|
), [viewAllSearch, viewAllVehicles]);
|
||||||
|
|
||||||
|
if (targetsLoading && targets.length === 0) {
|
||||||
|
return <LoadingState label="正在加载考核统计" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetsError && targets.length === 0) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
title="考核统计加载失败"
|
||||||
|
message={targetsError}
|
||||||
|
onRetry={retryTargets}
|
||||||
|
retrying={targetsLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetsLoading && targets.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无考核目标"
|
||||||
|
description="当前权限范围内没有有效考核目标"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
<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 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
||||||
{/* Project Selector */}
|
{/* Project Selector */}
|
||||||
@@ -322,6 +452,16 @@ export default function StatisticsView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{targetsError && (
|
||||||
|
<ErrorState
|
||||||
|
title="考核统计刷新失败"
|
||||||
|
message={`${targetsError} 当前仍展示上一次成功加载的数据。`}
|
||||||
|
onRetry={retryTargets}
|
||||||
|
retrying={targetsLoading}
|
||||||
|
variant="inline"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedTarget && (
|
{selectedTarget && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
@@ -353,20 +493,36 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">最新日变化</div>
|
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">最新日变化</div>
|
||||||
<div className={`mt-1 text-lg font-black ${trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
<div className={`mt-1 text-lg font-black ${trendLoading || trendError ? 'text-slate-500' : trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||||
{trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
|
{trendLoading ? '加载中' : trendError ? '暂不可用' : `${trendDelta >= 0 ? '+' : ''}${fmtKm(trendDelta)}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(selectedQualifiedRate)}</div>
|
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(selectedQualifiedRate)}</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedTarget && selectedCoverage && selectedCoverage.status !== 'matched' && (
|
||||||
|
<div role="alert" className="flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-amber-900">
|
||||||
|
<AlertTriangle size={15} className="mt-0.5 shrink-0 text-amber-600" />
|
||||||
|
<div className="min-w-0 text-[11px] font-bold leading-5">
|
||||||
|
<span className="font-black">考核车辆配置不一致:</span>
|
||||||
|
目标 {selectedCoverage.declaredCount} 台,当前有效 {selectedCoverage.activeCount} 台,
|
||||||
|
{selectedCoverage.status === 'missing'
|
||||||
|
? `缺少 ${Math.abs(selectedCoverage.difference)} 台`
|
||||||
|
: `超出 ${selectedCoverage.difference} 台`}。
|
||||||
|
完成率、缺口和归因按当前有效车辆计算。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedTarget && (
|
{selectedTarget && (
|
||||||
<MileageAttributionView
|
<MileageAttributionView
|
||||||
vehicles={selectedTargetVehicles}
|
vehicles={selectedTargetVehicles}
|
||||||
dimension={attributionDimension}
|
dimension={attributionDimension}
|
||||||
selectedValue={selectedBreakdownValue}
|
selectedValue={selectedBreakdownValue}
|
||||||
loading={!targetVehiclesMap[selectedTarget.id]}
|
loading={targetVehicleLoadingMap[selectedTarget.id]
|
||||||
|
|| (!targetVehiclesMap[selectedTarget.id] && !targetVehicleErrorMap[selectedTarget.id])}
|
||||||
|
error={targetVehicleErrorMap[selectedTarget.id]}
|
||||||
targetMileagePerVehicle={selectedTarget.annualMileagePerVehicle * (selectedAssessmentYear || 1)}
|
targetMileagePerVehicle={selectedTarget.annualMileagePerVehicle * (selectedAssessmentYear || 1)}
|
||||||
expectedShortfallMileage={selectedAssessment?.remaining}
|
expectedShortfallMileage={selectedAssessment?.remaining}
|
||||||
assessmentLabel={selectedAssessment?.label}
|
assessmentLabel={selectedAssessment?.label}
|
||||||
@@ -375,6 +531,7 @@ export default function StatisticsView() {
|
|||||||
if (drillContext.level === 'breakdown') closeVehiclePanel();
|
if (drillContext.level === 'breakdown') closeVehiclePanel();
|
||||||
}}
|
}}
|
||||||
onSelect={openAttributionGroup}
|
onSelect={openAttributionGroup}
|
||||||
|
onRetry={() => loadCurrentTargetVehicles(selectedTarget.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -439,6 +596,13 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[280px] w-full min-w-0">
|
<div className="h-[280px] w-full min-w-0">
|
||||||
|
{trendLoading ? (
|
||||||
|
<LoadingState label="正在加载里程趋势" variant="inline" />
|
||||||
|
) : trendError ? (
|
||||||
|
<ErrorState message={trendError} onRetry={retryTrend} variant="inline" />
|
||||||
|
) : trendData.length === 0 ? (
|
||||||
|
<EmptyState title="暂无趋势数据" description="当前考核目标在最近 7 天没有可用里程" variant="inline" />
|
||||||
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={280} minWidth={0}>
|
<ResponsiveContainer width="100%" height={280} minWidth={0}>
|
||||||
{chartType === 'bar' ? (
|
{chartType === 'bar' ? (
|
||||||
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||||
@@ -481,6 +645,7 @@ export default function StatisticsView() {
|
|||||||
</AreaChart>
|
</AreaChart>
|
||||||
)}
|
)}
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -734,10 +899,14 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => { fetchTargets().then(data => { setTargets(data); }).catch(() => {}); }}
|
type="button"
|
||||||
|
onClick={retryTargets}
|
||||||
|
disabled={targetsLoading}
|
||||||
|
title="刷新考核统计"
|
||||||
|
aria-label="刷新考核统计"
|
||||||
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
|
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw size={13} />
|
<RotateCcw size={13} className={targetsLoading ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsTableFullscreen(false)}
|
onClick={() => setIsTableFullscreen(false)}
|
||||||
@@ -748,6 +917,15 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{targetsError && (
|
||||||
|
<div role="alert" className="flex items-center justify-between gap-3 border-b border-rose-900/50 bg-rose-950/60 px-3 py-2 text-[10px] font-bold text-rose-200">
|
||||||
|
<span>{targetsError} 当前仍展示上一次成功加载的数据。</span>
|
||||||
|
<button type="button" onClick={retryTargets} disabled={targetsLoading} className="shrink-0 text-rose-100 underline disabled:opacity-60">
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table Area */}
|
{/* Table Area */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
@@ -847,7 +1025,7 @@ export default function StatisticsView() {
|
|||||||
{viewAllTargetName || selectedTarget?.targetName || '考核车辆'}
|
{viewAllTargetName || selectedTarget?.targetName || '考核车辆'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-xs font-bold uppercase tracking-widest text-slate-400">
|
<p className="mt-1 text-xs font-bold uppercase tracking-widest text-slate-400">
|
||||||
{selectedAssessment ? `${selectedAssessment.label} · ` : ''}车辆实时明细清单
|
{selectedAssessment ? `${selectedAssessment.label} · ` : ''}车辆日期明细清单
|
||||||
</p>
|
</p>
|
||||||
{selectedBreakdownValue && drillContext.dimension !== 'assessment-target' && (
|
{selectedBreakdownValue && drillContext.dimension !== 'assessment-target' && (
|
||||||
<p className="mt-1 text-[10px] font-bold text-blue-600">{selectedBreakdownValue}</p>
|
<p className="mt-1 text-[10px] font-bold text-blue-600">{selectedBreakdownValue}</p>
|
||||||
@@ -861,7 +1039,7 @@ export default function StatisticsView() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
|
<div className="px-4 py-3 border-b border-slate-50 space-y-2 md:px-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
||||||
@@ -879,15 +1057,26 @@ export default function StatisticsView() {
|
|||||||
type="date"
|
type="date"
|
||||||
value={viewAllDate}
|
value={viewAllDate}
|
||||||
onChange={(e) => setViewAllDate(e.target.value)}
|
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]"
|
className="w-[145px] bg-slate-50 py-2 pl-8 pr-2 text-xs font-bold text-slate-700 border border-slate-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
{viewAllLoading ? '加载中...' : (() => {
|
{viewAllLoading
|
||||||
const totalKm = searchedViewAllVehicles.reduce((sum, vehicle) => sum + (vehicle.todayMileage || 0), 0);
|
? hasViewAllSnapshot
|
||||||
return `${searchedViewAllVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
|
? `正在加载 ${viewAllDate} · 当前显示 ${viewAllLoadedDate}`
|
||||||
|
: `正在加载 ${viewAllDate}`
|
||||||
|
: viewAllError
|
||||||
|
? hasViewAllSnapshot
|
||||||
|
? `当前显示 ${viewAllLoadedDate} 已成功数据`
|
||||||
|
: `${viewAllDate} 暂不可用`
|
||||||
|
: (() => {
|
||||||
|
const totalKm = searchedViewAllVehicles.reduce(
|
||||||
|
(sum, vehicle) => sum + (vehicle.todayMileage || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return `${viewAllLoadedDate || viewAllDate} · ${searchedViewAllVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -901,6 +1090,34 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
||||||
|
{viewAllLoading && !hasViewAllSnapshot ? (
|
||||||
|
<LoadingState label={`正在加载 ${viewAllDate} 车辆明细`} variant="inline" />
|
||||||
|
) : viewAllError && !hasViewAllSnapshot ? (
|
||||||
|
<ErrorState
|
||||||
|
title="车辆明细加载失败"
|
||||||
|
message={viewAllError}
|
||||||
|
onRetry={retryViewAllVehicles}
|
||||||
|
retrying={viewAllLoading}
|
||||||
|
variant="inline"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{viewAllError && (
|
||||||
|
<ErrorState
|
||||||
|
title={`${viewAllDate} 车辆明细刷新失败`}
|
||||||
|
message={`${viewAllError} 当前保留 ${viewAllLoadedDate} 已成功加载的数据。`}
|
||||||
|
onRetry={retryViewAllVehicles}
|
||||||
|
retrying={viewAllLoading}
|
||||||
|
variant="inline"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!viewAllLoading && !viewAllError && searchedViewAllVehicles.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={viewAllHistoricalVehicles?.length ? '没有匹配车辆' : '该日期暂无车辆里程'}
|
||||||
|
description={viewAllHistoricalVehicles?.length ? '请调整车牌搜索条件' : `${viewAllLoadedDate || viewAllDate} 未返回可展示的车辆数据`}
|
||||||
|
variant="inline"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{[...searchedViewAllVehicles].sort((a, b) => {
|
{[...searchedViewAllVehicles].sort((a, b) => {
|
||||||
const valA = a.todayMileage || 0;
|
const valA = a.todayMileage || 0;
|
||||||
const valB = b.todayMileage || 0;
|
const valB = b.todayMileage || 0;
|
||||||
@@ -938,6 +1155,8 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-slate-50 border-t border-slate-100">
|
<div className="p-4 bg-slate-50 border-t border-slate-100">
|
||||||
|
|||||||
@@ -1,57 +1,42 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
||||||
import { X, Truck } from 'lucide-react';
|
import { RefreshCw, X, Truck } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { MileageSourceGroup, MonitoringVehicle } from './types';
|
import type { MileageSourceGroup, MonitoringVehicle } from './types';
|
||||||
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
import { EmptyState, ErrorState } from '../../components/ui/surface';
|
||||||
|
import {
|
||||||
|
includeCurrentDayInVehicleDetail,
|
||||||
|
normalizeVehicleDetailContext,
|
||||||
|
resolveVehicleDetailRange,
|
||||||
|
vehicleDetailContextLabel,
|
||||||
|
type VehicleDetailRangeKey,
|
||||||
|
} from './vehicle-detail-range';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vehicle: MonitoringVehicle | null;
|
vehicle: MonitoringVehicle | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
sourcePriority: MileageSourceGroup[];
|
sourcePriority: MileageSourceGroup[];
|
||||||
|
initialStartDate?: string;
|
||||||
|
initialEndDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RangeKey = 'last15' | 'month' | 'quarter';
|
const RANGE_TABS: { key: VehicleDetailRangeKey; label: string }[] = [
|
||||||
|
|
||||||
const RANGE_TABS: { key: RangeKey; label: string }[] = [
|
|
||||||
{ key: 'last15', label: '近 15 天' },
|
{ key: 'last15', label: '近 15 天' },
|
||||||
{ key: 'month', label: '本月' },
|
{ key: 'month', label: '本月' },
|
||||||
{ key: 'quarter', label: '本季度' },
|
{ key: 'quarter', label: '本季度' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function fmtYmd(d: Date): string {
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const dd = String(d.getDate()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${dd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: string } {
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const end = fmtYmd(today);
|
|
||||||
if (key === 'last15') {
|
|
||||||
const start = new Date(today);
|
|
||||||
start.setDate(today.getDate() - 14);
|
|
||||||
return { start: fmtYmd(start), end, rangeLabel: '近 15 天' };
|
|
||||||
}
|
|
||||||
if (key === 'month') {
|
|
||||||
const start = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
||||||
return { start: fmtYmd(start), end, rangeLabel: '本月' };
|
|
||||||
}
|
|
||||||
const q = Math.floor(today.getMonth() / 3);
|
|
||||||
const start = new Date(today.getFullYear(), q * 3, 1);
|
|
||||||
return { start: fmtYmd(start), end, rangeLabel: '本季度' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isToday(date: string): boolean {
|
function isToday(date: string): boolean {
|
||||||
return date === fmtYmd(new Date());
|
const now = new Date();
|
||||||
|
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
return date === today;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLabel(date: string, key: RangeKey): string {
|
function formatLabel(date: string, key: VehicleDetailRangeKey): string {
|
||||||
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD)
|
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD)
|
||||||
void key;
|
void key;
|
||||||
return date.slice(5);
|
return date.slice(5);
|
||||||
@@ -63,30 +48,65 @@ function daySourceLabel(day: VehicleRecentDay): string {
|
|||||||
return day.isDataSynced ? '来源待接口' : '无数据';
|
return day.isDataSynced ? '来源待接口' : '无数据';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) {
|
export default function VehicleDetailModal({
|
||||||
|
vehicle,
|
||||||
|
onClose,
|
||||||
|
sourcePriority,
|
||||||
|
initialStartDate,
|
||||||
|
initialEndDate,
|
||||||
|
}: Props) {
|
||||||
|
const contextRange = useMemo(
|
||||||
|
() => normalizeVehicleDetailContext(initialStartDate, initialEndDate),
|
||||||
|
[initialEndDate, initialStartDate],
|
||||||
|
);
|
||||||
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
||||||
|
const [loadedRequestKey, setLoadedRequestKey] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [range, setRange] = useState<RangeKey>('last15');
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [requestVersion, setRequestVersion] = useState(0);
|
||||||
|
const [range, setRange] = useState<VehicleDetailRangeKey>(() => (
|
||||||
|
contextRange ? 'context' : 'last15'
|
||||||
|
));
|
||||||
const dragControls = useDragControls();
|
const dragControls = useDragControls();
|
||||||
|
const resolvedRange = useMemo(
|
||||||
// 切换车辆时重置区间为默认
|
() => resolveVehicleDetailRange(range, contextRange),
|
||||||
useEffect(() => {
|
[contextRange, range],
|
||||||
if (vehicle) setRange('last15');
|
);
|
||||||
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
|
const sourceKey = sourcePriority.join(',');
|
||||||
|
const requestKey = vehicle
|
||||||
|
? `${vehicle.plate}|${resolvedRange.start}|${resolvedRange.end}|${sourceKey}`
|
||||||
|
: null;
|
||||||
|
const hasSnapshot = requestKey !== null && loadedRequestKey === requestKey;
|
||||||
|
const rangeTabs = useMemo(() => (
|
||||||
|
contextRange
|
||||||
|
? [{ key: 'context' as const, label: vehicleDetailContextLabel(contextRange) }, ...RANGE_TABS]
|
||||||
|
: RANGE_TABS
|
||||||
|
), [contextRange]);
|
||||||
|
|
||||||
// 拉取数据(车辆或区间变化)
|
// 拉取数据(车辆或区间变化)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!vehicle) return;
|
if (!vehicle || !requestKey) return;
|
||||||
const { start, end } = rangeFor(range);
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setDays([]);
|
setError(null);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
|
fetchVehicleRecent(vehicle.plate, {
|
||||||
.then(d => { if (!cancelled) setDays(d.days); })
|
start: resolvedRange.start,
|
||||||
.catch(() => { if (!cancelled) setDays([]); })
|
end: resolvedRange.end,
|
||||||
|
sourcePriority,
|
||||||
|
})
|
||||||
|
.then(d => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDays(d.days);
|
||||||
|
setLoadedRequestKey(requestKey);
|
||||||
|
})
|
||||||
|
.catch(cause => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(cause instanceof Error ? cause.message : '车辆近期里程加载失败,请稍后重试。');
|
||||||
|
}
|
||||||
|
})
|
||||||
.finally(() => { if (!cancelled) setLoading(false); });
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [requestKey, requestVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 锁滚动
|
// 锁滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,8 +115,11 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [vehicle]);
|
}, [vehicle]);
|
||||||
|
|
||||||
// 排除"今日"列(数据未到位时易引起误读)
|
// 预设区间排除未完整的今日;显式下钻区间严格保留用户选择的日期。
|
||||||
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
|
const historyDays = useMemo(() => {
|
||||||
|
if (!hasSnapshot) return [];
|
||||||
|
return includeCurrentDayInVehicleDetail(range) ? days : days.filter(d => !isToday(d.date));
|
||||||
|
}, [days, hasSnapshot, range]);
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
|
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
|
||||||
const synced = historyDays.filter(d => d.isDataSynced).length;
|
const synced = historyDays.filter(d => d.isDataSynced).length;
|
||||||
@@ -107,12 +130,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
|
|
||||||
// 骨架天数:根据区间预估
|
// 骨架天数:根据区间预估
|
||||||
const skeletonCount = useMemo(() => {
|
const skeletonCount = useMemo(() => {
|
||||||
if (range === 'last15') return 15;
|
const start = new Date(`${resolvedRange.start}T00:00:00`);
|
||||||
const { start, end } = rangeFor(range);
|
const end = new Date(`${resolvedRange.end}T00:00:00`);
|
||||||
const s = new Date(start);
|
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86400000) + 1);
|
||||||
const e = new Date(end);
|
}, [resolvedRange.end, resolvedRange.start]);
|
||||||
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
|
const showInitialLoading = loading && !hasSnapshot;
|
||||||
}, [range]);
|
const showUnavailable = !!error && !hasSnapshot;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -142,6 +165,9 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
}}
|
}}
|
||||||
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col touch-pan-y"
|
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col touch-pan-y"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="vehicle-detail-title"
|
||||||
>
|
>
|
||||||
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
|
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
|
||||||
<div
|
<div
|
||||||
@@ -160,7 +186,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
|
<span id="vehicle-detail-title" className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
|
||||||
<span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
|
<span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
|
||||||
{vehicle.isOnline ? '在线' : '离线'}
|
{vehicle.isOnline ? '在线' : '离线'}
|
||||||
</span>
|
</span>
|
||||||
@@ -173,19 +199,22 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
|
<button type="button" onClick={onClose} aria-label="关闭车辆详情" className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 时间范围切换 */}
|
{/* 时间范围切换 */}
|
||||||
<div className="px-4 pt-3">
|
<div className="px-4 pt-3">
|
||||||
<div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
|
<div className="relative inline-flex max-w-full overflow-x-auto bg-slate-100 p-0.5 rounded-lg" role="tablist" aria-label="车辆里程区间">
|
||||||
{RANGE_TABS.map(tab => (
|
{rangeTabs.map(tab => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setRange(tab.key)}
|
onClick={() => setRange(tab.key)}
|
||||||
className={`relative px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${range === tab.key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'}`}
|
role="tab"
|
||||||
|
aria-selected={range === tab.key}
|
||||||
|
className={`relative shrink-0 px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${range === tab.key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'}`}
|
||||||
>
|
>
|
||||||
{range === tab.key && (
|
{range === tab.key && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -198,28 +227,48 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{loading && hasSnapshot ? (
|
||||||
|
<span className="ml-2 inline-flex items-center gap-1 text-[9px] font-bold text-slate-400">
|
||||||
|
<RefreshCw size={10} className="animate-spin" /> 刷新中
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="px-4 pt-3">
|
||||||
|
<ErrorState
|
||||||
|
title="车辆近期里程加载失败"
|
||||||
|
message={hasSnapshot ? `${error} 当前保留上一次成功加载的数据。` : error}
|
||||||
|
onRetry={() => setRequestVersion(version => version + 1)}
|
||||||
|
retrying={loading}
|
||||||
|
variant="inline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* KPI cards */}
|
{/* KPI cards */}
|
||||||
<div className="px-4 py-3 grid grid-cols-3 gap-2">
|
<div className="px-4 py-3 grid grid-cols-3 gap-2">
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<div className="text-base font-black text-slate-900 leading-tight">
|
||||||
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
|
{showInitialLoading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
|
||||||
|
: showUnavailable ? <span className="text-slate-300">—</span>
|
||||||
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<div className="text-base font-black text-slate-900 leading-tight">
|
||||||
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
|
{showInitialLoading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
|
||||||
|
: showUnavailable ? <span className="text-slate-300">—</span>
|
||||||
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">有数据天</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">有数据天</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<div className="text-base font-black text-slate-900 leading-tight">
|
||||||
{loading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" />
|
{showInitialLoading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" />
|
||||||
|
: showUnavailable ? <span className="text-slate-300">—</span>
|
||||||
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
|
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,8 +282,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-slate-50">
|
<div className="bg-white rounded-xl border border-slate-50">
|
||||||
<div className="h-[140px]">
|
<div className="h-[140px]">
|
||||||
{loading ? (
|
{showInitialLoading ? (
|
||||||
<SkeletonBars count={Math.min(skeletonCount, 30)} />
|
<SkeletonBars count={Math.min(skeletonCount, 30)} />
|
||||||
|
) : showUnavailable ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-[10px] font-bold text-slate-300">所选区间趋势暂不可用</div>
|
||||||
|
) : historyDays.length === 0 ? (
|
||||||
|
<EmptyState title="暂无里程数据" description="所选区间没有可展示的车辆里程" variant="inline" />
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||||
@@ -269,8 +322,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
{/* 每日明细 */}
|
{/* 每日明细 */}
|
||||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
<div className="text-[10px] font-bold text-slate-500 mb-1.5">每日明细</div>
|
<div className="text-[10px] font-bold text-slate-500 mb-1.5">每日明细</div>
|
||||||
{loading ? (
|
{showInitialLoading ? (
|
||||||
<SkeletonList count={Math.min(skeletonCount, 15)} />
|
<SkeletonList count={Math.min(skeletonCount, 15)} />
|
||||||
|
) : showUnavailable ? (
|
||||||
|
<div className="py-6 text-center text-[10px] font-bold text-slate-300">请重试加载车辆每日明细</div>
|
||||||
|
) : historyDays.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-[10px] font-bold text-slate-300">所选区间暂无每日明细</div>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={range}
|
key={range}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { assessVehicleCoverage } from './assessment-coverage.js';
|
||||||
|
|
||||||
|
test('reports matched, missing, and excess assessment vehicles', () => {
|
||||||
|
assert.deepEqual(assessVehicleCoverage(50, 46), {
|
||||||
|
status: 'missing',
|
||||||
|
declaredCount: 50,
|
||||||
|
activeCount: 46,
|
||||||
|
difference: -4,
|
||||||
|
coverageRate: 92,
|
||||||
|
});
|
||||||
|
assert.equal(assessVehicleCoverage(40, 40).status, 'matched');
|
||||||
|
assert.equal(assessVehicleCoverage(40, 42).status, 'excess');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes invalid counts without inventing configured vehicles', () => {
|
||||||
|
assert.deepEqual(assessVehicleCoverage(Number.NaN, -2), {
|
||||||
|
status: 'matched',
|
||||||
|
declaredCount: 0,
|
||||||
|
activeCount: 0,
|
||||||
|
difference: 0,
|
||||||
|
coverageRate: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type AssessmentCoverageStatus = 'matched' | 'missing' | 'excess';
|
||||||
|
|
||||||
|
export interface AssessmentCoverage {
|
||||||
|
status: AssessmentCoverageStatus;
|
||||||
|
declaredCount: number;
|
||||||
|
activeCount: number;
|
||||||
|
difference: number;
|
||||||
|
coverageRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(value: number): number {
|
||||||
|
return Math.max(0, Math.trunc(Number(value) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assessVehicleCoverage(declaredCount: number, activeCount: number): AssessmentCoverage {
|
||||||
|
const declared = count(declaredCount);
|
||||||
|
const active = count(activeCount);
|
||||||
|
const difference = active - declared;
|
||||||
|
return {
|
||||||
|
status: difference === 0 ? 'matched' : difference < 0 ? 'missing' : 'excess',
|
||||||
|
declaredCount: declared,
|
||||||
|
activeCount: active,
|
||||||
|
difference,
|
||||||
|
coverageRate: declared > 0 ? active / declared * 100 : active === 0 ? 100 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
buildMileageMonitoringUrl,
|
||||||
|
parseMileageMonitoringContext,
|
||||||
|
} from './monitoring-context.js';
|
||||||
|
|
||||||
|
test('parses a complete mileage monitoring context', () => {
|
||||||
|
const context = parseMileageMonitoringContext(
|
||||||
|
'?mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=gps,instrument'
|
||||||
|
+ '&mileageBatch=%E4%BA%A4%E6%8A%95190%E8%BE%86&mileageBatch=%E7%BE%9A%E7%89%9B136%E8%BE%86'
|
||||||
|
+ '&mileageRegion=%E5%B9%BF%E5%B7%9E&mileageFilterPlate=%E7%B2%A4A12345&mileageDepartment=%E4%B8%9A%E5%8A%A1%E4%B8%80%E9%83%A8'
|
||||||
|
+ '&mileageCustomer=%E5%AE%A2%E6%88%B7A&mileageProject=%E9%A1%B9%E7%9B%AEA&mileageEntity=%E4%B8%BB%E4%BD%93A'
|
||||||
|
+ '&mileageRentStatus=%E7%A7%9F%E8%B5%81&mileageBrand=%E7%8E%B0%E4%BB%A3&mileagePlatePrefix=%E7%B2%A4A'
|
||||||
|
+ '&mileageSearch=123&mileageMin=10&mileageMax=500&mileageSort=total&mileageOrder=asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(context, {
|
||||||
|
startDate: '2026-08-01',
|
||||||
|
endDate: '2026-08-07',
|
||||||
|
sourcePriority: ['gps', 'instrument'],
|
||||||
|
targetNames: ['交投190辆', '羚牛136辆'],
|
||||||
|
region: '广州',
|
||||||
|
plates: ['粤A12345'],
|
||||||
|
department: '业务一部',
|
||||||
|
customer: '客户A',
|
||||||
|
project: '项目A',
|
||||||
|
entity: '主体A',
|
||||||
|
rentStatus: '租赁',
|
||||||
|
brands: ['现代'],
|
||||||
|
platePrefix: '粤A',
|
||||||
|
search: '123',
|
||||||
|
mileageMin: '10',
|
||||||
|
mileageMax: '500',
|
||||||
|
sortBy: 'total',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes invalid monitoring URL values', () => {
|
||||||
|
const context = parseMileageMonitoringContext(
|
||||||
|
'?mileageStart=invalid&mileageSource=unknown&mileageBatch=&mileageRegion=All&mileageSort=unknown&mileageOrder=unknown',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(context, {
|
||||||
|
startDate: undefined,
|
||||||
|
endDate: undefined,
|
||||||
|
sourcePriority: ['instrument'],
|
||||||
|
targetNames: [],
|
||||||
|
region: undefined,
|
||||||
|
plates: [],
|
||||||
|
department: undefined,
|
||||||
|
customer: undefined,
|
||||||
|
project: undefined,
|
||||||
|
entity: undefined,
|
||||||
|
rentStatus: undefined,
|
||||||
|
brands: [],
|
||||||
|
platePrefix: undefined,
|
||||||
|
search: undefined,
|
||||||
|
mileageMin: undefined,
|
||||||
|
mileageMax: undefined,
|
||||||
|
sortBy: 'today',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds monitoring state without removing drill or unrelated context', () => {
|
||||||
|
const url = buildMileageMonitoringUrl(
|
||||||
|
{
|
||||||
|
pathname: '/asset',
|
||||||
|
search: '?company=5&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageBatch=old',
|
||||||
|
hash: '#mileage',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: '2026-08-01',
|
||||||
|
endDate: '2026-08-07',
|
||||||
|
sourcePriority: ['instrument'],
|
||||||
|
targetNames: ['交投190辆', '羚牛136辆'],
|
||||||
|
region: '广州',
|
||||||
|
plates: ['粤A12345'],
|
||||||
|
department: undefined,
|
||||||
|
customer: undefined,
|
||||||
|
project: undefined,
|
||||||
|
entity: undefined,
|
||||||
|
rentStatus: undefined,
|
||||||
|
brands: [],
|
||||||
|
platePrefix: undefined,
|
||||||
|
search: undefined,
|
||||||
|
mileageMin: undefined,
|
||||||
|
mileageMax: undefined,
|
||||||
|
sortBy: 'statisticTime',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
url,
|
||||||
|
'/asset?company=5&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument&mileageBatch=%E4%BA%A4%E6%8A%95190%E8%BE%86&mileageBatch=%E7%BE%9A%E7%89%9B136%E8%BE%86&mileageRegion=%E5%B9%BF%E5%B7%9E&mileageFilterPlate=%E7%B2%A4A12345&mileageSort=statisticTime&mileageOrder=asc#mileage',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import type { MileageSourceGroup } from './types';
|
||||||
|
|
||||||
|
export type MonitoringSortBy = 'today' | 'total' | 'statisticTime';
|
||||||
|
export type MonitoringSortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export interface MileageMonitoringContext {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
sourcePriority: MileageSourceGroup[];
|
||||||
|
targetNames: string[];
|
||||||
|
region?: string;
|
||||||
|
plates: string[];
|
||||||
|
department?: string;
|
||||||
|
customer?: string;
|
||||||
|
project?: string;
|
||||||
|
entity?: string;
|
||||||
|
rentStatus?: string;
|
||||||
|
brands: string[];
|
||||||
|
platePrefix?: string;
|
||||||
|
search?: string;
|
||||||
|
mileageMin?: string;
|
||||||
|
mileageMax?: string;
|
||||||
|
sortBy: MonitoringSortBy;
|
||||||
|
sortOrder: MonitoringSortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationParts {
|
||||||
|
pathname: string;
|
||||||
|
search: string;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const CONTEXT_KEYS = [
|
||||||
|
'mileageStart',
|
||||||
|
'mileageEnd',
|
||||||
|
'mileageSource',
|
||||||
|
'mileageBatch',
|
||||||
|
'mileageRegion',
|
||||||
|
'mileageFilterPlate',
|
||||||
|
'mileageDepartment',
|
||||||
|
'mileageCustomer',
|
||||||
|
'mileageProject',
|
||||||
|
'mileageEntity',
|
||||||
|
'mileageRentStatus',
|
||||||
|
'mileageBrand',
|
||||||
|
'mileagePlatePrefix',
|
||||||
|
'mileageSearch',
|
||||||
|
'mileageMin',
|
||||||
|
'mileageMax',
|
||||||
|
'mileageSort',
|
||||||
|
'mileageOrder',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function validDate(value: string | null): string | undefined {
|
||||||
|
if (!value || !DATE_PATTERN.test(value)) return undefined;
|
||||||
|
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
|
||||||
|
return Number.isFinite(timestamp) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedValue(value: string | null, maxLength = 200): string | undefined {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
if (!normalized || normalized === 'All') return undefined;
|
||||||
|
return normalized.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueValues(values: string[], limit = 200): string[] {
|
||||||
|
return Array.from(new Set(values.map(value => boundedValue(value)).filter((value): value is string => !!value))).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSourcePriority(value: string | null): MileageSourceGroup[] {
|
||||||
|
const sources = value
|
||||||
|
?.split(',')
|
||||||
|
.filter((source): source is MileageSourceGroup => source === 'instrument' || source === 'gps');
|
||||||
|
return sources && sources.length > 0 ? Array.from(new Set(sources)).slice(0, 2) : ['instrument'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendValues(params: URLSearchParams, key: string, values: string[]): void {
|
||||||
|
uniqueValues(values).forEach(value => params.append(key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMileageMonitoringContext(search: string): MileageMonitoringContext {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const rawSortBy = params.get('mileageSort');
|
||||||
|
const rawSortOrder = params.get('mileageOrder');
|
||||||
|
return {
|
||||||
|
startDate: validDate(params.get('mileageStart')),
|
||||||
|
endDate: validDate(params.get('mileageEnd')),
|
||||||
|
sourcePriority: parseSourcePriority(params.get('mileageSource')),
|
||||||
|
targetNames: uniqueValues(params.getAll('mileageBatch')),
|
||||||
|
region: boundedValue(params.get('mileageRegion')),
|
||||||
|
plates: uniqueValues(params.getAll('mileageFilterPlate')),
|
||||||
|
department: boundedValue(params.get('mileageDepartment')),
|
||||||
|
customer: boundedValue(params.get('mileageCustomer')),
|
||||||
|
project: boundedValue(params.get('mileageProject')),
|
||||||
|
entity: boundedValue(params.get('mileageEntity')),
|
||||||
|
rentStatus: boundedValue(params.get('mileageRentStatus')),
|
||||||
|
brands: uniqueValues(params.getAll('mileageBrand')),
|
||||||
|
platePrefix: boundedValue(params.get('mileagePlatePrefix')),
|
||||||
|
search: boundedValue(params.get('mileageSearch')),
|
||||||
|
mileageMin: boundedValue(params.get('mileageMin'), 32),
|
||||||
|
mileageMax: boundedValue(params.get('mileageMax'), 32),
|
||||||
|
sortBy: rawSortBy === 'total' || rawSortBy === 'statisticTime' ? rawSortBy : 'today',
|
||||||
|
sortOrder: rawSortOrder === 'asc' ? 'asc' : 'desc',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMileageMonitoringUrl(
|
||||||
|
location: LocationParts,
|
||||||
|
context: MileageMonitoringContext,
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
CONTEXT_KEYS.forEach(key => params.delete(key));
|
||||||
|
|
||||||
|
if (context.startDate) params.set('mileageStart', context.startDate);
|
||||||
|
if (context.endDate) params.set('mileageEnd', context.endDate);
|
||||||
|
if (context.sourcePriority.length > 0) params.set('mileageSource', context.sourcePriority.join(','));
|
||||||
|
appendValues(params, 'mileageBatch', context.targetNames);
|
||||||
|
if (context.region) params.set('mileageRegion', context.region);
|
||||||
|
appendValues(params, 'mileageFilterPlate', context.plates);
|
||||||
|
if (context.department) params.set('mileageDepartment', context.department);
|
||||||
|
if (context.customer) params.set('mileageCustomer', context.customer);
|
||||||
|
if (context.project) params.set('mileageProject', context.project);
|
||||||
|
if (context.entity) params.set('mileageEntity', context.entity);
|
||||||
|
if (context.rentStatus) params.set('mileageRentStatus', context.rentStatus);
|
||||||
|
appendValues(params, 'mileageBrand', context.brands);
|
||||||
|
if (context.platePrefix) params.set('mileagePlatePrefix', context.platePrefix);
|
||||||
|
if (context.search) params.set('mileageSearch', context.search);
|
||||||
|
if (context.mileageMin) params.set('mileageMin', context.mileageMin);
|
||||||
|
if (context.mileageMax) params.set('mileageMax', context.mileageMax);
|
||||||
|
if (context.sortBy !== 'today') params.set('mileageSort', context.sortBy);
|
||||||
|
if (context.sortOrder !== 'desc') params.set('mileageOrder', context.sortOrder);
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildMonitoringKpis } from './monitoring-kpis.js';
|
||||||
|
|
||||||
|
test('builds daily monitoring KPIs from the selected-day flow metric', () => {
|
||||||
|
const model = buildMonitoringKpis(
|
||||||
|
{ totalToday: 120, totalAll: 3_000, vehicleCount: 3, yesterdayTotal: 100 },
|
||||||
|
{ start: '2026-08-07', end: '2026-08-07' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(model, {
|
||||||
|
distanceMetricId: 'mileage.daily_total',
|
||||||
|
distanceLabel: '当日总里程',
|
||||||
|
distanceShortLabel: '当日',
|
||||||
|
distanceKm: 120,
|
||||||
|
averagePerVehicleKm: 40,
|
||||||
|
vehicleCount: 3,
|
||||||
|
cumulativeOdometerKm: 3_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds range KPIs without substituting cumulative odometer values', () => {
|
||||||
|
const model = buildMonitoringKpis(
|
||||||
|
{ totalToday: 900, totalAll: 30_000, vehicleCount: 6, yesterdayTotal: 0 },
|
||||||
|
{ start: '2026-08-01', end: '2026-08-07' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(model.distanceMetricId, 'mileage.period_total');
|
||||||
|
assert.equal(model.distanceLabel, '区间总里程');
|
||||||
|
assert.equal(model.distanceKm, 900);
|
||||||
|
assert.equal(model.averagePerVehicleKm, 150);
|
||||||
|
assert.equal(model.cumulativeOdometerKm, 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes invalid monitoring KPI inputs', () => {
|
||||||
|
const model = buildMonitoringKpis({
|
||||||
|
totalToday: Number.NaN,
|
||||||
|
totalAll: -10,
|
||||||
|
vehicleCount: Number.POSITIVE_INFINITY,
|
||||||
|
yesterdayTotal: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(model.distanceKm, 0);
|
||||||
|
assert.equal(model.averagePerVehicleKm, 0);
|
||||||
|
assert.equal(model.vehicleCount, 0);
|
||||||
|
assert.equal(model.cumulativeOdometerKm, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { MonitoringStats } from './types';
|
||||||
|
|
||||||
|
export interface MonitoringKpiModel {
|
||||||
|
distanceMetricId: 'mileage.daily_total' | 'mileage.period_total';
|
||||||
|
distanceLabel: '当日总里程' | '区间总里程';
|
||||||
|
distanceShortLabel: '当日' | '区间';
|
||||||
|
distanceKm: number;
|
||||||
|
averagePerVehicleKm: number;
|
||||||
|
vehicleCount: number;
|
||||||
|
cumulativeOdometerKm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMonitoringKpis(
|
||||||
|
stats: MonitoringStats,
|
||||||
|
dateRange?: { start: string; end: string },
|
||||||
|
): MonitoringKpiModel {
|
||||||
|
const isRange = !!dateRange?.start && !!dateRange?.end && dateRange.start !== dateRange.end;
|
||||||
|
const vehicleCount = Number.isFinite(stats.vehicleCount) ? Math.max(0, stats.vehicleCount) : 0;
|
||||||
|
const distanceKm = Number.isFinite(stats.totalToday) ? Math.max(0, stats.totalToday) : 0;
|
||||||
|
const cumulativeOdometerKm = Number.isFinite(stats.totalAll) ? Math.max(0, stats.totalAll) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
distanceMetricId: isRange ? 'mileage.period_total' : 'mileage.daily_total',
|
||||||
|
distanceLabel: isRange ? '区间总里程' : '当日总里程',
|
||||||
|
distanceShortLabel: isRange ? '区间' : '当日',
|
||||||
|
distanceKm,
|
||||||
|
averagePerVehicleKm: vehicleCount > 0 ? distanceKm / vehicleCount : 0,
|
||||||
|
vehicleCount,
|
||||||
|
cumulativeOdometerKm,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ export interface TargetSummary {
|
|||||||
id: number;
|
id: number;
|
||||||
targetName: string;
|
targetName: string;
|
||||||
snapshotUpdatedAt: string | null;
|
snapshotUpdatedAt: string | null;
|
||||||
|
declaredVehicleCount: number;
|
||||||
vehicleCount: number;
|
vehicleCount: number;
|
||||||
totalMileagePerVehicle: number;
|
totalMileagePerVehicle: number;
|
||||||
annualMileagePerVehicle: number;
|
annualMileagePerVehicle: number;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
includeCurrentDayInVehicleDetail,
|
||||||
|
normalizeVehicleDetailContext,
|
||||||
|
resolveVehicleDetailRange,
|
||||||
|
vehicleDetailContextLabel,
|
||||||
|
} from './vehicle-detail-range.js';
|
||||||
|
|
||||||
|
const NOW = new Date(2026, 7, 7, 12, 0, 0);
|
||||||
|
|
||||||
|
test('normalizes and preserves a selected vehicle drill range', () => {
|
||||||
|
const context = normalizeVehicleDetailContext('2026-08-07', '2026-08-05');
|
||||||
|
assert.deepEqual(context, { start: '2026-08-05', end: '2026-08-07' });
|
||||||
|
assert.deepEqual(resolveVehicleDetailRange('context', context, NOW), context);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid context dates and resolves calendar presets', () => {
|
||||||
|
assert.equal(normalizeVehicleDetailContext('2026-02-30', '2026-03-01'), null);
|
||||||
|
assert.deepEqual(resolveVehicleDetailRange('last15', null, NOW), {
|
||||||
|
start: '2026-07-24',
|
||||||
|
end: '2026-08-07',
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolveVehicleDetailRange('month', null, NOW), {
|
||||||
|
start: '2026-08-01',
|
||||||
|
end: '2026-08-07',
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolveVehicleDetailRange('quarter', null, NOW), {
|
||||||
|
start: '2026-07-01',
|
||||||
|
end: '2026-08-07',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('labels selected dates and includes today only for explicit context', () => {
|
||||||
|
const context = { start: '2026-08-07', end: '2026-08-07' };
|
||||||
|
assert.equal(vehicleDetailContextLabel(context), '8月7日');
|
||||||
|
assert.equal(includeCurrentDayInVehicleDetail('context'), true);
|
||||||
|
assert.equal(includeCurrentDayInVehicleDetail('last15'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export type VehicleDetailRangeKey = 'context' | 'last15' | 'month' | 'quarter';
|
||||||
|
|
||||||
|
export interface VehicleDetailDateRange {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||||
|
|
||||||
|
function fmtYmd(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validDate(value?: string): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const match = DATE_PATTERN.exec(value);
|
||||||
|
if (!match) return null;
|
||||||
|
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||||
|
return fmtYmd(date) === value ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeVehicleDetailContext(
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string,
|
||||||
|
): VehicleDetailDateRange | null {
|
||||||
|
const start = validDate(startDate);
|
||||||
|
const end = validDate(endDate);
|
||||||
|
if (!start || !end) return null;
|
||||||
|
return start <= end ? { start, end } : { start: end, end: start };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVehicleDetailRange(
|
||||||
|
key: VehicleDetailRangeKey,
|
||||||
|
context: VehicleDetailDateRange | null,
|
||||||
|
now = new Date(),
|
||||||
|
): VehicleDetailDateRange {
|
||||||
|
if (key === 'context' && context) return context;
|
||||||
|
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const end = fmtYmd(today);
|
||||||
|
if (key === 'month') {
|
||||||
|
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end };
|
||||||
|
}
|
||||||
|
if (key === 'quarter') {
|
||||||
|
const quarterStartMonth = Math.floor(today.getMonth() / 3) * 3;
|
||||||
|
return { start: fmtYmd(new Date(today.getFullYear(), quarterStartMonth, 1)), end };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(today);
|
||||||
|
start.setDate(today.getDate() - 14);
|
||||||
|
return { start: fmtYmd(start), end };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vehicleDetailContextLabel(context: VehicleDetailDateRange): string {
|
||||||
|
if (context.start === context.end) {
|
||||||
|
return `${Number(context.start.slice(5, 7))}月${Number(context.start.slice(8, 10))}日`;
|
||||||
|
}
|
||||||
|
return '所选区间';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function includeCurrentDayInVehicleDetail(key: VehicleDetailRangeKey): boolean {
|
||||||
|
return key === 'context';
|
||||||
|
}
|
||||||
@@ -7,9 +7,10 @@ test('returns the published mileage metric contract', async () => {
|
|||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
|
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.equal(payload.catalogVersion, 6);
|
assert.equal(payload.catalogVersion, 7);
|
||||||
assert.deepEqual(payload.domains, ['mileage']);
|
assert.deepEqual(payload.domains, ['mileage']);
|
||||||
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate'));
|
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate'));
|
||||||
|
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.average_per_vehicle'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns the published hydrogen metric contract', async () => {
|
test('returns the published hydrogen metric contract', async () => {
|
||||||
@@ -27,7 +28,7 @@ test('returns the published electric metric contract', async () => {
|
|||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
|
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.equal(payload.catalogVersion, 6);
|
assert.equal(payload.catalogVersion, 7);
|
||||||
assert.deepEqual(payload.domains, ['electric']);
|
assert.deepEqual(payload.domains, ['electric']);
|
||||||
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.charge_total_fee'));
|
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.charge_total_fee'));
|
||||||
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.blended_cost_intensity'));
|
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.blended_cost_intensity'));
|
||||||
@@ -39,7 +40,7 @@ test('returns the published ETC metric contract', async () => {
|
|||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
|
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.equal(payload.catalogVersion, 6);
|
assert.equal(payload.catalogVersion, 7);
|
||||||
assert.deepEqual(payload.domains, ['etc']);
|
assert.deepEqual(payload.domains, ['etc']);
|
||||||
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.total_amount'));
|
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.total_amount'));
|
||||||
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.bill_receivable'));
|
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.bill_receivable'));
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
storeManualMonitoringSnapshot,
|
storeManualMonitoringSnapshot,
|
||||||
} from './manual-snapshot.js';
|
} from './manual-snapshot.js';
|
||||||
import { sumMileageKm } from './precision.js';
|
import { sumMileageKm } from './precision.js';
|
||||||
|
import {
|
||||||
|
rangeMileageSnapshotCache,
|
||||||
|
type RangeSnapshotStatus,
|
||||||
|
} from './range-snapshot.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -148,7 +152,7 @@ app.get('/', async (c) => {
|
|||||||
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
|
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
|
||||||
let dateRange: { start: string; end: string } | undefined;
|
let dateRange: { start: string; end: string } | undefined;
|
||||||
let dataUpdatedAt: string | undefined;
|
let dataUpdatedAt: string | undefined;
|
||||||
let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' = 'bypass';
|
let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' | RangeSnapshotStatus = 'bypass';
|
||||||
|
|
||||||
if (range) {
|
if (range) {
|
||||||
const cache = getCache();
|
const cache = getCache();
|
||||||
@@ -193,7 +197,14 @@ app.get('/', async (c) => {
|
|||||||
} else {
|
} else {
|
||||||
cacheStatus = cacheEligible ? 'miss' : 'bypass';
|
cacheStatus = cacheEligible ? 'miss' : 'bypass';
|
||||||
try {
|
try {
|
||||||
const result = await queryRangeMileage(range.start, range.end, protocolPriority);
|
const loaded = await rangeMileageSnapshotCache.load({
|
||||||
|
startDate: range.start,
|
||||||
|
endDate: range.end,
|
||||||
|
protocolPriority,
|
||||||
|
force,
|
||||||
|
}, () => queryRangeMileage(range.start, range.end, protocolPriority));
|
||||||
|
const { result } = loaded;
|
||||||
|
cacheStatus = loaded.status;
|
||||||
allVehicles = result.vehicles;
|
allVehicles = result.vehicles;
|
||||||
rangeDailyTotals = result.dailyTotals;
|
rangeDailyTotals = result.dailyTotals;
|
||||||
dateRange = { start: result.start, end: result.end };
|
dateRange = { start: result.start, end: result.end };
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { RangeMileageSnapshotCache } from './range-snapshot.js';
|
||||||
|
import type { RangeMileageResult } from './cache.js';
|
||||||
|
|
||||||
|
function result(start = '2026-08-01', end = '2026-08-07'): RangeMileageResult {
|
||||||
|
return { vehicles: [], dailyTotals: [], start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
startDate: '2026-08-01',
|
||||||
|
endDate: '2026-08-07',
|
||||||
|
protocolPriority: ['GB32960'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('reuses a completed range snapshot until its TTL expires', async () => {
|
||||||
|
let now = 1_000;
|
||||||
|
let calls = 0;
|
||||||
|
const cache = new RangeMileageSnapshotCache({ ttlMs: 60_000, now: () => now });
|
||||||
|
const loader = async () => {
|
||||||
|
calls += 1;
|
||||||
|
return result();
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal((await cache.load(input, loader)).status, 'range-miss');
|
||||||
|
assert.equal((await cache.load(input, loader)).status, 'range-hit');
|
||||||
|
now = 61_000;
|
||||||
|
assert.equal((await cache.load(input, loader)).status, 'range-miss');
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deduplicates concurrent requests for the same range', async () => {
|
||||||
|
const cache = new RangeMileageSnapshotCache();
|
||||||
|
let calls = 0;
|
||||||
|
let resolve!: (value: RangeMileageResult) => void;
|
||||||
|
const pending = new Promise<RangeMileageResult>(done => { resolve = done; });
|
||||||
|
const loader = async () => {
|
||||||
|
calls += 1;
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = cache.load(input, loader);
|
||||||
|
const second = cache.load(input, loader);
|
||||||
|
resolve(result());
|
||||||
|
|
||||||
|
assert.equal((await first).status, 'range-miss');
|
||||||
|
assert.equal((await second).status, 'range-inflight');
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps date ranges and source protocols in separate cache keys', async () => {
|
||||||
|
const cache = new RangeMileageSnapshotCache();
|
||||||
|
let calls = 0;
|
||||||
|
const loader = async () => {
|
||||||
|
calls += 1;
|
||||||
|
return result();
|
||||||
|
};
|
||||||
|
|
||||||
|
await cache.load(input, loader);
|
||||||
|
await cache.load({ ...input, endDate: '2026-08-06' }, loader);
|
||||||
|
await cache.load({ ...input, protocolPriority: ['MQTT'] }, loader);
|
||||||
|
assert.equal(calls, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('force refresh bypasses a completed snapshot', async () => {
|
||||||
|
const cache = new RangeMileageSnapshotCache();
|
||||||
|
let calls = 0;
|
||||||
|
const loader = async () => {
|
||||||
|
calls += 1;
|
||||||
|
return result();
|
||||||
|
};
|
||||||
|
|
||||||
|
await cache.load(input, loader);
|
||||||
|
const refreshed = await cache.load({ ...input, force: true }, loader);
|
||||||
|
assert.equal(refreshed.status, 'range-refresh');
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not cache failed range requests', async () => {
|
||||||
|
const cache = new RangeMileageSnapshotCache();
|
||||||
|
let calls = 0;
|
||||||
|
const loader = async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) throw new Error('upstream unavailable');
|
||||||
|
return result();
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(cache.load(input, loader), /upstream unavailable/);
|
||||||
|
const recovered = await cache.load(input, loader);
|
||||||
|
assert.equal(recovered.status, 'range-miss');
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { RangeMileageResult } from './cache.js';
|
||||||
|
import type { OneOsProtocol } from './source-policy.js';
|
||||||
|
|
||||||
|
export type RangeSnapshotStatus = 'range-hit' | 'range-miss' | 'range-refresh' | 'range-inflight';
|
||||||
|
|
||||||
|
export interface RangeSnapshotLoadResult {
|
||||||
|
result: RangeMileageResult;
|
||||||
|
status: RangeSnapshotStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RangeSnapshotEntry {
|
||||||
|
result: RangeMileageResult;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RangeSnapshotCacheOptions {
|
||||||
|
ttlMs?: number;
|
||||||
|
maxEntries?: number;
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RangeSnapshotInput {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
protocolPriority: readonly OneOsProtocol[];
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TTL_MS = 60 * 1000;
|
||||||
|
const DEFAULT_MAX_ENTRIES = 8;
|
||||||
|
|
||||||
|
function snapshotKey(input: RangeSnapshotInput): string {
|
||||||
|
return [input.startDate, input.endDate, input.protocolPriority.join('>')].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RangeMileageSnapshotCache {
|
||||||
|
private readonly entries = new Map<string, RangeSnapshotEntry>();
|
||||||
|
private readonly inflight = new Map<string, Promise<RangeMileageResult>>();
|
||||||
|
private readonly ttlMs: number;
|
||||||
|
private readonly maxEntries: number;
|
||||||
|
private readonly now: () => number;
|
||||||
|
|
||||||
|
constructor(options: RangeSnapshotCacheOptions = {}) {
|
||||||
|
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||||
|
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
||||||
|
this.now = options.now ?? Date.now;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(
|
||||||
|
input: RangeSnapshotInput,
|
||||||
|
loader: () => Promise<RangeMileageResult>,
|
||||||
|
): Promise<RangeSnapshotLoadResult> {
|
||||||
|
const key = snapshotKey(input);
|
||||||
|
if (!input.force) {
|
||||||
|
const entry = this.entries.get(key);
|
||||||
|
if (entry && entry.expiresAt > this.now()) {
|
||||||
|
this.entries.delete(key);
|
||||||
|
this.entries.set(key, entry);
|
||||||
|
return { result: entry.result, status: 'range-hit' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.inflight.get(key);
|
||||||
|
if (existing) {
|
||||||
|
return { result: await existing, status: 'range-inflight' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = loader()
|
||||||
|
.then(result => {
|
||||||
|
this.entries.delete(key);
|
||||||
|
this.entries.set(key, {
|
||||||
|
result,
|
||||||
|
expiresAt: this.now() + this.ttlMs,
|
||||||
|
});
|
||||||
|
while (this.entries.size > this.maxEntries) {
|
||||||
|
const oldest = this.entries.keys().next().value as string | undefined;
|
||||||
|
if (!oldest) break;
|
||||||
|
this.entries.delete(oldest);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.finally(() => this.inflight.delete(key));
|
||||||
|
this.inflight.set(key, pending);
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: await pending,
|
||||||
|
status: input.force ? 'range-refresh' : 'range-miss',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.entries.clear();
|
||||||
|
this.inflight.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rangeMileageSnapshotCache = new RangeMileageSnapshotCache();
|
||||||
@@ -201,7 +201,8 @@ app.get('/', async (c) => {
|
|||||||
id: t.id,
|
id: t.id,
|
||||||
targetName: t.target_name,
|
targetName: t.target_name,
|
||||||
snapshotUpdatedAt: s.snapshot_updated_at || null,
|
snapshotUpdatedAt: s.snapshot_updated_at || null,
|
||||||
vehicleCount: Number(s.total) || t.vehicle_count,
|
declaredVehicleCount: Number(t.vehicle_count) || 0,
|
||||||
|
vehicleCount: s.total == null ? 0 : Number(s.total) || 0,
|
||||||
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
|
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
|
||||||
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
||||||
assessmentYears: t.assessment_years,
|
assessmentYears: t.assessment_years,
|
||||||
|
|||||||
@@ -98,7 +98,11 @@ app.get('/:plate/recent', async (c) => {
|
|||||||
});
|
});
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('vehicle recent error:', e);
|
console.error('vehicle recent error:', e);
|
||||||
return c.json({ plate, days: [] }, 500);
|
return c.json({
|
||||||
|
error: '车辆近期里程服务暂时不可用',
|
||||||
|
code: 'MILEAGE_RECENT_UNAVAILABLE',
|
||||||
|
retryable: true,
|
||||||
|
}, 503);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ test('lists mileage metrics without exposing the catalog array', () => {
|
|||||||
metrics.find(metric => metric.id === 'mileage.cumulative_odometer_sum')?.timeSemantics,
|
metrics.find(metric => metric.id === 'mileage.cumulative_odometer_sum')?.timeSemantics,
|
||||||
'snapshot',
|
'snapshot',
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
metrics.find(metric => metric.id === 'mileage.average_per_vehicle')?.formula,
|
||||||
|
'mileage.period_total / NULLIF(mileage.vehicle_count, 0)',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('publishes hydrogen cost, revenue, and gross profit as separate metrics', () => {
|
test('publishes hydrogen cost, revenue, and gross profit as separate metrics', () => {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface MetricDefinition {
|
|||||||
drillEntity: 'vehicle' | 'assessment-target' | 'hydrogen-station' | 'hydrogen-customer' | 'hydrogen-order' | 'electric-charge-order' | 'etc-toll-record' | 'etc-bill';
|
drillEntity: 'vehicle' | 'assessment-target' | 'hydrogen-station' | 'hydrogen-customer' | 'hydrogen-order' | 'electric-charge-order' | 'etc-toll-record' | 'etc-bill';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const METRIC_CATALOG_VERSION = 6;
|
export const METRIC_CATALOG_VERSION = 7;
|
||||||
|
|
||||||
const MILEAGE_METRICS: readonly MetricDefinition[] = [
|
const MILEAGE_METRICS: readonly MetricDefinition[] = [
|
||||||
{
|
{
|
||||||
@@ -59,6 +59,19 @@ const MILEAGE_METRICS: readonly MetricDefinition[] = [
|
|||||||
dimensions: ['department', 'region', 'customer', 'vehicle-model'],
|
dimensions: ['department', 'region', 'customer', 'vehicle-model'],
|
||||||
drillEntity: 'vehicle',
|
drillEntity: 'vehicle',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'mileage.average_per_vehicle',
|
||||||
|
domain: 'mileage',
|
||||||
|
label: '平均单车里程',
|
||||||
|
description: '所选自然日或日期区间总里程除以当前筛选范围内的去重监控车辆数,不使用累计仪表值。',
|
||||||
|
unit: 'km',
|
||||||
|
aggregation: 'derived',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: 'mileage.period_total / NULLIF(mileage.vehicle_count, 0)',
|
||||||
|
sources: ['OneOS mileage API', 'Asset database'],
|
||||||
|
dimensions: ['date', 'department', 'region', 'customer', 'vehicle-model'],
|
||||||
|
drillEntity: 'vehicle',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'mileage.assessment_completion_rate',
|
id: 'mileage.assessment_completion_rate',
|
||||||
domain: 'mileage',
|
domain: 'mileage',
|
||||||
|
|||||||
Reference in New Issue
Block a user