feat(energy): add vehicle scope and station search to hydrogen daily
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
|
import { Check, ChevronRight, Fuel, Layers, Plug, Search, TrendingUp, Truck, X } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchHydrogenDaily } from './api';
|
import { fetchHydrogenDaily } from './api';
|
||||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||||
import {
|
import {
|
||||||
|
filterHydrogenRows,
|
||||||
getQuickRange,
|
getQuickRange,
|
||||||
getRangeModeLabel,
|
getRangeModeLabel,
|
||||||
normalizeRange,
|
normalizeRange,
|
||||||
@@ -13,12 +14,14 @@ import {
|
|||||||
type RangeMode,
|
type RangeMode,
|
||||||
} from './hydrogen-daily/model';
|
} from './hydrogen-daily/model';
|
||||||
import DailyRangeControls from './daily-range/DailyRangeControls';
|
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||||
|
import { DateRangePicker } from '../../components/ui/DateRangePicker';
|
||||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
export default function HydrogenDaily() {
|
export default function HydrogenDaily() {
|
||||||
const [pick, setPick] = useState<RangeMode>('last15');
|
const [pick, setPick] = useState<RangeMode>('last15');
|
||||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
const [customer, setCustomer] = useState<CustomerType>('all');
|
||||||
|
const [stationSearch, setStationSearch] = useState('');
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -38,6 +41,8 @@ export default function HydrogenDaily() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
|
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => filterHydrogenRows(rows, stationSearch), [rows, stationSearch]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
trendData,
|
trendData,
|
||||||
totalKg,
|
totalKg,
|
||||||
@@ -47,7 +52,7 @@ export default function HydrogenDaily() {
|
|||||||
peakDay,
|
peakDay,
|
||||||
lowDay,
|
lowDay,
|
||||||
zeroDays,
|
zeroDays,
|
||||||
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
|
} = useMemo(() => summarizeHydrogenRows(filteredRows), [filteredRows]);
|
||||||
const scopeLabel = getRangeModeLabel(pick);
|
const scopeLabel = getRangeModeLabel(pick);
|
||||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||||
|
|
||||||
@@ -78,13 +83,83 @@ export default function HydrogenDaily() {
|
|||||||
onCustomPick={() => setPick('custom')}
|
onCustomPick={() => setPick('custom')}
|
||||||
onDateRangeChange={updateDateRange}
|
onDateRangeChange={updateDateRange}
|
||||||
onCustomerChange={setCustomer}
|
onCustomerChange={setCustomer}
|
||||||
/>
|
dateControl={
|
||||||
|
<DateRangePicker
|
||||||
|
value={effectiveRange}
|
||||||
|
label="加氢日期"
|
||||||
|
testId="hydrogen-date-range"
|
||||||
|
quickPicks={([
|
||||||
|
{ id: 'thisWeek', label: '本周' },
|
||||||
|
{ id: 'thisMonth', label: '本月' },
|
||||||
|
{ id: 'last15', label: '近 15 天' },
|
||||||
|
] as const).map(option => ({ label: option.label, range: getQuickRange(option.id) }))}
|
||||||
|
onChange={range => {
|
||||||
|
setDateRange(range);
|
||||||
|
const matching = (['thisWeek', 'thisMonth', 'last15'] as const).find(option => {
|
||||||
|
const preset = getQuickRange(option);
|
||||||
|
return preset.start === range.start && preset.end === range.end;
|
||||||
|
});
|
||||||
|
setPick(matching ?? 'custom');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<fieldset>
|
||||||
|
<legend className="mb-2 px-1 text-[11px] font-semibold text-slate-500">车辆归属</legend>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{([
|
||||||
|
{ value: 'all', label: '全部车辆', description: '内外部汇总', icon: Layers },
|
||||||
|
{ value: 'lingniu', label: '内部车辆', description: '羚牛车辆', icon: Truck },
|
||||||
|
{ value: 'external', label: '外部车辆', description: '佛山站外部订单', icon: Truck },
|
||||||
|
] as const).map(option => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
return (
|
||||||
|
<label key={option.value} className="relative min-w-0 cursor-pointer">
|
||||||
|
<input type="radio" name="hydrogen-customer" aria-label={option.label} value={option.value} checked={customer === option.value}
|
||||||
|
onChange={() => setCustomer(option.value)} className="peer sr-only" />
|
||||||
|
<span className="flex h-full min-h-[94px] flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-3 transition hover:border-blue-300 hover:bg-blue-50/40 peer-checked:border-blue-400 peer-checked:bg-blue-50/70 peer-checked:shadow-sm peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue-500">
|
||||||
|
<span className="flex items-center justify-between">
|
||||||
|
<Icon size={17} aria-hidden="true" className={customer === option.value ? 'text-blue-600' : 'text-slate-400'} />
|
||||||
|
<span aria-hidden="true" className={`flex h-4 w-4 items-center justify-center rounded-full ${customer === option.value ? 'bg-blue-600 text-white' : 'border border-slate-200 bg-white'}`}>
|
||||||
|
{customer === option.value && <Check size={11} strokeWidth={3} />}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={`text-[12px] font-bold ${customer === option.value ? 'text-blue-700' : 'text-slate-700'}`}>{option.label}</span>
|
||||||
|
<span className="text-[10px] leading-tight text-slate-500">{option.description}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2 focus-within:ring-2 focus-within:ring-blue-200">
|
||||||
|
<label htmlFor="hydrogen-station-search" className="block text-[10px] font-black text-slate-400">搜索加氢站</label>
|
||||||
|
<div className="mt-1 flex h-7 items-center gap-2">
|
||||||
|
<Search size={15} className="shrink-0 text-slate-400" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
id="hydrogen-station-search"
|
||||||
|
type="search"
|
||||||
|
value={stationSearch}
|
||||||
|
onChange={event => setStationSearch(event.target.value)}
|
||||||
|
placeholder="输入加氢站名称,例如:佛山"
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-[12px] font-bold text-slate-800 outline-none placeholder:font-normal placeholder:text-slate-400 [&::-webkit-search-cancel-button]:hidden"
|
||||||
|
/>
|
||||||
|
{stationSearch && (
|
||||||
|
<button type="button" aria-label="清除加氢站搜索" onClick={() => setStationSearch('')} className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-200 hover:text-slate-700">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DailyRangeControls>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
||||||
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper={customer === 'external' ? '佛山南海羚牛加氢站 · 未关联车辆的订单' : '当前筛选口径'} tone="emerald" />
|
<MetricTile icon={Truck} label="车辆归属" value={{ all: '全部', lingniu: '内部', external: '外部' }[customer]} helper={customer === 'external' ? '佛山南海羚牛加氢站 · 未关联车辆的订单' : '当前筛选口径'} tone="emerald" />
|
||||||
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
|
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
|
||||||
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
|
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper={stationSearch.trim() ? `搜索:${stationSearch.trim()}` : '按明细站点去重'} tone="slate" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 时段加氢量柱图 */}
|
{/* 时段加氢量柱图 */}
|
||||||
@@ -183,11 +258,11 @@ export default function HydrogenDaily() {
|
|||||||
{/* 主行 + 子行 */}
|
{/* 主行 + 子行 */}
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="p-3"><ErrorState message={error} /></div>
|
<div className="p-3"><ErrorState message={error} /></div>
|
||||||
) : rows === null ? (
|
) : filteredRows === null ? (
|
||||||
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
|
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
|
||||||
) : rows.length === 0 || totalKg === 0 ? (
|
) : filteredRows.length === 0 || totalKg === 0 ? (
|
||||||
<div className="p-3"><EmptyState title="所选时段暂无加氢数据" description={customer === 'external' ? '佛山南海羚牛加氢站在该时段没有未关联车辆的加氢订单,请切换时间范围' : '请切换时间范围或车辆归属'} /></div>
|
<div className="p-3"><EmptyState title="所选时段暂无加氢数据" description={stationSearch.trim() ? '当前时段和车辆归属下没有匹配的加氢站数据,请调整关键词或清除搜索' : customer === 'external' ? '佛山南海羚牛加氢站在该时段没有未关联车辆的加氢订单,请切换时间范围' : '请切换时间范围或车辆归属'} /></div>
|
||||||
) : rows.map(r => {
|
) : filteredRows.map(r => {
|
||||||
const open = expanded.has(r.date);
|
const open = expanded.has(r.date);
|
||||||
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
||||||
const abnormalBg = isAbnormal
|
const abnormalBg = isAbnormal
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import type { HydrogenDailyRow } from '../types.js';
|
import type { HydrogenDailyRow } from '../types.js';
|
||||||
import {
|
import {
|
||||||
|
filterHydrogenRows,
|
||||||
getQuickRange,
|
getQuickRange,
|
||||||
getRangeModeLabel,
|
getRangeModeLabel,
|
||||||
normalizeRange,
|
normalizeRange,
|
||||||
@@ -82,3 +83,53 @@ test('空数据保持零值且没有峰谷日', () => {
|
|||||||
zeroDays: 0,
|
zeroDays: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const searchRows: HydrogenDailyRow[] = [
|
||||||
|
{ date: '2026-09-17', totalKg: 50, chainPct: -0.75, customerType: 'all', stations: [
|
||||||
|
{ name: '嘉兴站', kg: 50, pricePerKg: 30, chainPct: 0 },
|
||||||
|
] },
|
||||||
|
{ date: '2026-09-16', totalKg: 200, chainPct: 1, customerType: 'all', stations: [
|
||||||
|
{ name: '佛山南海羚牛加氢站', kg: 30, pricePerKg: 38, chainPct: 2 },
|
||||||
|
{ name: '佛山豪汇石油加氢站', kg: 10, pricePerKg: 35, chainPct: 0 },
|
||||||
|
{ name: '嘉兴站', kg: 160, pricePerKg: 30, chainPct: 1 },
|
||||||
|
] },
|
||||||
|
{ date: '2026-09-15', totalKg: 100, chainPct: 0, customerType: 'all', stations: [
|
||||||
|
{ name: '佛山南海羚牛加氢站', kg: 10, pricePerKg: 38, chainPct: 0 },
|
||||||
|
{ name: '嘉兴站', kg: 90, pricePerKg: 30, chainPct: 0 },
|
||||||
|
] },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('站名搜索同步更新日明细、总量、趋势和环比,保留完整日期及原始数据', () => {
|
||||||
|
const before = structuredClone(searchRows);
|
||||||
|
const filtered = filterHydrogenRows(searchRows, ' 佛山 ')!;
|
||||||
|
assert.deepEqual(filtered.map(row => row.date), searchRows.map(row => row.date));
|
||||||
|
assert.deepEqual(filtered.map(row => row.totalKg), [0, 40, 10]);
|
||||||
|
assert.deepEqual(filtered.map(row => row.chainPct), [-1, 3, 0]);
|
||||||
|
assert.equal(filtered[1].stations.length, 2);
|
||||||
|
assert.equal(filtered[1].stations[0].pricePerKg, 38);
|
||||||
|
assert.equal(filtered[1].stations[0].chainPct, 2);
|
||||||
|
const summary = summarizeHydrogenRows(filtered);
|
||||||
|
assert.equal(summary.totalKg, 50);
|
||||||
|
assert.equal(summary.stationCount, 2);
|
||||||
|
assert.equal(summary.activeDays, 2);
|
||||||
|
assert.equal(summary.avgKg, 25);
|
||||||
|
assert.equal(summary.zeroDays, 1);
|
||||||
|
assert.deepEqual(summary.trendData.map(row => row.totalKg), [10, 40, 0]);
|
||||||
|
assert.deepEqual(searchRows, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无匹配关键词显示零结果,清除或空白搜索恢复全量,加载中保持加载状态', () => {
|
||||||
|
const missing = filterHydrogenRows(searchRows, '不存在的站点')!;
|
||||||
|
assert.equal(missing.length, 3);
|
||||||
|
assert.ok(missing.every(row => row.totalKg === 0 && row.chainPct === 0 && row.stations.length === 0));
|
||||||
|
assert.strictEqual(filterHydrogenRows(searchRows, ''), searchRows);
|
||||||
|
assert.strictEqual(filterHydrogenRows(searchRows, ' '), searchRows);
|
||||||
|
assert.equal(summarizeHydrogenRows(filterHydrogenRows(searchRows, '')).totalKg, 350);
|
||||||
|
assert.equal(filterHydrogenRows(null, '佛山'), null);
|
||||||
|
assert.deepEqual(filterHydrogenRows([], '佛山'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('完整站名可定位单站,搜索将特殊字符按普通文字匹配', () => {
|
||||||
|
assert.equal(summarizeHydrogenRows(filterHydrogenRows(searchRows, '佛山南海羚牛加氢站')).totalKg, 40);
|
||||||
|
assert.equal(summarizeHydrogenRows(filterHydrogenRows(searchRows, '%')).totalKg, 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,28 @@ export {
|
|||||||
type RangeMode,
|
type RangeMode,
|
||||||
} from '../daily-range/model';
|
} from '../daily-range/model';
|
||||||
|
|
||||||
|
// 接口已返回所选车辆范围内的全部日/站点数据;搜索后统一重算日合计与环比。
|
||||||
|
export function filterHydrogenRows(rows: HydrogenDailyRow[] | null, stationSearch: string): HydrogenDailyRow[] | null {
|
||||||
|
const keyword = stationSearch.trim().toLocaleLowerCase();
|
||||||
|
if (!rows || !keyword) return rows;
|
||||||
|
|
||||||
|
const filtered = rows.map(row => {
|
||||||
|
const stations = row.stations.filter(station => station.name.toLocaleLowerCase().includes(keyword));
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
stations,
|
||||||
|
totalKg: Math.round(stations.reduce((sum, station) => sum + station.kg, 0) * 100) / 100,
|
||||||
|
chainPct: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
let previousKg = 0;
|
||||||
|
for (const row of [...filtered].sort((a, b) => a.date.localeCompare(b.date))) {
|
||||||
|
row.chainPct = previousKg > 0 ? (row.totalKg - previousKg) / previousKg : 0;
|
||||||
|
previousKg = row.totalKg;
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
|
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
|
||||||
const source = rows ?? [];
|
const source = rows ?? [];
|
||||||
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
|
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type CustomerType = 'external' | 'lingniu';
|
export type CustomerType = 'external' | 'lingniu' | 'all';
|
||||||
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
|
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
|
||||||
|
|
||||||
export interface HydrogenKpi {
|
export interface HydrogenKpi {
|
||||||
|
|||||||
Reference in New Issue
Block a user