refactor: unify BI date range filters

This commit is contained in:
kkfluous
2026-08-07 16:44:31 +08:00
parent 55297a6240
commit 3d57787e61
7 changed files with 248 additions and 222 deletions
+1
View File
@@ -151,6 +151,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 已完成里程、电能、氢能主要 URL 状态。
- 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。
- 已统一共享加载、空数据和故障组件的顶层卡片与明细内联边界,消除能源明细中的嵌套状态卡片。
- 已统一电能、氢能和 ETC 日期范围输入;电能与氢能共用快捷区间算法和单一输入事件路径。
- 已完成并发同参 GET 去重。
### 阶段 B:核心下钻闭环
+110
View File
@@ -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 (
<div className={cn('grid grid-cols-2 gap-2', className)}>
<label className="min-w-0 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
id={`${idPrefix}-start-date`}
name={`${idPrefix}StartDate`}
type="date"
aria-label={`${prefix}开始日期`}
value={startDate}
onInput={event => onChange('start', event.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
id={`${idPrefix}-end-date`}
name={`${idPrefix}EndDate`}
type="date"
aria-label={`${prefix}结束日期`}
value={endDate}
onInput={event => onChange('end', event.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
);
}
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 (
<>
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_DATE_OPTIONS.map(option => (
<button
key={option.id}
type="button"
onClick={() => onQuickPick(option.id)}
aria-pressed={mode === option.id}
className={cn(
'min-h-9 shrink-0 rounded-lg border px-3 text-[12px] font-black transition-colors',
mode === option.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50',
)}
>
{option.label}
</button>
))}
<button
type="button"
onClick={onCustom}
aria-pressed={mode === 'custom'}
className={cn(
'min-h-9 shrink-0 rounded-lg border px-3 text-[12px] font-black transition-colors',
mode === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50',
)}
>
</button>
</div>
<DateRangeInputs
idPrefix={idPrefix}
startDate={startDate}
endDate={endDate}
onChange={onDateChange}
className="mt-2"
/>
</>
);
}
+17 -19
View File
@@ -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,21 +118,17 @@ export default function ETCDetails({
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[140px_140px_minmax(0,1fr)_36px]">
<input
type="date"
aria-label="ETC 开始日期"
value={startDate}
onChange={event => 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"
/>
<input
type="date"
aria-label="ETC 结束日期"
value={endDate}
onChange={event => 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"
<div className="grid grid-cols-[minmax(0,1fr)_40px] gap-2 sm:grid-cols-[minmax(280px,1fr)_minmax(0,1fr)_40px]">
<DateRangeInputs
idPrefix="etc"
ariaPrefix="ETC"
startDate={startDate}
endDate={endDate}
onChange={updateDate}
className="col-span-2 sm:col-span-1"
/>
<label className="min-w-0 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="search"
aria-label={view === 'records' ? '搜索车牌、客户或流水号' : '搜索客户或账单号'}
@@ -140,12 +137,13 @@ export default function ETCDetails({
maxLength={128}
onChange={event => 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"
className="mt-1 h-6 w-full min-w-0 bg-transparent text-[11px] font-bold text-slate-700 outline-none placeholder:text-slate-300"
/>
</label>
<button
type="button"
onClick={applySearch}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-900 text-white hover:bg-blue-600"
className="flex h-full min-h-[58px] w-10 items-center justify-center rounded-lg bg-slate-900 text-white hover:bg-blue-600"
aria-label="应用 ETC 明细搜索"
title="搜索"
>
+21 -96
View File
@@ -11,48 +11,20 @@ import {
parseElectricDrillContext,
type ElectricDrillContext,
} from './electric-drill-context';
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 ElectricDaily() {
const [drillContext, setDrillContext] = useState<ElectricDrillContext>(() => (
parseElectricDrillContext(window.location.search)
));
const [customer, setCustomer] = useState<ElectricVehicleScope>(drillContext.vehicleScope);
const [pick, setPick] = useState<RangeMode>(() => (
const [pick, setPick] = useState<DateRangeMode>(() => (
drillContext.startDate && drillContext.endDate || drillContext.selectedDate ? 'custom' : 'last15'
));
const [dateRange, setDateRange] = useState(() => {
@@ -62,7 +34,7 @@ export default function ElectricDaily() {
if (drillContext.selectedDate) {
return { start: drillContext.selectedDate, end: drillContext.selectedDate };
}
return getQuickRange('last15');
return getQuickDateRange('last15');
});
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
@@ -70,7 +42,7 @@ export default function ElectricDaily() {
const [orders, setOrders] = useState<ElectricChargeOrderResponse | null>(null);
const [ordersError, setOrdersError] = useState<string | null>(null);
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 selectedDate = drillContext.selectedDate;
const selectedDateRef = useRef(selectedDate);
selectedDateRef.current = selectedDate;
@@ -126,14 +98,12 @@ export default function ElectricDaily() {
const abnormalDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => Math.abs(r.chainPct) >= 0.3).length, 0), [months]);
const avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 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 showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
const nextRange = getQuickRange(nextPick);
const nextRange = getQuickDateRange(nextPick);
setPick(nextPick);
setDateRange(nextRange);
commitDrillContext({
@@ -146,7 +116,7 @@ export default function ElectricDaily() {
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
const nextRange = { ...dateRange, [field]: value };
const normalized = normalizeRange(nextRange.start, nextRange.end);
const normalized = normalizeDateRange(nextRange.start, nextRange.end);
setPick('custom');
setDateRange(nextRange);
commitDrillContext({
@@ -196,60 +166,15 @@ export default function ElectricDaily() {
return (
<div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => applyQuickPick(opt.id)}
aria-pressed={pick === opt.id}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
<button
onClick={() => setPick('custom')}
aria-pressed={pick === 'custom'}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
id="electric-start-date"
name="electricStartDate"
type="date"
value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)}
onInput={e => updateDateRange('start', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
<AnalysisDateFilters
idPrefix="electric"
mode={pick}
startDate={dateRange.start}
endDate={dateRange.end}
onQuickPick={applyQuickPick}
onCustom={() => setPick('custom')}
onDateChange={updateDateRange}
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
id="electric-end-date"
name="electricEndDate"
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
onInput={e => updateDateRange('end', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1">
{(['all', 'lingniu', 'external'] as const).map(c => (
+21 -96
View File
@@ -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<HydrogenDrillContext>(() => (
parseHydrogenDrillContext(window.location.search)
));
const [pick, setPick] = useState<RangeMode>(() => (
const [pick, setPick] = useState<DateRangeMode>(() => (
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<CustomerType>(drillContext.vehicleScope);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
@@ -67,7 +39,7 @@ export default function HydrogenDaily() {
const [error, setError] = useState<string | null>(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<HydrogenDailyRow | null>((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 (
<div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => applyQuickPick(opt.id)}
aria-pressed={pick === opt.id}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
<button
onClick={() => setPick('custom')}
aria-pressed={pick === 'custom'}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
id="hydrogen-start-date"
name="hydrogenStartDate"
type="date"
value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)}
onInput={e => updateDateRange('start', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
<AnalysisDateFilters
idPrefix="hydrogen"
mode={pick}
startDate={dateRange.start}
endDate={dateRange.end}
onQuickPick={applyQuickPick}
onCustom={() => setPick('custom')}
onDateChange={updateDateRange}
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
id="hydrogen-end-date"
name="hydrogenEndDate"
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
onInput={e => updateDateRange('end', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => (
+17
View File
@@ -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'), '自定义区间');
});
+50
View File
@@ -0,0 +1,50 @@
import type { DateQuickPick } from './types';
export type DateRangeMode = DateQuickPick | 'custom';
export interface DateRangeValue {
start: string;
end: string;
}
const LABELS: Record<DateQuickPick, string> = {
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],
}));