refactor: add hydrogen station drilldown
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
|
import { Building2, ChevronRight, Fuel, Plug, TrendingUp, Truck, X } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
@@ -7,6 +7,11 @@ import { fetchHydrogenDaily } from './api';
|
|||||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
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 }> = [
|
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||||
{ id: 'thisWeek', label: '本周' },
|
{ id: 'thisWeek', label: '本周' },
|
||||||
@@ -44,35 +49,54 @@ function normalizeRange(start: string, end: string): { start: string; end: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HydrogenDaily() {
|
export default function HydrogenDaily() {
|
||||||
const [pick, setPick] = useState<RangeMode>('last15');
|
const [drillContext, setDrillContext] = useState<HydrogenDrillContext>(() => (
|
||||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
parseHydrogenDrillContext(window.location.search)
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
));
|
||||||
|
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 [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
const query = pick === 'custom'
|
const query = pick === 'custom'
|
||||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId }
|
||||||
: { range: pick };
|
: { range: pick, stationId: selectedStationId };
|
||||||
fetchHydrogenDaily(query, customer)
|
fetchHydrogenDaily(query, customer)
|
||||||
.then(r => { if (!cancelled) setRows(r); })
|
.then(r => { if (!cancelled) setRows(r); })
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||||
return () => { cancelled = true; };
|
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 trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
||||||
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
|
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
|
||||||
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
|
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
|
||||||
const stationCount = useMemo(() => {
|
const stationCount = useMemo(() => {
|
||||||
const names = new Set<string>();
|
const ids = new Set<number>();
|
||||||
(rows ?? []).forEach(r => r.stations.forEach(s => names.add(s.name)));
|
(rows ?? []).forEach(r => r.stations.forEach(s => ids.add(s.stationId)));
|
||||||
return names.size;
|
return ids.size;
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
|
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
|
||||||
const scopeLabel = pick === 'custom'
|
const scopeLabel = pick === 'custom'
|
||||||
@@ -92,16 +116,65 @@ export default function HydrogenDaily() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||||
|
const nextRange = getQuickRange(nextPick);
|
||||||
setPick(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) => {
|
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
const nextRange = { ...dateRange, [field]: value };
|
||||||
setPick('custom');
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<SurfaceCard className="p-2 md:p-3">
|
<SurfaceCard className="p-2 md:p-3">
|
||||||
@@ -158,7 +231,7 @@ export default function HydrogenDaily() {
|
|||||||
{(['lingniu', 'external'] as const).map(c => (
|
{(['lingniu', 'external'] as const).map(c => (
|
||||||
<button
|
<button
|
||||||
key={c}
|
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 ${
|
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'
|
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>
|
</div>
|
||||||
</SurfaceCard>
|
</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">
|
<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={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
||||||
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||||||
} from 'recharts';
|
} 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 { motion, AnimatePresence } from 'motion/react';
|
||||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { buildHydrogenDrillUrl } from './hydrogen-drill-context';
|
||||||
|
|
||||||
const REGION_COLORS = [
|
const REGION_COLORS = [
|
||||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
'#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 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 profitYield = k.yearRevenue > 0 ? k.yearProfit / k.yearRevenue * 100 : 0;
|
||||||
const stationAvgKg = stations.length > 0 ? k.yearKg / stations.length : 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 => ({
|
const monthlyDual = monthly.map(m => ({
|
||||||
@@ -497,7 +518,17 @@ export default function HydrogenOverview() {
|
|||||||
return (
|
return (
|
||||||
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
<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 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">
|
<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>
|
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface HydrogenDailyQuery {
|
|||||||
range?: DateQuickPick;
|
range?: DateQuickPick;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
|
stationId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
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.range) q.set('range', query.range);
|
||||||
if (query.startDate) q.set('startDate', query.startDate);
|
if (query.startDate) q.set('startDate', query.startDate);
|
||||||
if (query.endDate) q.set('endDate', query.endDate);
|
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()}`);
|
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 {
|
export interface HydrogenStationTop {
|
||||||
|
id: number;
|
||||||
rank: number;
|
rank: number;
|
||||||
name: string;
|
name: string;
|
||||||
kg: number;
|
kg: number;
|
||||||
@@ -52,6 +53,7 @@ export interface HydrogenCustomerRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HydrogenStationFull {
|
export interface HydrogenStationFull {
|
||||||
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
kg: number;
|
kg: number;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
@@ -60,6 +62,7 @@ export interface HydrogenStationFull {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HydrogenStationRow {
|
export interface HydrogenStationRow {
|
||||||
|
stationId: number;
|
||||||
name: string;
|
name: string;
|
||||||
pricePerKg: number;
|
pricePerKg: number;
|
||||||
kg: number;
|
kg: number;
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import type { RowDataPacket } from 'mysql2';
|
|||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
import hydrogenPool from '../../hydrogen-db.js';
|
import hydrogenPool from '../../hydrogen-db.js';
|
||||||
import { cached } from './cache.js';
|
import { cached } from './cache.js';
|
||||||
|
import {
|
||||||
|
parseHydrogenCustomerKind,
|
||||||
|
parseHydrogenStationId,
|
||||||
|
type HydrogenCustomerKind,
|
||||||
|
} from './query.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import { canAccessEnergy } from '../../auth/types.js';
|
import { canAccessEnergy } from '../../auth/types.js';
|
||||||
|
|
||||||
@@ -27,10 +32,8 @@ const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
|
|||||||
const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
|
const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
|
||||||
const ELECTRIC_LOCAL = `charging_start_time`;
|
const ELECTRIC_LOCAL = `charging_start_time`;
|
||||||
|
|
||||||
type CustomerKind = 'external' | 'lingniu' | 'all';
|
|
||||||
|
|
||||||
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
|
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
|
||||||
function customerClause(customer: CustomerKind): string {
|
function customerClause(customer: HydrogenCustomerKind): string {
|
||||||
if (customer === 'external') return '1=0';
|
if (customer === 'external') return '1=0';
|
||||||
if (customer === 'lingniu') return '1=1';
|
if (customer === 'lingniu') return '1=1';
|
||||||
return '1=1';
|
return '1=1';
|
||||||
@@ -229,6 +232,7 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
);
|
);
|
||||||
const top5KgSum = kpi.yearKg || 1;
|
const top5KgSum = kpi.yearKg || 1;
|
||||||
const top5 = top5Rows.map((r, i) => ({
|
const top5 = top5Rows.map((r, i) => ({
|
||||||
|
id: Number(r.id) || 0,
|
||||||
rank: i + 1,
|
rank: i + 1,
|
||||||
name: r.name as string,
|
name: r.name as string,
|
||||||
kg: Number(r.kg) || 0,
|
kg: Number(r.kg) || 0,
|
||||||
@@ -256,6 +260,7 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
|
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
|
||||||
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
|
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
|
||||||
const stations = stationFullRows.map(r => ({
|
const stations = stationFullRows.map(r => ({
|
||||||
|
id: Number(r.id) || 0,
|
||||||
name: r.name as string,
|
name: r.name as string,
|
||||||
kg: Number(r.kg) || 0,
|
kg: Number(r.kg) || 0,
|
||||||
revenue: Number(r.revenue) || 0,
|
revenue: Number(r.revenue) || 0,
|
||||||
@@ -370,17 +375,24 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
app.get('/hydrogen/daily', async (c) => {
|
app.get('/hydrogen/daily', async (c) => {
|
||||||
const range = (c.req.query('range') || 'last15') as Range;
|
const range = (c.req.query('range') || 'last15') as Range;
|
||||||
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
||||||
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
const customer = parseHydrogenCustomerKind(c.req.query('customer'));
|
||||||
|
const stationId = parseHydrogenStationId(c.req.query('stationId'));
|
||||||
const force = c.req.query('force') === '1';
|
const force = c.req.query('force') === '1';
|
||||||
|
|
||||||
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}`, async () => {
|
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}&station=${stationId ?? 'all'}`, async () => {
|
||||||
|
|
||||||
const where = [
|
const whereParts = [
|
||||||
HYDROGEN_BASE_WHERE_B,
|
HYDROGEN_BASE_WHERE_B,
|
||||||
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
||||||
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
|
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
|
||||||
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
||||||
].join(' AND ');
|
];
|
||||||
|
const whereParams: Array<string | number> = [dateRange.start, dateRange.end];
|
||||||
|
if (stationId !== null) {
|
||||||
|
whereParts.push('COALESCE(b.station_id, 0) = ?');
|
||||||
|
whereParams.push(stationId);
|
||||||
|
}
|
||||||
|
const where = whereParts.join(' AND ');
|
||||||
|
|
||||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||||
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
||||||
@@ -399,7 +411,7 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
WHERE ${where}
|
WHERE ${where}
|
||||||
GROUP BY d, COALESCE(b.station_id, 0)
|
GROUP BY d, COALESCE(b.station_id, 0)
|
||||||
ORDER BY d DESC, kg DESC`,
|
ORDER BY d DESC, kg DESC`,
|
||||||
[dateRange.start, dateRange.end],
|
whereParams,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 站点环比:同站点上一条记录的 kg
|
// 站点环比:同站点上一条记录的 kg
|
||||||
@@ -459,6 +471,7 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
customerType: customer,
|
customerType: customer,
|
||||||
stations: info
|
stations: info
|
||||||
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
||||||
|
stationId: s.stationId,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
||||||
kg: Math.round(s.kg * 100) / 100,
|
kg: Math.round(s.kg * 100) / 100,
|
||||||
@@ -571,7 +584,7 @@ app.get('/electric/overview', async (c) => {
|
|||||||
// 缺失日期补零
|
// 缺失日期补零
|
||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/electric/monthly', async (c) => {
|
app.get('/electric/monthly', async (c) => {
|
||||||
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
const customer = parseHydrogenCustomerKind(c.req.query('customer'));
|
||||||
const range = (c.req.query('range') || 'last15') as Range;
|
const range = (c.req.query('range') || 'last15') as Range;
|
||||||
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
||||||
const force = c.req.query('force') === '1';
|
const force = c.req.query('force') === '1';
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
parseHydrogenCustomerKind,
|
||||||
|
parseHydrogenStationId,
|
||||||
|
} from './query.js';
|
||||||
|
|
||||||
|
test('parses only non-negative integer station ids', () => {
|
||||||
|
assert.equal(parseHydrogenStationId('0'), 0);
|
||||||
|
assert.equal(parseHydrogenStationId('18'), 18);
|
||||||
|
assert.equal(parseHydrogenStationId('-1'), null);
|
||||||
|
assert.equal(parseHydrogenStationId('1 OR 1=1'), null);
|
||||||
|
assert.equal(parseHydrogenStationId(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults unknown customer scopes to lingniu', () => {
|
||||||
|
assert.equal(parseHydrogenCustomerKind('external'), 'external');
|
||||||
|
assert.equal(parseHydrogenCustomerKind('all'), 'all');
|
||||||
|
assert.equal(parseHydrogenCustomerKind('invalid'), 'lingniu');
|
||||||
|
assert.equal(parseHydrogenCustomerKind(undefined), 'lingniu');
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export type HydrogenCustomerKind = 'external' | 'lingniu' | 'all';
|
||||||
|
|
||||||
|
export function parseHydrogenCustomerKind(value: string | undefined): HydrogenCustomerKind {
|
||||||
|
if (value === 'external' || value === 'all') return value;
|
||||||
|
return 'lingniu';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHydrogenStationId(value: string | undefined): number | null {
|
||||||
|
if (value == null || !/^\d+$/.test(value)) return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user