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 { 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" />
+33 -2
View File
@@ -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>
+2
View File
@@ -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}`;
}
+3
View File
@@ -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;
+22 -9
View File
@@ -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';
+21
View File
@@ -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');
});
+12
View File
@@ -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;
}