refactor: align electric overview drilldown
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user