diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index 7827e06..3ecd1f3 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -153,6 +153,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0) - 已统一共享加载、空数据和故障组件的顶层卡片与明细内联边界,消除能源明细中的嵌套状态卡片。 - 已统一电能、氢能和 ETC 日期范围输入;电能与氢能共用快捷区间算法和单一输入事件路径。 - 电能与氢能可根据 URL 日期恢复快捷区间选中态;过期区间自动归为自定义,避免标签与统计日期不一致。 +- 已统一电能和氢能车辆范围 segmented control,并区分前端车辆归属状态与后端 `customer` 查询参数语义。 - 已完成并发同参 GET 去重。 ### 阶段 B:核心下钻闭环 diff --git a/src/modules/energy/AnalysisScopeSwitch.tsx b/src/modules/energy/AnalysisScopeSwitch.tsx new file mode 100644 index 0000000..62752d0 --- /dev/null +++ b/src/modules/energy/AnalysisScopeSwitch.tsx @@ -0,0 +1,50 @@ +import type { ComponentType } from 'react'; +import { cn } from '../../lib/cn'; + +export interface AnalysisScopeOption { + id: T; + label: string; +} + +export default function AnalysisScopeSwitch({ + ariaLabel, + value, + options, + onChange, + icon: Icon, + className, +}: { + ariaLabel: string; + value: T; + options: readonly AnalysisScopeOption[]; + onChange: (value: T) => void; + icon?: ComponentType<{ size?: number; className?: string }>; + className?: string; +}) { + return ( +
+ {options.map(option => ( + + ))} +
+ ); +} diff --git a/src/modules/energy/ElectricDaily.tsx b/src/modules/energy/ElectricDaily.tsx index 248c1e4..b3447d9 100644 --- a/src/modules/energy/ElectricDaily.tsx +++ b/src/modules/energy/ElectricDaily.tsx @@ -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[] = [ + { id: 'all', label: '全部车辆' }, + { id: 'lingniu', label: '羚牛车辆' }, + { id: 'external', label: '外部车辆' }, +]; + export default function ElectricDaily() { const [drillContext, setDrillContext] = useState(() => ( parseElectricDrillContext(window.location.search) )); - const [customer, setCustomer] = useState(drillContext.vehicleScope); + const [vehicleScope, setVehicleScope] = useState(drillContext.vehicleScope); const [pick, setPick] = useState(() => { 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} /> -
- {(['all', 'lingniu', 'external'] as const).map(c => ( - - ))} -
+
diff --git a/src/modules/energy/HydrogenDaily.tsx b/src/modules/energy/HydrogenDaily.tsx index 329c19b..41ffd78 100644 --- a/src/modules/energy/HydrogenDaily.tsx +++ b/src/modules/energy/HydrogenDaily.tsx @@ -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[] = [ + { id: 'lingniu', label: '羚牛车辆' }, + { id: 'external', label: '外部车辆' }, +]; + export default function HydrogenDaily() { const [drillContext, setDrillContext] = useState(() => ( parseHydrogenDrillContext(window.location.search) @@ -36,7 +42,7 @@ export default function HydrogenDaily() { ? normalizeDateRange(drillContext.startDate, drillContext.endDate) : getQuickDateRange('last15') )); - const [customer, setCustomer] = useState(drillContext.vehicleScope); + const [vehicleScope, setVehicleScope] = useState(drillContext.vehicleScope); const [expanded, setExpanded] = useState>(new Set()); const [rows, setRows] = useState(null); const [error, setError] = useState(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} /> -
- {(['lingniu', 'external'] as const).map(c => ( - - ))} -
+ {selectedStationName && ( @@ -261,13 +260,13 @@ export default function HydrogenDaily() { {!error && rows !== null &&
- +
} {/* 外部车辆:新系统数据还没准备好 */} - {!error && customer === 'external' && rows !== null && totalKg === 0 && ( + {!error && vehicleScope === 'external' && rows !== null && totalKg === 0 && ( 0 && ( + {!error && !(vehicleScope === 'external' && totalKg === 0) && trendData.length > 0 && (
每日加氢量 @@ -375,7 +374,7 @@ export default function HydrogenDaily() { {selectedDate && ( {/* 表头 */}
diff --git a/src/modules/energy/HydrogenOrders.tsx b/src/modules/energy/HydrogenOrders.tsx index ad4f480..c0f2ab3 100644 --- a/src/modules/energy/HydrogenOrders.tsx +++ b/src/modules/energy/HydrogenOrders.tsx @@ -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 (
diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index d09374b..c22b853 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -39,8 +39,8 @@ export interface HydrogenDailyQuery { customerName?: string; } -export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise { - const q = new URLSearchParams({ customer }); +export function fetchHydrogenDaily(query: HydrogenDailyQuery, vehicleScope: CustomerType): Promise { + 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 { return fetchJson(`${BASE}/electric/overview`); } -export function fetchElectricMonthly(customer: ElectricVehicleScope, query: HydrogenDailyQuery = { range: 'last15' }): Promise { - const q = new URLSearchParams({ customer }); +export function fetchElectricMonthly(vehicleScope: ElectricVehicleScope, query: HydrogenDailyQuery = { range: 'last15' }): Promise { + 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(`${BASE}/electric/monthly?${q.toString()}`); } -export function fetchElectricOrders(date: string, customer: ElectricVehicleScope): Promise { - const q = new URLSearchParams({ date, customer }); +export function fetchElectricOrders(date: string, vehicleScope: ElectricVehicleScope): Promise { + const q = new URLSearchParams({ date, customer: vehicleScope }); return fetchJson(`${BASE}/electric/orders?${q.toString()}`); }