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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user