From 21f5e5388a1bb0d5752ff49f1775f7af1d903f6f Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 15:18:58 +0800 Subject: [PATCH] refactor: align electric overview drilldown --- src/modules/energy/ElectricDaily.tsx | 19 +++++++---- src/modules/energy/ElectricOverview.tsx | 32 +++++++++++++++++-- src/modules/energy/api.ts | 6 ++-- .../energy/electric-drill-context.test.ts | 7 ++++ src/modules/energy/electric-drill-context.ts | 7 ++-- src/modules/energy/types.ts | 3 +- src/server/routes/energy/index.ts | 19 +++++------ src/server/routes/energy/query.test.ts | 19 ++++++++--- src/server/routes/energy/query.ts | 7 ++++ 9 files changed, 90 insertions(+), 29 deletions(-) diff --git a/src/modules/energy/ElectricDaily.tsx b/src/modules/energy/ElectricDaily.tsx index e83de7a..dc91047 100644 --- a/src/modules/energy/ElectricDaily.tsx +++ b/src/modules/energy/ElectricDaily.tsx @@ -3,7 +3,7 @@ import { BatteryCharging, CalendarDays, ChevronRight, MapPin, Plug, TrendingUp, import { motion, AnimatePresence } from 'motion/react'; import TrendBadge from './TrendBadge'; import { fetchElectricMonthly, fetchElectricOrders } from './api'; -import type { CustomerType, DateQuickPick, ElectricChargeOrderResponse, ElectricMonthGroup } from './types'; +import type { DateQuickPick, ElectricChargeOrderResponse, ElectricMonthGroup, ElectricVehicleScope } from './types'; import RotatingFooterHint from '../../components/RotatingFooterHint'; import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; import { @@ -51,7 +51,7 @@ export default function ElectricDaily() { const [drillContext, setDrillContext] = useState(() => ( parseElectricDrillContext(window.location.search) )); - const [customer, setCustomer] = useState(drillContext.vehicleScope); + const [customer, setCustomer] = useState(drillContext.vehicleScope); const [pick, setPick] = useState(() => ( drillContext.startDate && drillContext.endDate || drillContext.selectedDate ? 'custom' : 'last15' )); @@ -156,7 +156,7 @@ export default function ElectricDaily() { }); }; - const updateCustomer = (next: CustomerType) => { + const updateCustomer = (next: ElectricVehicleScope) => { setCustomer(next); commitDrillContext({ ...drillContext, @@ -201,6 +201,7 @@ export default function ElectricDaily() { ))} diff --git a/src/modules/energy/ElectricOverview.tsx b/src/modules/energy/ElectricOverview.tsx index 2264374..89aad4a 100644 --- a/src/modules/energy/ElectricOverview.tsx +++ b/src/modules/energy/ElectricOverview.tsx @@ -4,6 +4,7 @@ import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, Refere import { fetchElectricOverview, type ElectricOverviewResponse } from './api'; import RotatingFooterHint from '../../components/RotatingFooterHint'; import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; +import { buildElectricDrillUrl } from './electric-drill-context'; function fmtYuan(yuan: number) { return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; @@ -44,6 +45,19 @@ export default function ElectricOverview() { const peakDay = trendData.reduce((best, item) => (!best || item.kwh > best.kwh ? item : best), null); const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0; const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0; + const openDay = (date: string) => { + const url = buildElectricDrillUrl( + { pathname: window.location.pathname, search: window.location.search, hash: '#electric' }, + { + vehicleScope: 'all', + startDate: date, + endDate: date, + selectedDate: date, + }, + ); + window.history.pushState(null, '', url); + window.dispatchEvent(new HashChangeEvent('hashchange')); + }; return (
@@ -109,8 +123,22 @@ export default function ElectricOverview() { /> )} - {trendData.map((_, i) => ( - + {trendData.map(item => ( + openDay(item.date)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openDay(item.date); + } + }} + /> ))} diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index 433aec5..1f4ab56 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -5,7 +5,7 @@ import type { ElectricKpi, ElectricDailyRow, ElectricMonthGroup, ElectricChargeOrderResponse, EtcOverviewResponse, - CustomerType, DateQuickPick, + CustomerType, DateQuickPick, ElectricVehicleScope, } from './types'; const BASE = '/api/energy'; @@ -56,7 +56,7 @@ export function fetchElectricOverview(): Promise { return fetchJson(`${BASE}/electric/overview`); } -export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise { +export function fetchElectricMonthly(customer: ElectricVehicleScope, query: HydrogenDailyQuery = { range: 'last15' }): Promise { const q = new URLSearchParams({ customer }); if (query.range) q.set('range', query.range); if (query.startDate) q.set('startDate', query.startDate); @@ -64,7 +64,7 @@ export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDail return fetchJson(`${BASE}/electric/monthly?${q.toString()}`); } -export function fetchElectricOrders(date: string, customer: CustomerType): Promise { +export function fetchElectricOrders(date: string, customer: ElectricVehicleScope): Promise { const q = new URLSearchParams({ date, customer }); return fetchJson(`${BASE}/electric/orders?${q.toString()}`); } diff --git a/src/modules/energy/electric-drill-context.test.ts b/src/modules/energy/electric-drill-context.test.ts index fbbf88f..6f07cc3 100644 --- a/src/modules/energy/electric-drill-context.test.ts +++ b/src/modules/energy/electric-drill-context.test.ts @@ -24,6 +24,13 @@ test('drops invalid electric dates and defaults scope', () => { ); }); +test('preserves the all-vehicle scope for overview drilldown', () => { + assert.deepEqual( + parseElectricDrillContext('?electricScope=all&electricDate=2026-07-28'), + { vehicleScope: 'all', selectedDate: '2026-07-28' }, + ); +}); + test('builds namespaced electric drill URL and preserves unrelated filters', () => { assert.equal( buildElectricDrillUrl( diff --git a/src/modules/energy/electric-drill-context.ts b/src/modules/energy/electric-drill-context.ts index 7e00989..0121d5f 100644 --- a/src/modules/energy/electric-drill-context.ts +++ b/src/modules/energy/electric-drill-context.ts @@ -1,7 +1,7 @@ -import type { CustomerType } from './types'; +import type { ElectricVehicleScope } from './types'; export interface ElectricDrillContext { - vehicleScope: CustomerType; + vehicleScope: ElectricVehicleScope; startDate?: string; endDate?: string; selectedDate?: string; @@ -28,8 +28,9 @@ function validDate(value: string | null): string | undefined { export function parseElectricDrillContext(search: string): ElectricDrillContext { const params = new URLSearchParams(search); + const scope = params.get('electricScope'); const context: ElectricDrillContext = { - vehicleScope: params.get('electricScope') === 'external' ? 'external' : 'lingniu', + vehicleScope: scope === 'external' || scope === 'all' ? scope : 'lingniu', }; const startDate = validDate(params.get('electricStart')); const endDate = validDate(params.get('electricEnd')); diff --git a/src/modules/energy/types.ts b/src/modules/energy/types.ts index 7b8f389..88b6d3c 100644 --- a/src/modules/energy/types.ts +++ b/src/modules/energy/types.ts @@ -1,4 +1,5 @@ export type CustomerType = 'external' | 'lingniu'; +export type ElectricVehicleScope = CustomerType | 'all'; export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15'; export interface HydrogenKpi { @@ -117,7 +118,7 @@ export interface ElectricChargeOrder { export interface ElectricChargeOrderResponse { date: string; - vehicleScope: CustomerType; + vehicleScope: ElectricVehicleScope; recordCount: number; totalKwh: number; totalFee: number; diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index 8715dd0..acaca78 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, + parseElectricVehicleScope, parseHydrogenCustomerName, parseHydrogenStationId, parseEnergyDate, @@ -602,14 +603,13 @@ app.get('/electric/orders', async (c) => { if (date === null) return c.json({ error: 'date 必须是有效的 YYYY-MM-DD 日期' }, 400); const customerParam = c.req.query('customer'); - if (customerParam !== undefined && customerParam !== 'lingniu' && customerParam !== 'external') { - return c.json({ error: 'customer 必须是 lingniu 或 external' }, 400); - } - const customer = customerParam === 'external' ? 'external' : 'lingniu'; - const vehicleKind = customer === 'lingniu' ? 'internal' : 'external'; + const customer = parseElectricVehicleScope(customerParam); + if (customer === null) return c.json({ error: 'customer 必须是 all、lingniu 或 external' }, 400); + const vehicleKind = customer === 'all' ? null : customer === 'lingniu' ? 'internal' : 'external'; + const kindClause = vehicleKind === null ? '' : 'AND vehicle_kind = ?'; const data = await cached(`electric/orders?date=${date}&customer=${customer}`, async () => { - const params = [date, date, vehicleKind]; + const params = vehicleKind === null ? [date, date] : [date, date, vehicleKind]; const [[summaryRows], [detailRows]] = await Promise.all([ pool.query( `SELECT COUNT(*) AS recordCount, @@ -617,7 +617,7 @@ app.get('/electric/orders', async (c) => { SUM(fee) AS totalFee FROM bi_ele_charge_record WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY) - AND vehicle_kind = ?`, + ${kindClause}`, params, ), pool.query( @@ -634,7 +634,7 @@ app.get('/electric/orders', async (c) => { fee AS totalFee FROM bi_ele_charge_record WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY) - AND vehicle_kind = ? + ${kindClause} ORDER BY start_time DESC, id DESC LIMIT 501`, params, @@ -674,7 +674,8 @@ app.get('/electric/orders', async (c) => { // 缺失日期补零 // ========================================================= app.get('/electric/monthly', async (c) => { - const customer = parseHydrogenCustomerKind(c.req.query('customer')); + const customer = parseElectricVehicleScope(c.req.query('customer')); + if (customer === null) return c.json({ error: 'customer 必须是 all、lingniu 或 external' }, 400); const range = (c.req.query('range') || 'last15') as Range; const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate')); const force = c.req.query('force') === '1'; diff --git a/src/server/routes/energy/query.test.ts b/src/server/routes/energy/query.test.ts index bd33b26..f41ef2b 100644 --- a/src/server/routes/energy/query.test.ts +++ b/src/server/routes/energy/query.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import app from './index.js'; import { parseHydrogenCustomerKind, + parseElectricVehicleScope, parseHydrogenCustomerName, parseHydrogenStationId, parseEnergyDate, @@ -23,6 +24,14 @@ test('defaults unknown customer scopes to lingniu', () => { assert.equal(parseHydrogenCustomerKind(undefined), 'lingniu'); }); +test('parses explicit electric vehicle scopes', () => { + assert.equal(parseElectricVehicleScope('all'), 'all'); + assert.equal(parseElectricVehicleScope('lingniu'), 'lingniu'); + assert.equal(parseElectricVehicleScope('external'), 'external'); + assert.equal(parseElectricVehicleScope(undefined), 'lingniu'); + assert.equal(parseElectricVehicleScope('unknown'), null); +}); + test('normalizes bounded customer names for exact matching', () => { assert.equal(parseHydrogenCustomerName(' 武汉客户 '), '武汉客户'); assert.equal(parseHydrogenCustomerName('未指定客户'), '未指定客户'); @@ -48,15 +57,15 @@ test('parses only valid energy calendar dates', () => { assert.equal(parseEnergyDate(undefined), null); }); -test('rejects invalid electric order drill filters before querying data', async () => { +test('rejects invalid electric drill filters before querying data', async () => { const invalidDate = await app.request('/electric/orders?date=2026-02-31&customer=lingniu'); assert.equal(invalidDate.status, 400); assert.deepEqual(await invalidDate.json(), { error: 'date 必须是有效的 YYYY-MM-DD 日期' }); - const invalidScope = await app.request('/electric/orders?date=2026-07-28&customer=all'); + const invalidScope = await app.request('/electric/orders?date=2026-07-28&customer=unknown'); assert.equal(invalidScope.status, 400); - assert.deepEqual(await invalidScope.json(), { error: 'customer 必须是 lingniu 或 external' }); + assert.deepEqual(await invalidScope.json(), { error: 'customer 必须是 all、lingniu 或 external' }); - const unknownScope = await app.request('/electric/orders?date=2026-07-28&customer=unknown'); - assert.equal(unknownScope.status, 400); + const invalidMonthlyScope = await app.request('/electric/monthly?customer=unknown'); + assert.equal(invalidMonthlyScope.status, 400); }); diff --git a/src/server/routes/energy/query.ts b/src/server/routes/energy/query.ts index e36121b..c569bdd 100644 --- a/src/server/routes/energy/query.ts +++ b/src/server/routes/energy/query.ts @@ -1,10 +1,17 @@ export type HydrogenCustomerKind = 'external' | 'lingniu' | 'all'; +export type ElectricVehicleScope = 'external' | 'lingniu' | 'all'; export function parseHydrogenCustomerKind(value: string | undefined): HydrogenCustomerKind { if (value === 'external' || value === 'all') return value; return 'lingniu'; } +export function parseElectricVehicleScope(value: string | undefined): ElectricVehicleScope | null { + if (value === undefined) return 'lingniu'; + if (value === 'external' || value === 'lingniu' || value === 'all') return value; + return null; +} + export function parseHydrogenStationId(value: string | undefined): number | null { if (value == null || !/^\d+$/.test(value)) return null; const parsed = Number(value);