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 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<ElectricDrillContext>(() => (
parseElectricDrillContext(window.location.search)
));
const [customer, setCustomer] = useState<CustomerType>(drillContext.vehicleScope);
const [customer, setCustomer] = useState<ElectricVehicleScope>(drillContext.vehicleScope);
const [pick, setPick] = useState<RangeMode>(() => (
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() {
<button
key={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 ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
@@ -212,6 +213,7 @@ export default function ElectricDaily() {
))}
<button
onClick={() => setPick('custom')}
aria-pressed={pick === 'custom'}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? '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">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
id="electric-start-date"
name="electricStartDate"
type="date"
value={dateRange.start}
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">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
id="electric-end-date"
name="electricEndDate"
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
@@ -245,17 +251,18 @@ export default function ElectricDaily() {
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => (
<div className="mt-2 grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1">
{(['all', 'lingniu', 'external'] as const).map(c => (
<button
key={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 ${
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Truck size={14} />
{c === 'external' ? '外部车辆' : '羚牛车辆'}
{c === 'all' ? '全部车辆' : c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</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 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<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 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 (
<div className="flex flex-col gap-3">
@@ -109,8 +123,22 @@ export default function ElectricOverview() {
/>
)}
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#electricBarGrad)" />
{trendData.map(item => (
<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>
<defs>
+3 -3
View File
@@ -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<ElectricOverviewResponse> {
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 });
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<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 });
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', () => {
assert.equal(
buildElectricDrillUrl(
+4 -3
View File
@@ -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'));
+2 -1
View File
@@ -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;
+10 -9
View File
@@ -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<RowDataPacket[]>(
`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<RowDataPacket[]>(
@@ -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';
+14 -5
View File
@@ -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);
});
+7
View File
@@ -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);