From cdf1fd27e7125a078e7550a9ae21d1b2a968ded6 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 14:55:55 +0800 Subject: [PATCH] refactor: add electric order drilldown --- src/modules/energy/ElectricDaily.tsx | 198 ++++++++++++++++-- src/modules/energy/api.ts | 6 + .../energy/electric-drill-context.test.ts | 40 ++++ src/modules/energy/electric-drill-context.ts | 57 +++++ src/modules/energy/types.ts | 24 +++ src/server/routes/energy/index.ts | 76 +++++++ src/server/routes/energy/query.test.ts | 21 ++ src/server/routes/energy/query.ts | 13 ++ 8 files changed, 414 insertions(+), 21 deletions(-) create mode 100644 src/modules/energy/electric-drill-context.test.ts create mode 100644 src/modules/energy/electric-drill-context.ts diff --git a/src/modules/energy/ElectricDaily.tsx b/src/modules/energy/ElectricDaily.tsx index b1d0060..e83de7a 100644 --- a/src/modules/energy/ElectricDaily.tsx +++ b/src/modules/energy/ElectricDaily.tsx @@ -1,11 +1,16 @@ -import { useEffect, useMemo, useState } from 'react'; -import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Truck, Wallet } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { BatteryCharging, CalendarDays, ChevronRight, MapPin, Plug, TrendingUp, Truck, Wallet } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import TrendBadge from './TrendBadge'; -import { fetchElectricMonthly } from './api'; -import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types'; +import { fetchElectricMonthly, fetchElectricOrders } from './api'; +import type { CustomerType, DateQuickPick, ElectricChargeOrderResponse, ElectricMonthGroup } from './types'; import RotatingFooterHint from '../../components/RotatingFooterHint'; import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; +import { + buildElectricDrillUrl, + parseElectricDrillContext, + type ElectricDrillContext, +} from './electric-drill-context'; const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [ { id: 'thisWeek', label: '本周' }, @@ -43,14 +48,38 @@ function normalizeRange(start: string, end: string): { start: string; end: strin } export default function ElectricDaily() { - const [customer, setCustomer] = useState('lingniu'); - const [pick, setPick] = useState('last15'); - const [dateRange, setDateRange] = useState(() => getQuickRange('last15')); + const [drillContext, setDrillContext] = useState(() => ( + parseElectricDrillContext(window.location.search) + )); + const [customer, setCustomer] = useState(drillContext.vehicleScope); + const [pick, setPick] = useState(() => ( + drillContext.startDate && drillContext.endDate || drillContext.selectedDate ? 'custom' : 'last15' + )); + const [dateRange, setDateRange] = useState(() => { + if (drillContext.startDate && drillContext.endDate) { + return { start: drillContext.startDate, end: drillContext.endDate }; + } + if (drillContext.selectedDate) { + return { start: drillContext.selectedDate, end: drillContext.selectedDate }; + } + return getQuickRange('last15'); + }); const [months, setMonths] = useState(null); const [openMonths, setOpenMonths] = useState>(new Set()); const [error, setError] = useState(null); + const [orders, setOrders] = useState(null); + const [ordersError, setOrdersError] = useState(null); const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]); + const selectedDate = drillContext.selectedDate; + const selectedDateRef = useRef(selectedDate); + selectedDateRef.current = selectedDate; + + const commitDrillContext = useCallback((next: ElectricDrillContext) => { + const url = buildElectricDrillUrl(window.location, next); + window.history.replaceState(null, '', url); + setDrillContext(next); + }, []); useEffect(() => { let cancelled = false; @@ -62,13 +91,29 @@ export default function ElectricDaily() { .then(m => { if (cancelled) return; setMonths(m); - // 默认展开最新一个月 - if (m.length > 0) setOpenMonths(new Set([m[0].month])); + if (m.length > 0) { + setOpenMonths(new Set([selectedDateRef.current?.slice(0, 7) || m[0].month])); + } }) .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); return () => { cancelled = true; }; }, [customer, pick, effectiveRange.start, effectiveRange.end]); + useEffect(() => { + if (!selectedDate) { + setOrders(null); + setOrdersError(null); + return undefined; + } + let cancelled = false; + setOrders(null); + setOrdersError(null); + fetchElectricOrders(selectedDate, customer) + .then(result => { if (!cancelled) setOrders(result); }) + .catch(e => { if (!cancelled) setOrdersError(e instanceof Error ? e.message : String(e)); }); + return () => { cancelled = true; }; + }, [selectedDate, customer]); + const toggleMonth = (m: string) => setOpenMonths(prev => { const next = new Set(prev); next.has(m) ? next.delete(m) : next.add(m); @@ -88,16 +133,66 @@ export default function ElectricDaily() { const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0; const applyQuickPick = (nextPick: DateQuickPick) => { + const nextRange = getQuickRange(nextPick); setPick(nextPick); - setDateRange(getQuickRange(nextPick)); + setDateRange(nextRange); + commitDrillContext({ + vehicleScope: customer, + startDate: nextRange.start, + endDate: nextRange.end, + }); }; const updateDateRange = (field: 'start' | 'end', value: string) => { if (!value) return; + const nextRange = { ...dateRange, [field]: value }; + const normalized = normalizeRange(nextRange.start, nextRange.end); setPick('custom'); - setDateRange(prev => ({ ...prev, [field]: value })); + setDateRange(nextRange); + commitDrillContext({ + vehicleScope: customer, + startDate: normalized.start, + endDate: normalized.end, + }); }; + const updateCustomer = (next: CustomerType) => { + setCustomer(next); + commitDrillContext({ + ...drillContext, + vehicleScope: next, + startDate: effectiveRange.start, + endDate: effectiveRange.end, + }); + }; + + const toggleDate = (date: string) => { + commitDrillContext({ + ...drillContext, + vehicleScope: customer, + startDate: effectiveRange.start, + endDate: effectiveRange.end, + selectedDate: selectedDate === date ? undefined : date, + }); + }; + + useEffect(() => { + const handlePopState = () => { + const next = parseElectricDrillContext(window.location.search); + setDrillContext(next); + setCustomer(next.vehicleScope); + if (next.startDate && next.endDate) { + setPick('custom'); + setDateRange({ start: next.startDate, end: next.endDate }); + } else if (next.selectedDate) { + setPick('custom'); + setDateRange({ start: next.selectedDate, end: next.selectedDate }); + } + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + return (
@@ -154,7 +249,7 @@ export default function ElectricDaily() { {(['lingniu', 'external'] as const).map(c => ( + {selectedDate === d.date && ( +
+ {ordersError ? ( + + ) : !orders || orders.date !== d.date ? ( + + ) : orders.items.length === 0 ? ( + + ) : ( +
+
+ {orders.recordCount} 笔 · {orders.totalKwh.toLocaleString('zh-CN')} 度 + 合计 ¥{orders.totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} +
+ {orders.truncated && ( +
+ 明细超过 500 笔,当前仅展示前 500 笔;顶部合计仍为全量口径。 +
+ )} +
+ 时间 / 站点 / 车辆 + 电量 + 费用 +
+ {orders.items.map(order => ( +
+
+
+ {order.startTime.slice(11, 16)} + {order.plate} + {order.orderStatus} +
+
+ + {order.stationName} + · {order.orderNo} +
+
+
+ {order.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} +
+
+
+ ¥{order.totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} +
+ 电 {order.electricityFee.toLocaleString('zh-CN')} · 服 {order.serviceFee.toLocaleString('zh-CN')} +
+
+
+ ))} +
+ )} +
+ )}
); })} diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index 529c32f..0844ea3 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -3,6 +3,7 @@ import type { HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow, HydrogenCustomerRow, HydrogenStationFull, ElectricKpi, ElectricDailyRow, ElectricMonthGroup, + ElectricChargeOrderResponse, CustomerType, DateQuickPick, } from './types'; @@ -61,3 +62,8 @@ export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDail if (query.endDate) q.set('endDate', query.endDate); return fetchJson(`${BASE}/electric/monthly?${q.toString()}`); } + +export function fetchElectricOrders(date: string, customer: CustomerType): 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 new file mode 100644 index 0000000..fbbf88f --- /dev/null +++ b/src/modules/energy/electric-drill-context.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildElectricDrillUrl, + parseElectricDrillContext, +} from './electric-drill-context.js'; + +test('parses electric scope, range, and selected day', () => { + assert.deepEqual( + parseElectricDrillContext('?electricScope=external&electricStart=2026-07-01&electricEnd=2026-07-31&electricDate=2026-07-24'), + { + vehicleScope: 'external', + startDate: '2026-07-01', + endDate: '2026-07-31', + selectedDate: '2026-07-24', + }, + ); +}); + +test('drops invalid electric dates and defaults scope', () => { + assert.deepEqual( + parseElectricDrillContext('?electricScope=unknown&electricDate=2026-02-31'), + { vehicleScope: 'lingniu' }, + ); +}); + +test('builds namespaced electric drill URL and preserves unrelated filters', () => { + assert.equal( + buildElectricDrillUrl( + { pathname: '/energy', search: '?company=5&electricDate=2026-01-01', hash: '#electric' }, + { + vehicleScope: 'lingniu', + startDate: '2026-07-24', + endDate: '2026-08-07', + selectedDate: '2026-07-24', + }, + ), + '/energy?company=5&electricScope=lingniu&electricStart=2026-07-24&electricEnd=2026-08-07&electricDate=2026-07-24#electric', + ); +}); diff --git a/src/modules/energy/electric-drill-context.ts b/src/modules/energy/electric-drill-context.ts new file mode 100644 index 0000000..7e00989 --- /dev/null +++ b/src/modules/energy/electric-drill-context.ts @@ -0,0 +1,57 @@ +import type { CustomerType } from './types'; + +export interface ElectricDrillContext { + vehicleScope: CustomerType; + startDate?: string; + endDate?: string; + selectedDate?: 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 [year, month, day] = value.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year + && date.getUTCMonth() === month - 1 + && date.getUTCDate() === day + ? value + : undefined; +} + +export function parseElectricDrillContext(search: string): ElectricDrillContext { + const params = new URLSearchParams(search); + const context: ElectricDrillContext = { + vehicleScope: params.get('electricScope') === 'external' ? 'external' : 'lingniu', + }; + const startDate = validDate(params.get('electricStart')); + const endDate = validDate(params.get('electricEnd')); + const selectedDate = validDate(params.get('electricDate')); + if (startDate) context.startDate = startDate; + if (endDate) context.endDate = endDate; + if (selectedDate) context.selectedDate = selectedDate; + return context; +} + +export function buildElectricDrillUrl( + location: LocationParts, + context: ElectricDrillContext, +): string { + const params = new URLSearchParams(location.search); + for (const key of ['electricScope', 'electricStart', 'electricEnd', 'electricDate']) { + params.delete(key); + } + params.set('electricScope', context.vehicleScope); + if (context.startDate) params.set('electricStart', context.startDate); + if (context.endDate) params.set('electricEnd', context.endDate); + if (context.selectedDate) params.set('electricDate', context.selectedDate); + const query = params.toString(); + return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`; +} diff --git a/src/modules/energy/types.ts b/src/modules/energy/types.ts index 99a2342..0e40ca6 100644 --- a/src/modules/energy/types.ts +++ b/src/modules/energy/types.ts @@ -100,3 +100,27 @@ export interface ElectricMonthGroup { fee: number; rows: ElectricDailyRow[]; } + +export interface ElectricChargeOrder { + id: number; + orderNo: string; + startTime: string; + stationName: string; + plate: string; + vehicleKind: 'internal' | 'external' | 'unknown'; + orderStatus: string; + kwh: number; + electricityFee: number; + serviceFee: number; + totalFee: number; +} + +export interface ElectricChargeOrderResponse { + date: string; + vehicleScope: CustomerType; + recordCount: number; + totalKwh: number; + totalFee: number; + truncated: boolean; + items: ElectricChargeOrder[]; +} diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index 61a0444..4753a87 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -7,6 +7,7 @@ import { parseHydrogenCustomerKind, parseHydrogenCustomerName, parseHydrogenStationId, + parseEnergyDate, type HydrogenCustomerKind, } from './query.js'; import type { AuthUser } from '../../auth/types.js'; @@ -592,6 +593,81 @@ app.get('/electric/overview', async (c) => { return c.json(data); }); +// ========================================================= +// 电能 订单下钻:指定自然日 + 车辆归属,最多返回 500 条明细 +// ========================================================= +app.get('/electric/orders', async (c) => { + const dateParam = c.req.query('date'); + const date = parseEnergyDate(dateParam); + 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 data = await cached(`electric/orders?date=${date}&customer=${customer}`, async () => { + const params = [date, date, vehicleKind]; + const [[summaryRows], [detailRows]] = await Promise.all([ + pool.query( + `SELECT COUNT(*) AS recordCount, + SUM(kwh) AS totalKwh, + SUM(fee) AS totalFee + FROM bi_ele_charge_record + WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY) + AND vehicle_kind = ?`, + params, + ), + pool.query( + `SELECT id, + order_no AS orderNo, + DATE_FORMAT(start_time, '%Y-%m-%d %H:%i:%s') AS startTime, + COALESCE(NULLIF(TRIM(station_name), ''), '未指定站点') AS stationName, + COALESCE(NULLIF(TRIM(matched_plate), ''), NULLIF(TRIM(judged_plate), ''), NULLIF(TRIM(plate), ''), '未识别车辆') AS plate, + vehicle_kind AS vehicleKind, + COALESCE(NULLIF(TRIM(order_status), ''), '未指定状态') AS orderStatus, + kwh, + e_fee AS electricityFee, + service_fee AS serviceFee, + fee AS totalFee + FROM bi_ele_charge_record + WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY) + AND vehicle_kind = ? + ORDER BY start_time DESC, id DESC + LIMIT 501`, + params, + ), + ]); + const summary = summaryRows[0] ?? {}; + const truncated = detailRows.length > 500; + return { + date, + vehicleScope: customer, + recordCount: Number(summary.recordCount) || 0, + totalKwh: Math.round((Number(summary.totalKwh) || 0) * 100) / 100, + totalFee: Math.round((Number(summary.totalFee) || 0) * 100) / 100, + truncated, + items: detailRows.slice(0, 500).map(row => ({ + id: Number(row.id), + orderNo: String(row.orderNo), + startTime: String(row.startTime), + stationName: String(row.stationName), + plate: String(row.plate), + vehicleKind: row.vehicleKind as 'internal' | 'external' | 'unknown', + orderStatus: String(row.orderStatus), + kwh: Math.round((Number(row.kwh) || 0) * 100) / 100, + electricityFee: Math.round((Number(row.electricityFee) || 0) * 100) / 100, + serviceFee: Math.round((Number(row.serviceFee) || 0) * 100) / 100, + totalFee: Math.round((Number(row.totalFee) || 0) * 100) / 100, + })), + }; + }); + + return c.json(data); +}); + // ========================================================= // 电能 每日:月份分组 + 日级行 —— 数据源:bi_ele_charge_record // 支持 range 参数(thisWeek / thisMonth / last15) diff --git a/src/server/routes/energy/query.test.ts b/src/server/routes/energy/query.test.ts index 5a88bd9..bd33b26 100644 --- a/src/server/routes/energy/query.test.ts +++ b/src/server/routes/energy/query.test.ts @@ -5,6 +5,7 @@ import { parseHydrogenCustomerKind, parseHydrogenCustomerName, parseHydrogenStationId, + parseEnergyDate, } from './query.js'; test('parses only non-negative integer station ids', () => { @@ -39,3 +40,23 @@ test('rejects invalid entity filters before querying hydrogen data', async () => assert.equal(invalidCustomer.status, 400); assert.deepEqual(await invalidCustomer.json(), { error: 'customerName 必须是 1-128 个字符' }); }); + +test('parses only valid energy calendar dates', () => { + assert.equal(parseEnergyDate('2026-07-24'), '2026-07-24'); + assert.equal(parseEnergyDate('2026-02-31'), null); + assert.equal(parseEnergyDate('2026-7-24'), null); + assert.equal(parseEnergyDate(undefined), null); +}); + +test('rejects invalid electric order 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'); + assert.equal(invalidScope.status, 400); + assert.deepEqual(await invalidScope.json(), { error: 'customer 必须是 lingniu 或 external' }); + + const unknownScope = await app.request('/electric/orders?date=2026-07-28&customer=unknown'); + assert.equal(unknownScope.status, 400); +}); diff --git a/src/server/routes/energy/query.ts b/src/server/routes/energy/query.ts index 16fd35a..e36121b 100644 --- a/src/server/routes/energy/query.ts +++ b/src/server/routes/energy/query.ts @@ -16,3 +16,16 @@ export function parseHydrogenCustomerName(value: string | undefined): string | n const normalized = value.trim(); return normalized.length > 0 && normalized.length <= 128 ? normalized : null; } + +const YMD_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export function parseEnergyDate(value: string | undefined): string | null { + if (!value || !YMD_PATTERN.test(value)) return null; + const [year, month, day] = value.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year + && date.getUTCMonth() === month - 1 + && date.getUTCDate() === day + ? value + : null; +}