refactor: align electric overview drilldown

This commit is contained in:
kkfluous
2026-08-07 15:18:58 +08:00
parent 08097f7c29
commit 21f5e5388a
9 changed files with 90 additions and 29 deletions
+13 -6
View File
@@ -3,7 +3,7 @@ import { BatteryCharging, CalendarDays, ChevronRight, MapPin, Plug, TrendingUp,
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge'; import TrendBadge from './TrendBadge';
import { fetchElectricMonthly, fetchElectricOrders } from './api'; 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 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 { import {
@@ -51,7 +51,7 @@ export default function ElectricDaily() {
const [drillContext, setDrillContext] = useState<ElectricDrillContext>(() => ( const [drillContext, setDrillContext] = useState<ElectricDrillContext>(() => (
parseElectricDrillContext(window.location.search) parseElectricDrillContext(window.location.search)
)); ));
const [customer, setCustomer] = useState<CustomerType>(drillContext.vehicleScope); const [customer, setCustomer] = useState<ElectricVehicleScope>(drillContext.vehicleScope);
const [pick, setPick] = useState<RangeMode>(() => ( const [pick, setPick] = useState<RangeMode>(() => (
drillContext.startDate && drillContext.endDate || drillContext.selectedDate ? 'custom' : 'last15' 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); setCustomer(next);
commitDrillContext({ commitDrillContext({
...drillContext, ...drillContext,
@@ -201,6 +201,7 @@ export default function ElectricDaily() {
<button <button
key={opt.id} key={opt.id}
onClick={() => applyQuickPick(opt.id)} onClick={() => applyQuickPick(opt.id)}
aria-pressed={pick === opt.id}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${ className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm' ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
@@ -212,6 +213,7 @@ export default function ElectricDaily() {
))} ))}
<button <button
onClick={() => setPick('custom')} onClick={() => setPick('custom')}
aria-pressed={pick === 'custom'}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${ className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom' pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm' ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
@@ -226,6 +228,8 @@ export default function ElectricDaily() {
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2"> <label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span> <span className="block text-[10px] font-black text-slate-400"></span>
<input <input
id="electric-start-date"
name="electricStartDate"
type="date" type="date"
value={dateRange.start} value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)} onChange={e => updateDateRange('start', e.target.value)}
@@ -236,6 +240,8 @@ export default function ElectricDaily() {
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2"> <label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span> <span className="block text-[10px] font-black text-slate-400"></span>
<input <input
id="electric-end-date"
name="electricEndDate"
type="date" type="date"
value={dateRange.end} value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)} onChange={e => updateDateRange('end', e.target.value)}
@@ -245,17 +251,18 @@ export default function ElectricDaily() {
</label> </label>
</div> </div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1"> <div className="mt-2 grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => ( {(['all', 'lingniu', 'external'] as const).map(c => (
<button <button
key={c} key={c}
onClick={() => updateCustomer(c)} onClick={() => updateCustomer(c)}
aria-pressed={customer === 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'
}`} }`}
> >
<Truck size={14} /> <Truck size={14} />
{c === 'external' ? '外部车辆' : '羚牛车辆'} {c === 'all' ? '全部车辆' : c === 'external' ? '外部车辆' : '羚牛车辆'}
</button> </button>
))} ))}
</div> </div>
+30 -2
View File
@@ -4,6 +4,7 @@ import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, Refere
import { fetchElectricOverview, type ElectricOverviewResponse } from './api'; import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import { buildElectricDrillUrl } from './electric-drill-context';
function fmtYuan(yuan: number) { function fmtYuan(yuan: number) {
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
@@ -44,6 +45,19 @@ export default function ElectricOverview() {
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null); const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0; const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 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 ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -109,8 +123,22 @@ export default function ElectricOverview() {
/> />
)} )}
<Bar dataKey="fee" radius={[4, 4, 0, 0]}> <Bar dataKey="fee" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => ( {trendData.map(item => (
<Cell key={i} fill="url(#electricBarGrad)" /> <Cell
key={item.date}
fill="url(#electricBarGrad)"
className="cursor-pointer outline-none focus:stroke-blue-700 focus:stroke-2"
role="button"
tabIndex={0}
aria-label={`${item.date},充电费用 ${fmtYuan(item.fee)},查看全部车辆订单`}
onClick={() => openDay(item.date)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openDay(item.date);
}
}}
/>
))} ))}
</Bar> </Bar>
<defs> <defs>
+3 -3
View File
@@ -5,7 +5,7 @@ import type {
ElectricKpi, ElectricDailyRow, ElectricMonthGroup, ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
ElectricChargeOrderResponse, ElectricChargeOrderResponse,
EtcOverviewResponse, EtcOverviewResponse,
CustomerType, DateQuickPick, CustomerType, DateQuickPick, ElectricVehicleScope,
} from './types'; } from './types';
const BASE = '/api/energy'; const BASE = '/api/energy';
@@ -56,7 +56,7 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`); return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
} }
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> { export function fetchElectricMonthly(customer: ElectricVehicleScope, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
const q = new URLSearchParams({ customer }); const q = new URLSearchParams({ 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);
@@ -64,7 +64,7 @@ export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDail
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`); return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
} }
export function fetchElectricOrders(date: string, customer: CustomerType): Promise<ElectricChargeOrderResponse> { export function fetchElectricOrders(date: string, customer: ElectricVehicleScope): Promise<ElectricChargeOrderResponse> {
const q = new URLSearchParams({ date, customer }); const q = new URLSearchParams({ date, customer });
return fetchJson<ElectricChargeOrderResponse>(`${BASE}/electric/orders?${q.toString()}`); return fetchJson<ElectricChargeOrderResponse>(`${BASE}/electric/orders?${q.toString()}`);
} }
@@ -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', () => { test('builds namespaced electric drill URL and preserves unrelated filters', () => {
assert.equal( assert.equal(
buildElectricDrillUrl( buildElectricDrillUrl(
+4 -3
View File
@@ -1,7 +1,7 @@
import type { CustomerType } from './types'; import type { ElectricVehicleScope } from './types';
export interface ElectricDrillContext { export interface ElectricDrillContext {
vehicleScope: CustomerType; vehicleScope: ElectricVehicleScope;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
selectedDate?: string; selectedDate?: string;
@@ -28,8 +28,9 @@ function validDate(value: string | null): string | undefined {
export function parseElectricDrillContext(search: string): ElectricDrillContext { export function parseElectricDrillContext(search: string): ElectricDrillContext {
const params = new URLSearchParams(search); const params = new URLSearchParams(search);
const scope = params.get('electricScope');
const context: ElectricDrillContext = { const context: ElectricDrillContext = {
vehicleScope: params.get('electricScope') === 'external' ? 'external' : 'lingniu', vehicleScope: scope === 'external' || scope === 'all' ? scope : 'lingniu',
}; };
const startDate = validDate(params.get('electricStart')); const startDate = validDate(params.get('electricStart'));
const endDate = validDate(params.get('electricEnd')); const endDate = validDate(params.get('electricEnd'));
+2 -1
View File
@@ -1,4 +1,5 @@
export type CustomerType = 'external' | 'lingniu'; export type CustomerType = 'external' | 'lingniu';
export type ElectricVehicleScope = CustomerType | 'all';
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15'; export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
export interface HydrogenKpi { export interface HydrogenKpi {
@@ -117,7 +118,7 @@ export interface ElectricChargeOrder {
export interface ElectricChargeOrderResponse { export interface ElectricChargeOrderResponse {
date: string; date: string;
vehicleScope: CustomerType; vehicleScope: ElectricVehicleScope;
recordCount: number; recordCount: number;
totalKwh: number; totalKwh: number;
totalFee: number; totalFee: number;
+10 -9
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,
parseElectricVehicleScope,
parseHydrogenCustomerName, parseHydrogenCustomerName,
parseHydrogenStationId, parseHydrogenStationId,
parseEnergyDate, parseEnergyDate,
@@ -602,14 +603,13 @@ app.get('/electric/orders', async (c) => {
if (date === null) return c.json({ error: 'date 必须是有效的 YYYY-MM-DD 日期' }, 400); if (date === null) return c.json({ error: 'date 必须是有效的 YYYY-MM-DD 日期' }, 400);
const customerParam = c.req.query('customer'); const customerParam = c.req.query('customer');
if (customerParam !== undefined && customerParam !== 'lingniu' && customerParam !== 'external') { const customer = parseElectricVehicleScope(customerParam);
return c.json({ error: 'customer 必须是 lingniu 或 external' }, 400); if (customer === null) return c.json({ error: 'customer 必须是 all、lingniu 或 external' }, 400);
} const vehicleKind = customer === 'all' ? null : customer === 'lingniu' ? 'internal' : 'external';
const customer = customerParam === 'external' ? 'external' : 'lingniu'; const kindClause = vehicleKind === null ? '' : 'AND vehicle_kind = ?';
const vehicleKind = customer === 'lingniu' ? 'internal' : 'external';
const data = await cached(`electric/orders?date=${date}&customer=${customer}`, async () => { 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([ const [[summaryRows], [detailRows]] = await Promise.all([
pool.query<RowDataPacket[]>( pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS recordCount, `SELECT COUNT(*) AS recordCount,
@@ -617,7 +617,7 @@ app.get('/electric/orders', async (c) => {
SUM(fee) AS totalFee SUM(fee) AS totalFee
FROM bi_ele_charge_record FROM bi_ele_charge_record
WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY) WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY)
AND vehicle_kind = ?`, ${kindClause}`,
params, params,
), ),
pool.query<RowDataPacket[]>( pool.query<RowDataPacket[]>(
@@ -634,7 +634,7 @@ app.get('/electric/orders', async (c) => {
fee AS totalFee fee AS totalFee
FROM bi_ele_charge_record FROM bi_ele_charge_record
WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY) WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY)
AND vehicle_kind = ? ${kindClause}
ORDER BY start_time DESC, id DESC ORDER BY start_time DESC, id DESC
LIMIT 501`, LIMIT 501`,
params, params,
@@ -674,7 +674,8 @@ app.get('/electric/orders', async (c) => {
// 缺失日期补零 // 缺失日期补零
// ========================================================= // =========================================================
app.get('/electric/monthly', 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 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';
+14 -5
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import app from './index.js'; import app from './index.js';
import { import {
parseHydrogenCustomerKind, parseHydrogenCustomerKind,
parseElectricVehicleScope,
parseHydrogenCustomerName, parseHydrogenCustomerName,
parseHydrogenStationId, parseHydrogenStationId,
parseEnergyDate, parseEnergyDate,
@@ -23,6 +24,14 @@ test('defaults unknown customer scopes to lingniu', () => {
assert.equal(parseHydrogenCustomerKind(undefined), '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', () => { test('normalizes bounded customer names for exact matching', () => {
assert.equal(parseHydrogenCustomerName(' 武汉客户 '), '武汉客户'); assert.equal(parseHydrogenCustomerName(' 武汉客户 '), '武汉客户');
assert.equal(parseHydrogenCustomerName('未指定客户'), '未指定客户'); assert.equal(parseHydrogenCustomerName('未指定客户'), '未指定客户');
@@ -48,15 +57,15 @@ test('parses only valid energy calendar dates', () => {
assert.equal(parseEnergyDate(undefined), null); 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'); const invalidDate = await app.request('/electric/orders?date=2026-02-31&customer=lingniu');
assert.equal(invalidDate.status, 400); assert.equal(invalidDate.status, 400);
assert.deepEqual(await invalidDate.json(), { error: 'date 必须是有效的 YYYY-MM-DD 日期' }); 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.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'); const invalidMonthlyScope = await app.request('/electric/monthly?customer=unknown');
assert.equal(unknownScope.status, 400); assert.equal(invalidMonthlyScope.status, 400);
}); });
+7
View File
@@ -1,10 +1,17 @@
export type HydrogenCustomerKind = 'external' | 'lingniu' | 'all'; export type HydrogenCustomerKind = 'external' | 'lingniu' | 'all';
export type ElectricVehicleScope = 'external' | 'lingniu' | 'all';
export function parseHydrogenCustomerKind(value: string | undefined): HydrogenCustomerKind { export function parseHydrogenCustomerKind(value: string | undefined): HydrogenCustomerKind {
if (value === 'external' || value === 'all') return value; if (value === 'external' || value === 'all') return value;
return 'lingniu'; 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 { export function parseHydrogenStationId(value: string | undefined): number | null {
if (value == null || !/^\d+$/.test(value)) return null; if (value == null || !/^\d+$/.test(value)) return null;
const parsed = Number(value); const parsed = Number(value);