diff --git a/src/modules/energy/HydrogenDaily.tsx b/src/modules/energy/HydrogenDaily.tsx index fdc3c53..707cfde 100644 --- a/src/modules/energy/HydrogenDaily.tsx +++ b/src/modules/energy/HydrogenDaily.tsx @@ -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() { + + )} +
diff --git a/src/modules/energy/HydrogenOverview.tsx b/src/modules/energy/HydrogenOverview.tsx index 6157612..c3f0700 100644 --- a/src/modules/energy/HydrogenOverview.tsx +++ b/src/modules/energy/HydrogenOverview.tsx @@ -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 ( {i + 1} - {c2.name} + + + {c2.payer === 'lingniu' ? ( 羚牛 diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index 2898103..529c32f 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -32,6 +32,7 @@ export interface HydrogenDailyQuery { startDate?: string; endDate?: string; stationId?: number; + customerName?: string; } export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise { @@ -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(`${BASE}/hydrogen/daily?${q.toString()}`); } diff --git a/src/modules/energy/hydrogen-drill-context.test.ts b/src/modules/energy/hydrogen-drill-context.test.ts index 810ec1b..e62ea19 100644 --- a/src/modules/energy/hydrogen-drill-context.test.ts +++ b/src/modules/energy/hydrogen-drill-context.test.ts @@ -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', () => { diff --git a/src/modules/energy/hydrogen-drill-context.ts b/src/modules/energy/hydrogen-drill-context.ts index 0afcf25..1822340 100644 --- a/src/modules/energy/hydrogen-drill-context.ts +++ b/src/modules/energy/hydrogen-drill-context.ts @@ -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' diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index 4b37b18..61a0444 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -5,6 +5,7 @@ import hydrogenPool from '../../hydrogen-db.js'; import { cached } from './cache.js'; import { parseHydrogenCustomerKind, + parseHydrogenCustomerName, parseHydrogenStationId, type HydrogenCustomerKind, } from './query.js'; @@ -370,16 +371,25 @@ app.get('/hydrogen/overview', async (c) => { }); // ========================================================= -// 氢能 每日:日期范围 + 客户类型 + 站点级下钻 +// 氢能 每日:日期范围 + 车辆归属 + 站点/客户下钻 // ========================================================= app.get('/hydrogen/daily', async (c) => { const range = (c.req.query('range') || 'last15') as Range; const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate')); 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 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 = [ HYDROGEN_BASE_WHERE_B, @@ -392,6 +402,10 @@ app.get('/hydrogen/daily', async (c) => { whereParts.push('COALESCE(b.station_id, 0) = ?'); whereParams.push(stationId); } + if (customerName !== null) { + whereParts.push("COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') = ?"); + whereParams.push(customerName); + } const where = whereParts.join(' AND '); // 站点级聚合(每日 × 每站)。前端组装成 day → stations diff --git a/src/server/routes/energy/query.test.ts b/src/server/routes/energy/query.test.ts index 1618bd9..5a88bd9 100644 --- a/src/server/routes/energy/query.test.ts +++ b/src/server/routes/energy/query.test.ts @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import app from './index.js'; import { parseHydrogenCustomerKind, + parseHydrogenCustomerName, parseHydrogenStationId, } from './query.js'; @@ -19,3 +21,21 @@ test('defaults unknown customer scopes to lingniu', () => { assert.equal(parseHydrogenCustomerKind('invalid'), '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 个字符' }); +}); diff --git a/src/server/routes/energy/query.ts b/src/server/routes/energy/query.ts index 5f7f342..16fd35a 100644 --- a/src/server/routes/energy/query.ts +++ b/src/server/routes/energy/query.ts @@ -10,3 +10,9 @@ export function parseHydrogenStationId(value: string | undefined): number | null const parsed = Number(value); 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; +}