Files
ln-bi/src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx
T
dsh-agent 5d14a28b0f refactor(stage6): 去掉热力图写死的默认区间与线上文件的演示数据
热力图默认区间
- 新增 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 全绿。
2026-09-11 10:23:31 +08:00

224 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}