refactor: add hydrogen customer drilldown

This commit is contained in:
kkfluous
2026-08-07 14:45:36 +08:00
parent 71d0091457
commit 757ed1ebd6
8 changed files with 134 additions and 12 deletions
+28 -6
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Building2, ChevronRight, Fuel, Plug, TrendingUp, Truck, X } from 'lucide-react';
import { Building2, ChevronRight, Fuel, Plug, TrendingUp, Truck, UserRound, 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';
@@ -70,6 +70,9 @@ export default function HydrogenDaily() {
const selectedStationName = drillContext.level === 'station'
? drillContext.stationName || `站点 #${drillContext.stationId}`
: undefined;
const selectedCustomerName = drillContext.level === 'customer'
? drillContext.customerName
: undefined;
const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => {
const url = buildHydrogenDrillUrl(window.location, next);
@@ -81,13 +84,13 @@ export default function HydrogenDaily() {
let cancelled = false;
setError(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId }
: { range: pick, stationId: selectedStationId };
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId, customerName: selectedCustomerName }
: { range: pick, stationId: selectedStationId, customerName: selectedCustomerName };
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, selectedStationId]);
}, [pick, customer, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName]);
// 柱图:按日期升序,用于"从左到右时间流"
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
@@ -151,7 +154,7 @@ export default function HydrogenDaily() {
}, 'replace');
};
const clearStation = () => {
const clearEntity = () => {
commitDrillContext({
level: 'overview',
year: drillContext.year,
@@ -252,7 +255,7 @@ export default function HydrogenDaily() {
</div>
<button
type="button"
onClick={clearStation}
onClick={clearEntity}
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="清除站点筛选"
@@ -262,6 +265,25 @@ export default function HydrogenDaily() {
</section>
)}
{selectedCustomerName && (
<section className="flex items-center gap-3 rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-emerald-700 shadow-sm">
<UserRound size={16} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-black">{selectedCustomerName}</div>
<div className="mt-0.5 text-[10px] font-bold text-emerald-600"> · {rangeText}</div>
</div>
<button
type="button"
onClick={clearEntity}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-emerald-600 shadow-sm ring-1 ring-emerald-100 hover:text-emerald-800"
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" />
+32 -2
View File
@@ -2,7 +2,7 @@ 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, ChevronRight } from 'lucide-react';
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2, ChevronRight, UserRound } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint';
@@ -176,6 +176,25 @@ export default function HydrogenOverview() {
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
};
const openCustomer = (customer: typeof customers[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: 'customer',
year: activeYear,
customerName: customer.name,
vehicleScope: 'lingniu',
startDate: `${activeYear}-01-01`,
endDate,
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
};
// 月度收支组合数据(推算"年内每月"图)
const monthlyDual = monthly.map(m => ({
@@ -587,7 +606,18 @@ export default function HydrogenOverview() {
return (
<tr key={c2.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-[200px]">{c2.name}</td>
<td className="py-1.5 max-w-[200px]">
<button
type="button"
onClick={() => openCustomer(c2)}
className="flex max-w-full items-center gap-1 text-left font-bold text-slate-700 hover:text-blue-600"
title={`查看 ${c2.name} 每日明细`}
>
<UserRound size={12} className="shrink-0 text-slate-300" />
<span className="truncate">{c2.name}</span>
<ChevronRight size={12} className="shrink-0 text-slate-300" />
</button>
</td>
<td className="py-1.5 text-center hidden sm:table-cell">
{c2.payer === 'lingniu' ? (
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold"></span>
+2
View File
@@ -32,6 +32,7 @@ export interface HydrogenDailyQuery {
startDate?: string;
endDate?: string;
stationId?: number;
customerName?: string;
}
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
@@ -40,6 +41,7 @@ export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: Customer
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));
if (query.customerName) q.set('customerName', query.customerName);
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
}
@@ -22,6 +22,31 @@ test('parses a station drill context including station zero', () => {
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');
assert.equal(
parseHydrogenDrillContext(`?hydrogenLevel=customer&hydrogenCustomer=${'x'.repeat(129)}`).level,
'overview',
);
});
test('parses and builds a customer drill context', () => {
const context = parseHydrogenDrillContext(
'?hydrogenLevel=customer&hydrogenCustomer=%E6%AD%A6%E6%B1%89%E5%AE%A2%E6%88%B7&hydrogenScope=lingniu&hydrogenStart=2026-01-01&hydrogenEnd=2026-08-07',
);
assert.deepEqual(context, {
level: 'customer',
customerName: '武汉客户',
vehicleScope: 'lingniu',
startDate: '2026-01-01',
endDate: '2026-08-07',
});
assert.equal(
buildHydrogenDrillUrl(
{ pathname: '/energy', search: '', hash: '#hydrogen' },
{ ...context, year: 2026 },
),
'/energy?hydrogenLevel=customer&hydrogenYear=2026&hydrogenCustomer=%E6%AD%A6%E6%B1%89%E5%AE%A2%E6%88%B7&hydrogenScope=lingniu&hydrogenStart=2026-01-01&hydrogenEnd=2026-08-07#hydrogen',
);
});
test('builds a namespaced station URL and preserves unrelated parameters', () => {
+4 -1
View File
@@ -44,7 +44,10 @@ export function parseHydrogenDrillContext(search: string): HydrogenDrillContext
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 rawCustomerName = params.get('hydrogenCustomer')?.trim();
const customerName = rawCustomerName && rawCustomerName.length <= 128
? rawCustomerName
: undefined;
const context: HydrogenDrillContext = {
level: requestedLevel === 'station' && parsedStationId !== undefined
? 'station'