refactor: unify BI vehicle scope controls
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export interface AnalysisScopeOption<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function AnalysisScopeSwitch<T extends string>({
|
||||
ariaLabel,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
icon: Icon,
|
||||
className,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
value: T;
|
||||
options: readonly AnalysisScopeOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
icon?: ComponentType<{ size?: number; className?: string }>;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
className={cn('grid gap-1 rounded-lg bg-slate-100 p-1', className)}
|
||||
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
aria-pressed={value === option.id}
|
||||
className={cn(
|
||||
'flex min-h-9 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-[12px] font-black transition-colors',
|
||||
value === option.id
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
)}
|
||||
>
|
||||
{Icon ? <Icon size={14} className="shrink-0" /> : null}
|
||||
<span className="min-w-0 truncate">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ElectricDrillContext,
|
||||
} from './electric-drill-context';
|
||||
import AnalysisDateFilters from './AnalysisDateFilters';
|
||||
import AnalysisScopeSwitch, { type AnalysisScopeOption } from './AnalysisScopeSwitch';
|
||||
import {
|
||||
dateRangeModeLabel,
|
||||
getQuickDateRange,
|
||||
@@ -20,11 +21,17 @@ import {
|
||||
type DateRangeMode,
|
||||
} from './date-range';
|
||||
|
||||
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<ElectricVehicleScope>[] = [
|
||||
{ id: 'all', label: '全部车辆' },
|
||||
{ id: 'lingniu', label: '羚牛车辆' },
|
||||
{ id: 'external', label: '外部车辆' },
|
||||
];
|
||||
|
||||
export default function ElectricDaily() {
|
||||
const [drillContext, setDrillContext] = useState<ElectricDrillContext>(() => (
|
||||
parseElectricDrillContext(window.location.search)
|
||||
));
|
||||
const [customer, setCustomer] = useState<ElectricVehicleScope>(drillContext.vehicleScope);
|
||||
const [vehicleScope, setVehicleScope] = useState<ElectricVehicleScope>(drillContext.vehicleScope);
|
||||
const [pick, setPick] = useState<DateRangeMode>(() => {
|
||||
if (drillContext.startDate && drillContext.endDate) {
|
||||
return inferDateRangeMode(drillContext.startDate, drillContext.endDate);
|
||||
@@ -63,7 +70,7 @@ export default function ElectricDaily() {
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
fetchElectricMonthly(customer, query)
|
||||
fetchElectricMonthly(vehicleScope, query)
|
||||
.then(m => {
|
||||
if (cancelled) return;
|
||||
setMonths(m);
|
||||
@@ -73,7 +80,7 @@ export default function ElectricDaily() {
|
||||
})
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
||||
}, [vehicleScope, pick, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDate) {
|
||||
@@ -84,11 +91,11 @@ export default function ElectricDaily() {
|
||||
let cancelled = false;
|
||||
setOrders(null);
|
||||
setOrdersError(null);
|
||||
fetchElectricOrders(selectedDate, customer)
|
||||
fetchElectricOrders(selectedDate, vehicleScope)
|
||||
.then(result => { if (!cancelled) setOrders(result); })
|
||||
.catch(e => { if (!cancelled) setOrdersError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedDate, customer]);
|
||||
}, [selectedDate, vehicleScope]);
|
||||
|
||||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -104,14 +111,14 @@ export default function ElectricDaily() {
|
||||
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
|
||||
const scopeLabel = dateRangeModeLabel(pick);
|
||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||||
const showExternalEmpty = vehicleScope === 'external' && months !== null && totalKwh === 0;
|
||||
|
||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||
const nextRange = getQuickDateRange(nextPick);
|
||||
setPick(nextPick);
|
||||
setDateRange(nextRange);
|
||||
commitDrillContext({
|
||||
vehicleScope: customer,
|
||||
vehicleScope,
|
||||
startDate: nextRange.start,
|
||||
endDate: nextRange.end,
|
||||
});
|
||||
@@ -124,14 +131,14 @@ export default function ElectricDaily() {
|
||||
setPick('custom');
|
||||
setDateRange(normalized);
|
||||
commitDrillContext({
|
||||
vehicleScope: customer,
|
||||
vehicleScope,
|
||||
startDate: normalized.start,
|
||||
endDate: normalized.end,
|
||||
});
|
||||
};
|
||||
|
||||
const updateCustomer = (next: ElectricVehicleScope) => {
|
||||
setCustomer(next);
|
||||
const updateVehicleScope = (next: ElectricVehicleScope) => {
|
||||
setVehicleScope(next);
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope: next,
|
||||
@@ -143,7 +150,7 @@ export default function ElectricDaily() {
|
||||
const toggleDate = (date: string) => {
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope: customer,
|
||||
vehicleScope,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
selectedDate: selectedDate === date ? undefined : date,
|
||||
@@ -154,7 +161,7 @@ export default function ElectricDaily() {
|
||||
const handlePopState = () => {
|
||||
const next = parseElectricDrillContext(window.location.search);
|
||||
setDrillContext(next);
|
||||
setCustomer(next.vehicleScope);
|
||||
setVehicleScope(next.vehicleScope);
|
||||
if (next.startDate && next.endDate) {
|
||||
const normalized = normalizeDateRange(next.startDate, next.endDate);
|
||||
setPick(inferDateRangeMode(normalized.start, normalized.end));
|
||||
@@ -181,21 +188,14 @@ export default function ElectricDaily() {
|
||||
onDateChange={updateDateRange}
|
||||
/>
|
||||
|
||||
<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 === 'all' ? '全部车辆' : c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<AnalysisScopeSwitch
|
||||
ariaLabel="电能车辆范围"
|
||||
value={vehicleScope}
|
||||
options={VEHICLE_SCOPE_OPTIONS}
|
||||
onChange={updateVehicleScope}
|
||||
icon={Truck}
|
||||
className="mt-2"
|
||||
/>
|
||||
</SurfaceCard>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './hydrogen-drill-context';
|
||||
import HydrogenOrders from './HydrogenOrders';
|
||||
import AnalysisDateFilters from './AnalysisDateFilters';
|
||||
import AnalysisScopeSwitch, { type AnalysisScopeOption } from './AnalysisScopeSwitch';
|
||||
import {
|
||||
dateRangeModeLabel,
|
||||
getQuickDateRange,
|
||||
@@ -22,6 +23,11 @@ import {
|
||||
type DateRangeMode,
|
||||
} from './date-range';
|
||||
|
||||
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<CustomerType>[] = [
|
||||
{ id: 'lingniu', label: '羚牛车辆' },
|
||||
{ id: 'external', label: '外部车辆' },
|
||||
];
|
||||
|
||||
export default function HydrogenDaily() {
|
||||
const [drillContext, setDrillContext] = useState<HydrogenDrillContext>(() => (
|
||||
parseHydrogenDrillContext(window.location.search)
|
||||
@@ -36,7 +42,7 @@ export default function HydrogenDaily() {
|
||||
? normalizeDateRange(drillContext.startDate, drillContext.endDate)
|
||||
: getQuickDateRange('last15')
|
||||
));
|
||||
const [customer, setCustomer] = useState<CustomerType>(drillContext.vehicleScope);
|
||||
const [vehicleScope, setVehicleScope] = useState<CustomerType>(drillContext.vehicleScope);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -64,11 +70,11 @@ export default function HydrogenDaily() {
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId, customerName: selectedCustomerName }
|
||||
: { range: pick, stationId: selectedStationId, customerName: selectedCustomerName };
|
||||
fetchHydrogenDaily(query, customer)
|
||||
fetchHydrogenDaily(query, vehicleScope)
|
||||
.then(r => { if (!cancelled) setRows(r); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [pick, customer, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName, retryKey]);
|
||||
}, [pick, vehicleScope, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName, retryKey]);
|
||||
|
||||
// 柱图:按日期升序,用于"从左到右时间流"
|
||||
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
||||
@@ -100,7 +106,7 @@ export default function HydrogenDaily() {
|
||||
setDateRange(nextRange);
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope: customer,
|
||||
vehicleScope,
|
||||
startDate: nextRange.start,
|
||||
endDate: nextRange.end,
|
||||
selectedDate: undefined,
|
||||
@@ -115,15 +121,15 @@ export default function HydrogenDaily() {
|
||||
setDateRange(normalized);
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope: customer,
|
||||
vehicleScope,
|
||||
startDate: normalized.start,
|
||||
endDate: normalized.end,
|
||||
selectedDate: undefined,
|
||||
}, 'replace');
|
||||
};
|
||||
|
||||
const updateCustomer = (next: CustomerType) => {
|
||||
setCustomer(next);
|
||||
const updateVehicleScope = (next: CustomerType) => {
|
||||
setVehicleScope(next);
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope: next,
|
||||
@@ -136,7 +142,7 @@ export default function HydrogenDaily() {
|
||||
commitDrillContext({
|
||||
level: 'overview',
|
||||
year: drillContext.year,
|
||||
vehicleScope: customer,
|
||||
vehicleScope,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
}, 'replace');
|
||||
@@ -145,7 +151,7 @@ export default function HydrogenDaily() {
|
||||
const openOrders = (date: string) => {
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope: customer,
|
||||
vehicleScope,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
selectedDate: date,
|
||||
@@ -155,7 +161,7 @@ export default function HydrogenDaily() {
|
||||
const closeOrders = () => {
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope: customer,
|
||||
vehicleScope,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
selectedDate: undefined,
|
||||
@@ -172,7 +178,7 @@ export default function HydrogenDaily() {
|
||||
const handlePopState = () => {
|
||||
const next = parseHydrogenDrillContext(window.location.search);
|
||||
setDrillContext(next);
|
||||
setCustomer(next.vehicleScope);
|
||||
setVehicleScope(next.vehicleScope);
|
||||
if (next.startDate && next.endDate) {
|
||||
const normalized = normalizeDateRange(next.startDate, next.endDate);
|
||||
setPick(inferDateRangeMode(normalized.start, normalized.end));
|
||||
@@ -196,21 +202,14 @@ export default function HydrogenDaily() {
|
||||
onDateChange={updateDateRange}
|
||||
/>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
{(['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' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<AnalysisScopeSwitch
|
||||
ariaLabel="氢能车辆范围"
|
||||
value={vehicleScope}
|
||||
options={VEHICLE_SCOPE_OPTIONS}
|
||||
onChange={updateVehicleScope}
|
||||
icon={Truck}
|
||||
className="mt-2"
|
||||
/>
|
||||
</SurfaceCard>
|
||||
|
||||
{selectedStationName && (
|
||||
@@ -261,13 +260,13 @@ export default function HydrogenDaily() {
|
||||
|
||||
{!error && rows !== null && <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={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
||||
<MetricTile icon={Truck} label="车辆归属" value={vehicleScope === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
||||
<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" />
|
||||
</div>}
|
||||
|
||||
{/* 外部车辆:新系统数据还没准备好 */}
|
||||
{!error && customer === 'external' && rows !== null && totalKg === 0 && (
|
||||
{!error && vehicleScope === 'external' && rows !== null && totalKg === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -288,7 +287,7 @@ export default function HydrogenDaily() {
|
||||
)}
|
||||
|
||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||
{!error && !(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||
{!error && !(vehicleScope === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||
<SurfaceCard>
|
||||
<div className="flex items-center justify-between px-4 pt-4 mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
||||
@@ -375,7 +374,7 @@ export default function HydrogenDaily() {
|
||||
{selectedDate && (
|
||||
<HydrogenOrders
|
||||
date={selectedDate}
|
||||
customer={customer}
|
||||
vehicleScope={vehicleScope}
|
||||
stationId={selectedStationId}
|
||||
customerName={selectedCustomerName}
|
||||
onClose={closeOrders}
|
||||
@@ -383,7 +382,7 @@ export default function HydrogenDaily() {
|
||||
)}
|
||||
|
||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
||||
{!error && !(customer === 'external' && rows !== null && totalKg === 0) && (
|
||||
{!error && !(vehicleScope === 'external' && rows !== null && totalKg === 0) && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
{/* 表头 */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_72px_64px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||
|
||||
@@ -11,13 +11,13 @@ function fmtMoney(value: number): string {
|
||||
|
||||
export default function HydrogenOrders({
|
||||
date,
|
||||
customer,
|
||||
vehicleScope,
|
||||
stationId,
|
||||
customerName,
|
||||
onClose,
|
||||
}: {
|
||||
date: string;
|
||||
customer: CustomerType;
|
||||
vehicleScope: CustomerType;
|
||||
stationId?: number;
|
||||
customerName?: string;
|
||||
onClose: () => void;
|
||||
@@ -30,28 +30,28 @@ export default function HydrogenOrders({
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [customer, customerName, date, stationId]);
|
||||
}, [vehicleScope, customerName, date, stationId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setData(null);
|
||||
fetchHydrogenOrders({ date, customer, stationId, customerName, page, limit: 20 })
|
||||
fetchHydrogenOrders({ date, customer: vehicleScope, stationId, customerName, page, limit: 20 })
|
||||
.then(result => { if (!cancelled) setData(result); })
|
||||
.catch(loadError => {
|
||||
if (!cancelled) setError(loadError instanceof Error ? loadError.message : String(loadError));
|
||||
})
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [customer, customerName, date, page, retryKey, stationId]);
|
||||
}, [vehicleScope, customerName, date, page, retryKey, stationId]);
|
||||
|
||||
const totalPages = data?.totalPages ?? 1;
|
||||
const scope = customerName
|
||||
? `客户:${customerName}`
|
||||
: stationId !== undefined
|
||||
? `站点 #${stationId}`
|
||||
: customer === 'external' ? '外部车辆' : '羚牛车辆';
|
||||
: vehicleScope === 'external' ? '外部车辆' : '羚牛车辆';
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-lg border border-blue-100 bg-white shadow-sm">
|
||||
|
||||
@@ -39,8 +39,8 @@ export interface HydrogenDailyQuery {
|
||||
customerName?: string;
|
||||
}
|
||||
|
||||
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
const q = new URLSearchParams({ customer });
|
||||
export function fetchHydrogenDaily(query: HydrogenDailyQuery, vehicleScope: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
const q = new URLSearchParams({ customer: vehicleScope });
|
||||
if (query.range) q.set('range', query.range);
|
||||
if (query.startDate) q.set('startDate', query.startDate);
|
||||
if (query.endDate) q.set('endDate', query.endDate);
|
||||
@@ -80,16 +80,16 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||
}
|
||||
|
||||
export function fetchElectricMonthly(customer: ElectricVehicleScope, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer });
|
||||
export function fetchElectricMonthly(vehicleScope: ElectricVehicleScope, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer: vehicleScope });
|
||||
if (query.range) q.set('range', query.range);
|
||||
if (query.startDate) q.set('startDate', query.startDate);
|
||||
if (query.endDate) q.set('endDate', query.endDate);
|
||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||
}
|
||||
|
||||
export function fetchElectricOrders(date: string, customer: ElectricVehicleScope): Promise<ElectricChargeOrderResponse> {
|
||||
const q = new URLSearchParams({ date, customer });
|
||||
export function fetchElectricOrders(date: string, vehicleScope: ElectricVehicleScope): Promise<ElectricChargeOrderResponse> {
|
||||
const q = new URLSearchParams({ date, customer: vehicleScope });
|
||||
return fetchJson<ElectricChargeOrderResponse>(`${BASE}/electric/orders?${q.toString()}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user