热力图默认区间
- 新增 src/shared/date-range.ts(近 N 天滚动窗口,按本地自然日)。
- 前端两个热力图与后端两个 query 模型原先都把默认区间写死为
2026-01-01 ~ 2026-07-13,会随时间永久指向一段历史数据;
现改为"近 30 天"滚动,并把 now 作为可注入参数以便测试。
- 两个 model 测试改为注入固定时钟并断言滚动窗口,不再硬编码日期。
氢能看板去演示数据(board/EnergyBiBoardApp.tsx,6554 -> 3765 行)
- 取消线上路径的 mock 兜底:省份下拉、站点汇总表、客户费用汇总表、
区域排行在 overview 未就绪时显示空,而不是假的 25 个站点 / Top30 账单。
- 删除已确认"零渲染点"的死代码(用 TypeScript AST 精确定位声明范围,
避免手工推断括号边界):
KpiDrillModal / CustomerBillDrillModal / StationBillDrillModal /
DrillPeriodControls / getYearTimeRange / getDrillTimeRange /
MOCK_STATION_SUMMARY_LIST / MOCK_CUSTOMER_SUMMARY_LIST
这些组件内含大量写死的 2026-08-08 快照日期与假流水,均不可达。
线上 KPI 下钻走的是 PrototypeDrillModal(真实 API)。
- 单站日报"最后更新时间"初值不再用伪造时间戳,改为与接口空态一致的文案。
注:本次未改动任何 DOM 结构与 CSS 类名;删除的均为不可达分支。
lint / test(134) / build 全绿。
224 lines
13 KiB
TypeScript
224 lines
13 KiB
TypeScript
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||
import { AlertTriangle, ChevronLeft, Clock3, Fuel, Maximize2, Minimize2, SlidersHorizontal } from 'lucide-react';
|
||
import HydrogenAmapCanvas from './HydrogenAmapCanvas';
|
||
import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel';
|
||
import HydrogenHeatmapFilters from './HydrogenHeatmapFilters';
|
||
import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api';
|
||
import { recentDayRange } from '../../shared/date-range';
|
||
import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types';
|
||
|
||
// 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。
|
||
const FALLBACK_RANGE = recentDayRange(30);
|
||
const integerFormat = new Intl.NumberFormat('zh-CN');
|
||
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
|
||
|
||
function Metric({ value, label }: { value: string; label: string }) {
|
||
return (
|
||
<div className="min-w-[88px] text-center max-md:min-w-[68px]">
|
||
<p className="text-[21px] font-semibold leading-none tracking-tight text-cyan-700 tabular-nums max-md:text-[16px]">{value}</p>
|
||
<p className="mt-1.5 text-[11px] text-slate-500 max-md:mt-1 max-md:text-[9px]">{label}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function metricName(metric: HydrogenHeatmapMetric) {
|
||
if (metric === 'refuels') return '加氢频次';
|
||
if (metric === 'vehicles') return '车辆覆盖';
|
||
return '加氢量强度';
|
||
}
|
||
|
||
function metricDescription(metric: HydrogenHeatmapMetric) {
|
||
if (metric === 'refuels') return '每个站点累计有效加氢订单数;平方根平滑强度。';
|
||
if (metric === 'vehicles') return '每个站点按车牌去重统计车辆数;平方根平滑强度。';
|
||
return '每个站点累计有效加氢量(kg);对数平滑强度。';
|
||
}
|
||
|
||
export default function HydrogenHeatmapModule() {
|
||
const [meta, setMeta] = useState<HydrogenHeatmapMeta | null>(null);
|
||
const [config, setConfig] = useState<AmapConfig | null>(null);
|
||
const [data, setData] = useState<HydrogenHeatmapResponse | null>(null);
|
||
const [nearby, setNearby] = useState<HydrogenNearbyResponse | null>(null);
|
||
const [startDate, setStartDate] = useState(FALLBACK_RANGE.start);
|
||
const [endDate, setEndDate] = useState(FALLBACK_RANGE.end);
|
||
const [query, setQuery] = useState('');
|
||
const [payer, setPayer] = useState<HydrogenPayer>('all');
|
||
const [metric, setMetric] = useState<HydrogenHeatmapMetric>('kg');
|
||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
const [showFilters, setShowFilters] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
|
||
const [showDetails, setShowDetails] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
|
||
const [loading, setLoading] = useState(true);
|
||
const [nearbyLoading, setNearbyLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const deferredQuery = useDeferredValue(query.trim());
|
||
|
||
useEffect(() => {
|
||
if (!isFullscreen) return undefined;
|
||
const previousOverflow = document.body.style.overflow;
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') setIsFullscreen(false);
|
||
};
|
||
document.body.style.overflow = 'hidden';
|
||
document.addEventListener('keydown', handleKeyDown);
|
||
return () => {
|
||
document.body.style.overflow = previousOverflow;
|
||
document.removeEventListener('keydown', handleKeyDown);
|
||
};
|
||
}, [isFullscreen]);
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
Promise.all([fetchHydrogenHeatmapMeta(controller.signal), fetchAmapConfig(controller.signal)])
|
||
.then(([nextMeta, nextConfig]) => {
|
||
setMeta(nextMeta);
|
||
setConfig(nextConfig);
|
||
setStartDate(nextMeta.startDate);
|
||
setEndDate(nextMeta.endDate);
|
||
})
|
||
.catch((requestError) => {
|
||
if (requestError?.name !== 'AbortError') setError('加氢热力图基础配置加载失败');
|
||
});
|
||
return () => controller.abort();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(() => {
|
||
setLoading(true);
|
||
setError('');
|
||
fetchHydrogenHeatmapPoints({ startDate, endDate, query: deferredQuery, payer, metric }, controller.signal)
|
||
.then((response) => {
|
||
setData(response);
|
||
setNearby(null);
|
||
})
|
||
.catch((requestError) => {
|
||
if (requestError?.name !== 'AbortError') setError('加氢热力图数据加载失败,请稍后重试');
|
||
})
|
||
.finally(() => setLoading(false));
|
||
}, 220);
|
||
return () => {
|
||
window.clearTimeout(timer);
|
||
controller.abort();
|
||
};
|
||
}, [deferredQuery, endDate, metric, payer, startDate]);
|
||
|
||
const handleMapClick = useCallback((longitude: number, latitude: number) => {
|
||
const controller = new AbortController();
|
||
setNearbyLoading(true);
|
||
fetchNearbyHydrogenStations({ lng: longitude, lat: latitude, startDate, endDate, query: deferredQuery, payer, metric }, controller.signal)
|
||
.then(setNearby)
|
||
.catch((requestError) => {
|
||
if (requestError?.name !== 'AbortError') setError('区域加氢站明细加载失败');
|
||
})
|
||
.finally(() => setNearbyLoading(false));
|
||
}, [deferredQuery, endDate, metric, payer, startDate]);
|
||
|
||
const reset = () => {
|
||
setStartDate(meta?.startDate || FALLBACK_RANGE.start);
|
||
setEndDate(meta?.endDate || FALLBACK_RANGE.end);
|
||
setQuery('');
|
||
setPayer('all');
|
||
setMetric('kg');
|
||
setNearby(null);
|
||
};
|
||
|
||
const coverageText = meta
|
||
? `GPS覆盖 ${(meta.gpsCoverageRate * 100).toFixed(3)}% · 已排除 ${integerFormat.format(meta.excludedRefuelCount)} 笔缺坐标数据`
|
||
: '正在核对站点 GPS 覆盖率';
|
||
|
||
const detail = useMemo(() => {
|
||
if (nearby) {
|
||
return {
|
||
title: '选中区域',
|
||
subtitle: `${nearby.center.lng.toFixed(3)}, ${nearby.center.lat.toFixed(3)} · 半径 ${nearby.radiusKm} 公里`,
|
||
kg: nearby.kg,
|
||
refuelCount: nearby.refuelCount,
|
||
vehicleCount: nearby.vehicleCount,
|
||
stationCount: nearby.stationCount,
|
||
stations: nearby.topStations,
|
||
selected: true,
|
||
};
|
||
}
|
||
return {
|
||
title: deferredQuery ? '站点筛选结果' : '全国 · 当前范围',
|
||
subtitle: `${payer === 'all' ? '' : `${payer === 'lingniu' ? '羚牛承担' : '客户承担'} · `}${startDate} ~ ${endDate}`,
|
||
kg: data?.kg || 0,
|
||
refuelCount: data?.refuelCount || 0,
|
||
vehicleCount: data?.vehicleCount || 0,
|
||
stationCount: data?.stationCount || 0,
|
||
stations: data?.topStations || [],
|
||
selected: false,
|
||
};
|
||
}, [data, deferredQuery, endDate, nearby, payer, startDate]);
|
||
|
||
return (
|
||
<section className={`flex flex-col overflow-hidden bg-white ${isFullscreen ? 'fixed inset-0 z-[100] h-screen w-screen min-h-0' : 'h-[100dvh] min-h-[620px] max-md:h-[calc(100dvh-4rem)] max-md:min-h-[520px]'}`}>
|
||
<header className="relative z-30 flex min-h-[98px] shrink-0 items-center justify-between border-b border-slate-200 bg-white px-8 max-md:min-h-[104px] max-md:flex-wrap max-md:gap-y-2 max-md:px-3 max-md:py-3">
|
||
<div className="min-w-0">
|
||
<h1 className="truncate text-[25px] font-semibold tracking-[-0.025em] text-slate-950 max-md:text-[18px]">加氢站加氢热力图</h1>
|
||
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-400 max-md:mt-1 max-md:text-[9px]"><Fuel size={13} />加氢账本 × 加氢站 GPS · 上海时区</p>
|
||
</div>
|
||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-7 max-md:order-3 max-md:static max-md:w-full max-md:translate-x-0 max-md:translate-y-0 max-md:justify-center max-md:gap-2">
|
||
<Metric value={kgFormat.format(data?.kg ?? meta?.eligibleKg ?? 0)} label="加氢量 kg" />
|
||
<span className="h-8 w-px bg-slate-200" />
|
||
<Metric value={integerFormat.format(data?.refuelCount ?? meta?.eligibleRefuelCount ?? 0)} label="加氢次数" />
|
||
<span className="h-8 w-px bg-slate-200" />
|
||
<Metric value={integerFormat.format(data?.stationCount ?? meta?.eligibleStationCount ?? 0)} label="站点" />
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} />数据更新至 {meta?.endDate || FALLBACK_RANGE.end}</p>
|
||
<button type="button" onClick={() => setIsFullscreen((current) => !current)} className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700" title={isFullscreen ? '退出全屏' : '地图全屏'} aria-label={isFullscreen ? '退出加氢地图全屏' : '加氢地图全屏'} aria-pressed={isFullscreen}>
|
||
{isFullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="relative flex min-h-0 flex-1">
|
||
<div className="relative z-0 min-w-0 flex-1 overflow-hidden">
|
||
{config ? <HydrogenAmapCanvas config={config} points={data?.points || []} max={data?.max || 1} metric={metric} focusQuery={deferredQuery} onMapClick={handleMapClick} /> : <div className="absolute inset-0 bg-slate-100" />}
|
||
|
||
{showFilters ? (
|
||
<div className="pointer-events-none absolute left-3 right-3 top-3 z-10 md:left-5 md:right-auto md:top-5">
|
||
<HydrogenHeatmapFilters
|
||
startDate={startDate}
|
||
endDate={endDate}
|
||
minDate={meta?.startDate || FALLBACK_RANGE.start}
|
||
maxDate={meta?.endDate || FALLBACK_RANGE.end}
|
||
query={query}
|
||
payer={payer}
|
||
metric={metric}
|
||
stations={meta?.stations || []}
|
||
onStartDateChange={setStartDate}
|
||
onEndDateChange={setEndDate}
|
||
onQueryChange={setQuery}
|
||
onPayerChange={setPayer}
|
||
onMetricChange={setMetric}
|
||
onReset={reset}
|
||
onHide={() => setShowFilters(false)}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<button type="button" onClick={() => setShowFilters(true)} aria-label="显示加氢筛选" className="absolute left-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:left-5 md:top-5 md:h-10"><SlidersHorizontal size={15} strokeWidth={1.8} />显示筛选</button>
|
||
)}
|
||
|
||
{!showDetails ? (
|
||
<button type="button" onClick={() => setShowDetails(true)} aria-label="显示加氢详情" className={`absolute right-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:right-5 md:top-5 md:h-10 ${showFilters ? 'max-md:hidden' : ''}`}><ChevronLeft size={16} strokeWidth={1.8} />显示详情</button>
|
||
) : null}
|
||
|
||
<div className="pointer-events-none absolute bottom-5 left-5 z-10 w-[300px] rounded-xl border border-slate-200/80 bg-white/95 p-3.5 shadow-[0_8px_30px_rgba(15,23,42,0.11)] backdrop-blur-sm max-lg:bottom-[calc(43vh+16px)] max-md:hidden">
|
||
<div className="flex items-center justify-between text-[11px] font-medium text-slate-700"><span>{metricName(metric)}</span>{loading ? <i className="h-3 w-3 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}</div>
|
||
<p className="mt-1 text-[10px] leading-4 text-slate-500">{metricDescription(metric)}</p>
|
||
<div className="mt-2.5 h-2.5 rounded-full bg-[linear-gradient(90deg,#2563eb_0%,#0891b2_25%,#16a34a_45%,#eab308_65%,#f97316_82%,#dc2626_100%)]" />
|
||
<div className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span>相对较低</span><span>相对较高</span></div>
|
||
</div>
|
||
|
||
{error ? <div className="absolute bottom-5 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-xs text-white shadow-xl"><AlertTriangle size={15} className="text-amber-300" />{error}</div> : null}
|
||
</div>
|
||
|
||
{showDetails ? (
|
||
<HydrogenHeatmapDetailPanel {...detail} metric={metric} loading={nearbyLoading} coverageText={coverageText} onClose={() => setNearby(null)} onHide={() => setShowDetails(false)} />
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|