From 3d57787e61beb22b87913cf9d235fdda297cfbab Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 16:44:31 +0800 Subject: [PATCH] refactor: unify BI date range filters --- docs/bi-refactor-roadmap.md | 1 + src/modules/energy/AnalysisDateFilters.tsx | 110 +++++++++++++++++++ src/modules/energy/ETCDetails.tsx | 54 +++++----- src/modules/energy/ElectricDaily.tsx | 119 ++++----------------- src/modules/energy/HydrogenDaily.tsx | 119 ++++----------------- src/modules/energy/date-range.test.ts | 17 +++ src/modules/energy/date-range.ts | 50 +++++++++ 7 files changed, 248 insertions(+), 222 deletions(-) create mode 100644 src/modules/energy/AnalysisDateFilters.tsx create mode 100644 src/modules/energy/date-range.test.ts create mode 100644 src/modules/energy/date-range.ts diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index 9b4141e..3b1d54f 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -151,6 +151,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0) - 已完成里程、电能、氢能主要 URL 状态。 - 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。 - 已统一共享加载、空数据和故障组件的顶层卡片与明细内联边界,消除能源明细中的嵌套状态卡片。 +- 已统一电能、氢能和 ETC 日期范围输入;电能与氢能共用快捷区间算法和单一输入事件路径。 - 已完成并发同参 GET 去重。 ### 阶段 B:核心下钻闭环 diff --git a/src/modules/energy/AnalysisDateFilters.tsx b/src/modules/energy/AnalysisDateFilters.tsx new file mode 100644 index 0000000..3baf6c1 --- /dev/null +++ b/src/modules/energy/AnalysisDateFilters.tsx @@ -0,0 +1,110 @@ +import { cn } from '../../lib/cn'; +import type { DateQuickPick } from './types'; +import { QUICK_DATE_OPTIONS, type DateRangeMode } from './date-range'; + +export function DateRangeInputs({ + idPrefix, + startDate, + endDate, + onChange, + ariaPrefix = '', + className, +}: { + idPrefix: string; + startDate: string; + endDate: string; + onChange: (field: 'start' | 'end', value: string) => void; + ariaPrefix?: string; + className?: string; +}) { + const prefix = ariaPrefix ? `${ariaPrefix} ` : ''; + return ( +
+ + +
+ ); +} + +export default function AnalysisDateFilters({ + idPrefix, + mode, + startDate, + endDate, + onQuickPick, + onCustom, + onDateChange, +}: { + idPrefix: string; + mode: DateRangeMode; + startDate: string; + endDate: string; + onQuickPick: (pick: DateQuickPick) => void; + onCustom: () => void; + onDateChange: (field: 'start' | 'end', value: string) => void; +}) { + return ( + <> +
+ {QUICK_DATE_OPTIONS.map(option => ( + + ))} + +
+ + + ); +} diff --git a/src/modules/energy/ETCDetails.tsx b/src/modules/energy/ETCDetails.tsx index 1ada1ce..2796931 100644 --- a/src/modules/energy/ETCDetails.tsx +++ b/src/modules/energy/ETCDetails.tsx @@ -5,6 +5,7 @@ import { ErrorState, LoadingState } from '../../components/ui/surface'; import { fetchEtcBills, fetchEtcRecords } from './api'; import type { EtcBillResponse, EtcTollRecordResponse } from './types'; import type { EtcDetailView, EtcDrillContext } from './etc-drill-context'; +import { DateRangeInputs } from './AnalysisDateFilters'; function fmtMoney(value: number): string { return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; @@ -80,9 +81,9 @@ export default function ETCDetails({ next === view ? 'replace' : 'push', ); }; - const updateDate = (field: 'startDate' | 'endDate', value: string) => { - let nextStart = field === 'startDate' ? value : startDate; - let nextEnd = field === 'endDate' ? value : endDate; + const updateDate = (field: 'start' | 'end', value: string) => { + let nextStart = field === 'start' ? value : startDate; + let nextEnd = field === 'end' ? value : endDate; if (nextStart && nextEnd && nextStart > nextEnd) [nextStart, nextEnd] = [nextEnd, nextStart]; onContextChange({ ...context, startDate: nextStart, endDate: nextEnd, page: 1 }, 'replace'); }; @@ -117,35 +118,32 @@ export default function ETCDetails({ -
- updateDate('startDate', event.target.value)} - className="h-9 min-w-0 rounded-lg border border-slate-200 bg-white px-2 text-[11px] font-bold text-slate-700 outline-none focus:border-blue-300" - /> - updateDate('endDate', event.target.value)} - className="h-9 min-w-0 rounded-lg border border-slate-200 bg-white px-2 text-[11px] font-bold text-slate-700 outline-none focus:border-blue-300" - /> - setDraftSearch(event.target.value)} - onKeyDown={event => { if (event.key === 'Enter') applySearch(); }} - className="h-9 min-w-0 rounded-lg border border-slate-200 bg-white px-3 text-[11px] font-bold text-slate-700 outline-none placeholder:text-slate-300 focus:border-blue-300" +
+ + - ))} - -
- -
- - -
+ setPick('custom')} + onDateChange={updateDateRange} + />
{(['all', 'lingniu', 'external'] as const).map(c => ( diff --git a/src/modules/energy/HydrogenDaily.tsx b/src/modules/energy/HydrogenDaily.tsx index 8c9378b..b01c84d 100644 --- a/src/modules/energy/HydrogenDaily.tsx +++ b/src/modules/energy/HydrogenDaily.tsx @@ -13,53 +13,25 @@ import { type HydrogenDrillContext, } from './hydrogen-drill-context'; import HydrogenOrders from './HydrogenOrders'; - -const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [ - { id: 'thisWeek', label: '本周' }, - { id: 'thisMonth', label: '本月' }, - { id: 'last15', label: '近 15 天' }, -]; - -type RangeMode = DateQuickPick | 'custom'; - -function fmtYmd(d: Date): string { - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; -} - -function addDays(d: Date, days: number): Date { - const next = new Date(d); - next.setDate(next.getDate() + days); - return next; -} - -function getQuickRange(pick: DateQuickPick): { start: string; end: string } { - const today = new Date(); - today.setHours(0, 0, 0, 0); - if (pick === 'thisWeek') { - const day = today.getDay() || 7; - return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) }; - } - if (pick === 'thisMonth') { - return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) }; - } - return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) }; -} - -function normalizeRange(start: string, end: string): { start: string; end: string } { - return start <= end ? { start, end } : { start: end, end: start }; -} +import AnalysisDateFilters from './AnalysisDateFilters'; +import { + dateRangeModeLabel, + getQuickDateRange, + normalizeDateRange, + type DateRangeMode, +} from './date-range'; export default function HydrogenDaily() { const [drillContext, setDrillContext] = useState(() => ( parseHydrogenDrillContext(window.location.search) )); - const [pick, setPick] = useState(() => ( + const [pick, setPick] = useState(() => ( drillContext.startDate && drillContext.endDate ? 'custom' : 'last15' )); const [dateRange, setDateRange] = useState(() => ( drillContext.startDate && drillContext.endDate ? { start: drillContext.startDate, end: drillContext.endDate } - : getQuickRange('last15') + : getQuickDateRange('last15') )); const [customer, setCustomer] = useState(drillContext.vehicleScope); const [expanded, setExpanded] = useState>(new Set()); @@ -67,7 +39,7 @@ export default function HydrogenDaily() { const [error, setError] = useState(null); const [retryKey, setRetryKey] = useState(0); - const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]); + const effectiveRange = useMemo(() => normalizeDateRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]); const selectedStationId = drillContext.level === 'station' ? drillContext.stationId : undefined; const selectedStationName = drillContext.level === 'station' ? drillContext.stationName || `站点 #${drillContext.stationId}` @@ -105,9 +77,7 @@ export default function HydrogenDaily() { return ids.size; }, [rows]); const avgKg = activeDays > 0 ? totalKg / activeDays : 0; - const scopeLabel = pick === 'custom' - ? '自定义区间' - : QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段'; + const scopeLabel = dateRangeModeLabel(pick); const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`; const peakDay = trendData.reduce((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null); const lowDay = trendData @@ -122,7 +92,7 @@ export default function HydrogenDaily() { }); const applyQuickPick = (nextPick: DateQuickPick) => { - const nextRange = getQuickRange(nextPick); + const nextRange = getQuickDateRange(nextPick); setPick(nextPick); setDateRange(nextRange); commitDrillContext({ @@ -139,7 +109,7 @@ export default function HydrogenDaily() { const nextRange = { ...dateRange, [field]: value }; setPick('custom'); setDateRange(nextRange); - const normalized = normalizeRange(nextRange.start, nextRange.end); + const normalized = normalizeDateRange(nextRange.start, nextRange.end); commitDrillContext({ ...drillContext, vehicleScope: customer, @@ -212,60 +182,15 @@ export default function HydrogenDaily() { return (
-
- {QUICK_PICK_OPTIONS.map(opt => ( - - ))} - -
- -
- - -
+ setPick('custom')} + onDateChange={updateDateRange} + />
{(['lingniu', 'external'] as const).map(c => ( diff --git a/src/modules/energy/date-range.test.ts b/src/modules/energy/date-range.test.ts new file mode 100644 index 0000000..3d14392 --- /dev/null +++ b/src/modules/energy/date-range.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { dateRangeModeLabel, getQuickDateRange, normalizeDateRange } from './date-range.js'; + +const FRIDAY = new Date(2026, 7, 7, 16, 30, 0); + +test('builds shared quick date ranges from local calendar days', () => { + assert.deepEqual(getQuickDateRange('thisWeek', FRIDAY), { start: '2026-08-03', end: '2026-08-07' }); + assert.deepEqual(getQuickDateRange('thisMonth', FRIDAY), { start: '2026-08-01', end: '2026-08-07' }); + assert.deepEqual(getQuickDateRange('last15', FRIDAY), { start: '2026-07-24', end: '2026-08-07' }); +}); + +test('normalizes date order and publishes consistent labels', () => { + assert.deepEqual(normalizeDateRange('2026-08-07', '2026-08-01'), { start: '2026-08-01', end: '2026-08-07' }); + assert.equal(dateRangeModeLabel('thisMonth'), '本月'); + assert.equal(dateRangeModeLabel('custom'), '自定义区间'); +}); diff --git a/src/modules/energy/date-range.ts b/src/modules/energy/date-range.ts new file mode 100644 index 0000000..bba7440 --- /dev/null +++ b/src/modules/energy/date-range.ts @@ -0,0 +1,50 @@ +import type { DateQuickPick } from './types'; + +export type DateRangeMode = DateQuickPick | 'custom'; + +export interface DateRangeValue { + start: string; + end: string; +} + +const LABELS: Record = { + thisWeek: '本周', + thisMonth: '本月', + last15: '近 15 天', +}; + +function fmtYmd(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function addDays(date: Date, days: number): Date { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +export function getQuickDateRange(pick: DateQuickPick, now = new Date()): DateRangeValue { + const today = new Date(now); + today.setHours(0, 0, 0, 0); + if (pick === 'thisWeek') { + const day = today.getDay() || 7; + return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) }; + } + if (pick === 'thisMonth') { + return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) }; + } + return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) }; +} + +export function normalizeDateRange(start: string, end: string): DateRangeValue { + return start <= end ? { start, end } : { start: end, end: start }; +} + +export function dateRangeModeLabel(mode: DateRangeMode): string { + return mode === 'custom' ? '自定义区间' : LABELS[mode]; +} + +export const QUICK_DATE_OPTIONS = (Object.keys(LABELS) as DateQuickPick[]).map(id => ({ + id, + label: LABELS[id], +}));