refactor: add hydrogen station drilldown

This commit is contained in:
kkfluous
2026-08-07 14:41:29 +08:00
parent d53257c65c
commit 71d0091457
9 changed files with 361 additions and 25 deletions
+106 -14
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Building2, ChevronRight, Fuel, Plug, TrendingUp, Truck, X } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import TrendBadge from './TrendBadge';
@@ -7,6 +7,11 @@ import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import {
buildHydrogenDrillUrl,
parseHydrogenDrillContext,
type HydrogenDrillContext,
} from './hydrogen-drill-context';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
@@ -44,35 +49,54 @@ function normalizeRange(start: string, end: string): { start: string; end: strin
}
export default function HydrogenDaily() {
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [drillContext, setDrillContext] = useState<HydrogenDrillContext>(() => (
parseHydrogenDrillContext(window.location.search)
));
const [pick, setPick] = useState<RangeMode>(() => (
drillContext.startDate && drillContext.endDate ? 'custom' : 'last15'
));
const [dateRange, setDateRange] = useState(() => (
drillContext.startDate && drillContext.endDate
? { start: drillContext.startDate, end: drillContext.endDate }
: getQuickRange('last15')
));
const [customer, setCustomer] = useState<CustomerType>(drillContext.vehicleScope);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
const selectedStationId = drillContext.level === 'station' ? drillContext.stationId : undefined;
const selectedStationName = drillContext.level === 'station'
? drillContext.stationName || `站点 #${drillContext.stationId}`
: undefined;
const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => {
const url = buildHydrogenDrillUrl(window.location, next);
window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url);
setDrillContext(next);
}, []);
useEffect(() => {
let cancelled = false;
setError(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId }
: { range: pick, stationId: selectedStationId };
fetchHydrogenDaily(query, customer)
.then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
}, [pick, customer, effectiveRange.start, effectiveRange.end, selectedStationId]);
// 柱图:按日期升序,用于"从左到右时间流"
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
const stationCount = useMemo(() => {
const names = new Set<string>();
(rows ?? []).forEach(r => r.stations.forEach(s => names.add(s.name)));
return names.size;
const ids = new Set<number>();
(rows ?? []).forEach(r => r.stations.forEach(s => ids.add(s.stationId)));
return ids.size;
}, [rows]);
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
const scopeLabel = pick === 'custom'
@@ -92,16 +116,65 @@ export default function HydrogenDaily() {
});
const applyQuickPick = (nextPick: DateQuickPick) => {
const nextRange = getQuickRange(nextPick);
setPick(nextPick);
setDateRange(getQuickRange(nextPick));
setDateRange(nextRange);
commitDrillContext({
...drillContext,
vehicleScope: customer,
startDate: nextRange.start,
endDate: nextRange.end,
}, 'replace');
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
const nextRange = { ...dateRange, [field]: value };
setPick('custom');
setDateRange(prev => ({ ...prev, [field]: value }));
setDateRange(nextRange);
const normalized = normalizeRange(nextRange.start, nextRange.end);
commitDrillContext({
...drillContext,
vehicleScope: customer,
startDate: normalized.start,
endDate: normalized.end,
}, 'replace');
};
const updateCustomer = (next: CustomerType) => {
setCustomer(next);
commitDrillContext({
...drillContext,
vehicleScope: next,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
}, 'replace');
};
const clearStation = () => {
commitDrillContext({
level: 'overview',
year: drillContext.year,
vehicleScope: customer,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
}, 'replace');
};
useEffect(() => {
const handlePopState = () => {
const next = parseHydrogenDrillContext(window.location.search);
setDrillContext(next);
setCustomer(next.vehicleScope);
if (next.startDate && next.endDate) {
setPick('custom');
setDateRange({ start: next.startDate, end: next.endDate });
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
return (
<div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3">
@@ -158,7 +231,7 @@ export default function HydrogenDaily() {
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
onClick={() => updateCustomer(c)}
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
@@ -170,6 +243,25 @@ export default function HydrogenDaily() {
</div>
</SurfaceCard>
{selectedStationName && (
<section className="flex items-center gap-3 rounded-xl border border-blue-100 bg-blue-50 px-4 py-3 text-blue-700 shadow-sm">
<Building2 size={16} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-black">{selectedStationName}</div>
<div className="mt-0.5 text-[10px] font-bold text-blue-500"> · {rangeText}</div>
</div>
<button
type="button"
onClick={clearStation}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-blue-500 shadow-sm ring-1 ring-blue-100 hover:text-blue-700"
aria-label="清除站点筛选"
title="清除站点筛选"
>
<X size={14} />
</button>
</section>
)}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
+33 -2
View File
@@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
} from 'recharts';
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2 } from 'lucide-react';
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2, ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { buildHydrogenDrillUrl } from './hydrogen-drill-context';
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
@@ -155,6 +156,26 @@ export default function HydrogenOverview() {
const top5Share = top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, k.yearKg) * 100;
const profitYield = k.yearRevenue > 0 ? k.yearProfit / k.yearRevenue * 100 : 0;
const stationAvgKg = stations.length > 0 ? k.yearKg / stations.length : 0;
const openStation = (station: typeof stations[number]) => {
const now = new Date();
const endDate = activeYear === now.getFullYear()
? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
: `${activeYear}-12-31`;
const url = buildHydrogenDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#hydrogen' },
{
level: 'station',
year: activeYear,
stationId: station.id,
stationName: station.name,
vehicleScope: 'lingniu',
startDate: `${activeYear}-01-01`,
endDate,
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
};
// 月度收支组合数据(推算"年内每月"图)
const monthlyDual = monthly.map(m => ({
@@ -497,7 +518,17 @@ export default function HydrogenOverview() {
return (
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
<td className="py-1.5 max-w-[180px]">
<button
type="button"
onClick={() => openStation(s)}
className="flex max-w-full items-center gap-1 text-left font-bold text-slate-700 hover:text-blue-600"
title={`查看 ${s.name} 每日明细`}
>
<span className="truncate">{s.name}</span>
<ChevronRight size={12} className="shrink-0 text-slate-300" />
</button>
</td>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
</td>
+2
View File
@@ -31,6 +31,7 @@ export interface HydrogenDailyQuery {
range?: DateQuickPick;
startDate?: string;
endDate?: string;
stationId?: number;
}
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
@@ -38,6 +39,7 @@ export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: Customer
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
if (query.stationId !== undefined) q.set('stationId', String(query.stationId));
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
}
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildHydrogenDrillUrl,
parseHydrogenDrillContext,
} from './hydrogen-drill-context.js';
test('parses a station drill context including station zero', () => {
assert.deepEqual(
parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=0&hydrogenStationName=%E6%9C%AA%E5%85%B3%E8%81%94%E7%AB%99%E7%82%B9&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07'),
{
level: 'station',
stationId: 0,
stationName: '未关联站点',
vehicleScope: 'lingniu',
startDate: '2026-08-01',
endDate: '2026-08-07',
},
);
});
test('requires a valid entity for station and customer levels', () => {
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=-1').level, 'overview');
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=customer').level, 'overview');
});
test('builds a namespaced station URL and preserves unrelated parameters', () => {
const url = buildHydrogenDrillUrl(
{ pathname: '/energy', search: '?company=5', hash: '#hydrogen' },
{
level: 'station',
year: 2026,
stationId: 18,
stationName: '测试加氢站',
vehicleScope: 'lingniu',
startDate: '2026-08-01',
endDate: '2026-08-07',
},
);
assert.equal(
url,
'/energy?company=5&hydrogenLevel=station&hydrogenYear=2026&hydrogenStation=18&hydrogenStationName=%E6%B5%8B%E8%AF%95%E5%8A%A0%E6%B0%A2%E7%AB%99&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07#hydrogen',
);
});
test('clears station parameters when returning to overview', () => {
const url = buildHydrogenDrillUrl(
{
pathname: '/energy',
search: '?hydrogenLevel=station&hydrogenStation=18&hydrogenStationName=test&company=5',
hash: '#hydrogen',
},
{ level: 'overview', vehicleScope: 'lingniu' },
);
assert.equal(url, '/energy?company=5&hydrogenScope=lingniu#hydrogen');
});
@@ -0,0 +1,104 @@
import type { CustomerType } from './types';
export type HydrogenDrillLevel = 'overview' | 'station' | 'customer';
export interface HydrogenDrillContext {
level: HydrogenDrillLevel;
year?: number;
stationId?: number;
stationName?: string;
customerName?: string;
vehicleScope: CustomerType;
startDate?: string;
endDate?: string;
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
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 positiveInteger(value: string | null): number | undefined {
if (!value || !/^\d+$/.test(value)) return undefined;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function stationId(value: string | null): number | undefined {
if (!value || !/^\d+$/.test(value)) return undefined;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
}
export function parseHydrogenDrillContext(search: string): HydrogenDrillContext {
const params = new URLSearchParams(search);
const requestedLevel = params.get('hydrogenLevel');
const parsedStationId = stationId(params.get('hydrogenStation'));
const parsedStationName = params.get('hydrogenStationName')?.trim() || undefined;
const customerName = params.get('hydrogenCustomer')?.trim() || undefined;
const context: HydrogenDrillContext = {
level: requestedLevel === 'station' && parsedStationId !== undefined
? 'station'
: requestedLevel === 'customer' && customerName
? 'customer'
: 'overview',
vehicleScope: params.get('hydrogenScope') === 'external' ? 'external' : 'lingniu',
};
const year = positiveInteger(params.get('hydrogenYear'));
if (year && year >= 2000 && year <= 2100) context.year = year;
if (context.level === 'station') {
context.stationId = parsedStationId;
if (parsedStationName) context.stationName = parsedStationName;
}
if (context.level === 'customer') context.customerName = customerName;
const startDate = validDate(params.get('hydrogenStart'));
const endDate = validDate(params.get('hydrogenEnd'));
if (startDate) context.startDate = startDate;
if (endDate) context.endDate = endDate;
return context;
}
export function buildHydrogenDrillUrl(
location: LocationParts,
context: HydrogenDrillContext,
): string {
const params = new URLSearchParams(location.search);
for (const key of [
'hydrogenLevel',
'hydrogenYear',
'hydrogenStation',
'hydrogenStationName',
'hydrogenCustomer',
'hydrogenScope',
'hydrogenStart',
'hydrogenEnd',
]) {
params.delete(key);
}
if (context.level !== 'overview') params.set('hydrogenLevel', context.level);
if (context.year) params.set('hydrogenYear', String(context.year));
if (context.level === 'station' && context.stationId !== undefined) {
params.set('hydrogenStation', String(context.stationId));
if (context.stationName) params.set('hydrogenStationName', context.stationName);
}
if (context.level === 'customer' && context.customerName) {
params.set('hydrogenCustomer', context.customerName);
}
params.set('hydrogenScope', context.vehicleScope);
if (context.startDate) params.set('hydrogenStart', context.startDate);
if (context.endDate) params.set('hydrogenEnd', context.endDate);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
+3
View File
@@ -22,6 +22,7 @@ export interface HydrogenKpi {
}
export interface HydrogenStationTop {
id: number;
rank: number;
name: string;
kg: number;
@@ -52,6 +53,7 @@ export interface HydrogenCustomerRow {
}
export interface HydrogenStationFull {
id: number;
name: string;
kg: number;
revenue: number;
@@ -60,6 +62,7 @@ export interface HydrogenStationFull {
}
export interface HydrogenStationRow {
stationId: number;
name: string;
pricePerKg: number;
kg: number;