refactor: unify BI vehicle scope controls

This commit is contained in:
kkfluous
2026-08-07 16:53:07 +08:00
parent ea9c9212e2
commit 86a23ad343
6 changed files with 120 additions and 70 deletions
@@ -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>
);
}
+27 -27
View File
@@ -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">
+30 -31
View File
@@ -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">
+6 -6
View File
@@ -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">
+6 -6
View File
@@ -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()}`);
}