refactor: add hydrogen customer drilldown
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user