refactor: add hydrogen station drilldown
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user