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 { 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 { 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';
@@ -70,6 +70,9 @@ export default function HydrogenDaily() {
const selectedStationName = drillContext.level === 'station' const selectedStationName = drillContext.level === 'station'
? drillContext.stationName || `站点 #${drillContext.stationId}` ? drillContext.stationName || `站点 #${drillContext.stationId}`
: undefined; : undefined;
const selectedCustomerName = drillContext.level === 'customer'
? drillContext.customerName
: undefined;
const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => { const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => {
const url = buildHydrogenDrillUrl(window.location, next); const url = buildHydrogenDrillUrl(window.location, next);
@@ -81,13 +84,13 @@ export default function HydrogenDaily() {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
const query = pick === 'custom' const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId } ? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId, customerName: selectedCustomerName }
: { range: pick, stationId: selectedStationId }; : { range: pick, stationId: selectedStationId, customerName: selectedCustomerName };
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, selectedStationId]); }, [pick, customer, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName]);
// 柱图:按日期升序,用于"从左到右时间流" // 柱图:按日期升序,用于"从左到右时间流"
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]);
@@ -151,7 +154,7 @@ export default function HydrogenDaily() {
}, 'replace'); }, 'replace');
}; };
const clearStation = () => { const clearEntity = () => {
commitDrillContext({ commitDrillContext({
level: 'overview', level: 'overview',
year: drillContext.year, year: drillContext.year,
@@ -252,7 +255,7 @@ export default function HydrogenDaily() {
</div> </div>
<button <button
type="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" 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="清除站点筛选" aria-label="清除站点筛选"
title="清除站点筛选" title="清除站点筛选"
@@ -262,6 +265,25 @@ export default function HydrogenDaily() {
</section> </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"> <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" />
+32 -2
View File
@@ -2,7 +2,7 @@ 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, 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 { 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';
@@ -176,6 +176,25 @@ export default function HydrogenOverview() {
window.history.pushState(null, '', url); window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange')); 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 => ({ const monthlyDual = monthly.map(m => ({
@@ -587,7 +606,18 @@ export default function HydrogenOverview() {
return ( return (
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60"> <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 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"> <td className="py-1.5 text-center hidden sm:table-cell">
{c2.payer === 'lingniu' ? ( {c2.payer === 'lingniu' ? (
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold"></span> <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; startDate?: string;
endDate?: string; endDate?: string;
stationId?: number; stationId?: number;
customerName?: string;
} }
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> { 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.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)); 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()}`); 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', () => { test('requires a valid entity for station and customer levels', () => {
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=-1').level, 'overview'); assert.equal(parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=-1').level, 'overview');
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=customer').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', () => { 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 requestedLevel = params.get('hydrogenLevel');
const parsedStationId = stationId(params.get('hydrogenStation')); const parsedStationId = stationId(params.get('hydrogenStation'));
const parsedStationName = params.get('hydrogenStationName')?.trim() || undefined; 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 = { const context: HydrogenDrillContext = {
level: requestedLevel === 'station' && parsedStationId !== undefined level: requestedLevel === 'station' && parsedStationId !== undefined
? 'station' ? 'station'
+17 -3
View File
@@ -5,6 +5,7 @@ import hydrogenPool from '../../hydrogen-db.js';
import { cached } from './cache.js'; import { cached } from './cache.js';
import { import {
parseHydrogenCustomerKind, parseHydrogenCustomerKind,
parseHydrogenCustomerName,
parseHydrogenStationId, parseHydrogenStationId,
type HydrogenCustomerKind, type HydrogenCustomerKind,
} from './query.js'; } from './query.js';
@@ -370,16 +371,25 @@ 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 = parseHydrogenCustomerKind(c.req.query('customer')); const customer = parseHydrogenCustomerKind(c.req.query('customer'));
const stationId = parseHydrogenStationId(c.req.query('stationId')); const stationIdParam = c.req.query('stationId');
const customerNameParam = c.req.query('customerName');
const stationId = parseHydrogenStationId(stationIdParam);
const customerName = parseHydrogenCustomerName(customerNameParam);
if (stationIdParam !== undefined && stationId === null) {
return c.json({ error: 'stationId 必须是非负整数' }, 400);
}
if (customerNameParam !== undefined && customerName === null) {
return c.json({ error: 'customerName 必须是 1-128 个字符' }, 400);
}
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}&station=${stationId ?? 'all'}`, async () => { const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}&station=${stationId ?? 'all'}&customerName=${encodeURIComponent(customerName ?? 'all')}`, async () => {
const whereParts = [ const whereParts = [
HYDROGEN_BASE_WHERE_B, HYDROGEN_BASE_WHERE_B,
@@ -392,6 +402,10 @@ app.get('/hydrogen/daily', async (c) => {
whereParts.push('COALESCE(b.station_id, 0) = ?'); whereParts.push('COALESCE(b.station_id, 0) = ?');
whereParams.push(stationId); whereParams.push(stationId);
} }
if (customerName !== null) {
whereParts.push("COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') = ?");
whereParams.push(customerName);
}
const where = whereParts.join(' AND '); const where = whereParts.join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations // 站点级聚合(每日 × 每站)。前端组装成 day → stations
+20
View File
@@ -1,7 +1,9 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import app from './index.js';
import { import {
parseHydrogenCustomerKind, parseHydrogenCustomerKind,
parseHydrogenCustomerName,
parseHydrogenStationId, parseHydrogenStationId,
} from './query.js'; } from './query.js';
@@ -19,3 +21,21 @@ test('defaults unknown customer scopes to lingniu', () => {
assert.equal(parseHydrogenCustomerKind('invalid'), 'lingniu'); assert.equal(parseHydrogenCustomerKind('invalid'), 'lingniu');
assert.equal(parseHydrogenCustomerKind(undefined), 'lingniu'); assert.equal(parseHydrogenCustomerKind(undefined), 'lingniu');
}); });
test('normalizes bounded customer names for exact matching', () => {
assert.equal(parseHydrogenCustomerName(' 武汉客户 '), '武汉客户');
assert.equal(parseHydrogenCustomerName('未指定客户'), '未指定客户');
assert.equal(parseHydrogenCustomerName(' '), null);
assert.equal(parseHydrogenCustomerName('x'.repeat(129)), null);
assert.equal(parseHydrogenCustomerName(undefined), null);
});
test('rejects invalid entity filters before querying hydrogen data', async () => {
const invalidStation = await app.request('/hydrogen/daily?stationId=-1');
assert.equal(invalidStation.status, 400);
assert.deepEqual(await invalidStation.json(), { error: 'stationId 必须是非负整数' });
const invalidCustomer = await app.request(`/hydrogen/daily?customerName=${'x'.repeat(129)}`);
assert.equal(invalidCustomer.status, 400);
assert.deepEqual(await invalidCustomer.json(), { error: 'customerName 必须是 1-128 个字符' });
});
+6
View File
@@ -10,3 +10,9 @@ export function parseHydrogenStationId(value: string | undefined): number | null
const parsed = Number(value); const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
} }
export function parseHydrogenCustomerName(value: string | undefined): string | null {
if (value == null) return null;
const normalized = value.trim();
return normalized.length > 0 && normalized.length <= 128 ? normalized : null;
}