refactor: unify BI date range filters
This commit is contained in:
@@ -151,6 +151,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
|||||||
- 已完成里程、电能、氢能主要 URL 状态。
|
- 已完成里程、电能、氢能主要 URL 状态。
|
||||||
- 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。
|
- 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。
|
||||||
- 已统一共享加载、空数据和故障组件的顶层卡片与明细内联边界,消除能源明细中的嵌套状态卡片。
|
- 已统一共享加载、空数据和故障组件的顶层卡片与明细内联边界,消除能源明细中的嵌套状态卡片。
|
||||||
|
- 已统一电能、氢能和 ETC 日期范围输入;电能与氢能共用快捷区间算法和单一输入事件路径。
|
||||||
- 已完成并发同参 GET 去重。
|
- 已完成并发同参 GET 去重。
|
||||||
|
|
||||||
### 阶段 B:核心下钻闭环
|
### 阶段 B:核心下钻闭环
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ErrorState, LoadingState } from '../../components/ui/surface';
|
|||||||
import { fetchEtcBills, fetchEtcRecords } from './api';
|
import { fetchEtcBills, fetchEtcRecords } from './api';
|
||||||
import type { EtcBillResponse, EtcTollRecordResponse } from './types';
|
import type { EtcBillResponse, EtcTollRecordResponse } from './types';
|
||||||
import type { EtcDetailView, EtcDrillContext } from './etc-drill-context';
|
import type { EtcDetailView, EtcDrillContext } from './etc-drill-context';
|
||||||
|
import { DateRangeInputs } from './AnalysisDateFilters';
|
||||||
|
|
||||||
function fmtMoney(value: number): string {
|
function fmtMoney(value: number): string {
|
||||||
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
@@ -80,9 +81,9 @@ export default function ETCDetails({
|
|||||||
next === view ? 'replace' : 'push',
|
next === view ? 'replace' : 'push',
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const updateDate = (field: 'startDate' | 'endDate', value: string) => {
|
const updateDate = (field: 'start' | 'end', value: string) => {
|
||||||
let nextStart = field === 'startDate' ? value : startDate;
|
let nextStart = field === 'start' ? value : startDate;
|
||||||
let nextEnd = field === 'endDate' ? value : endDate;
|
let nextEnd = field === 'end' ? value : endDate;
|
||||||
if (nextStart && nextEnd && nextStart > nextEnd) [nextStart, nextEnd] = [nextEnd, nextStart];
|
if (nextStart && nextEnd && nextStart > nextEnd) [nextStart, nextEnd] = [nextEnd, nextStart];
|
||||||
onContextChange({ ...context, startDate: nextStart, endDate: nextEnd, page: 1 }, 'replace');
|
onContextChange({ ...context, startDate: nextStart, endDate: nextEnd, page: 1 }, 'replace');
|
||||||
};
|
};
|
||||||
@@ -117,35 +118,32 @@ export default function ETCDetails({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-[140px_140px_minmax(0,1fr)_36px]">
|
<div className="grid grid-cols-[minmax(0,1fr)_40px] gap-2 sm:grid-cols-[minmax(280px,1fr)_minmax(0,1fr)_40px]">
|
||||||
<input
|
<DateRangeInputs
|
||||||
type="date"
|
idPrefix="etc"
|
||||||
aria-label="ETC 开始日期"
|
ariaPrefix="ETC"
|
||||||
value={startDate}
|
startDate={startDate}
|
||||||
onChange={event => updateDate('startDate', event.target.value)}
|
endDate={endDate}
|
||||||
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"
|
onChange={updateDate}
|
||||||
/>
|
className="col-span-2 sm:col-span-1"
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
aria-label={view === 'records' ? '搜索车牌、客户或流水号' : '搜索客户或账单号'}
|
|
||||||
placeholder={view === 'records' ? '车牌 / 客户 / 流水号' : '客户 / 账单号'}
|
|
||||||
value={draftSearch}
|
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
|
<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' ? '搜索车牌、客户或流水号' : '搜索客户或账单号'}
|
||||||
|
placeholder={view === 'records' ? '车牌 / 客户 / 流水号' : '客户 / 账单号'}
|
||||||
|
value={draftSearch}
|
||||||
|
maxLength={128}
|
||||||
|
onChange={event => setDraftSearch(event.target.value)}
|
||||||
|
onKeyDown={event => { if (event.key === 'Enter') applySearch(); }}
|
||||||
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={applySearch}
|
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 明细搜索"
|
aria-label="应用 ETC 明细搜索"
|
||||||
title="搜索"
|
title="搜索"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,48 +11,20 @@ import {
|
|||||||
parseElectricDrillContext,
|
parseElectricDrillContext,
|
||||||
type ElectricDrillContext,
|
type ElectricDrillContext,
|
||||||
} from './electric-drill-context';
|
} from './electric-drill-context';
|
||||||
|
import AnalysisDateFilters from './AnalysisDateFilters';
|
||||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
import {
|
||||||
{ id: 'thisWeek', label: '本周' },
|
dateRangeModeLabel,
|
||||||
{ id: 'thisMonth', label: '本月' },
|
getQuickDateRange,
|
||||||
{ id: 'last15', label: '近 15 天' },
|
normalizeDateRange,
|
||||||
];
|
type DateRangeMode,
|
||||||
|
} from './date-range';
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ElectricDaily() {
|
export default function ElectricDaily() {
|
||||||
const [drillContext, setDrillContext] = useState<ElectricDrillContext>(() => (
|
const [drillContext, setDrillContext] = useState<ElectricDrillContext>(() => (
|
||||||
parseElectricDrillContext(window.location.search)
|
parseElectricDrillContext(window.location.search)
|
||||||
));
|
));
|
||||||
const [customer, setCustomer] = useState<ElectricVehicleScope>(drillContext.vehicleScope);
|
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'
|
drillContext.startDate && drillContext.endDate || drillContext.selectedDate ? 'custom' : 'last15'
|
||||||
));
|
));
|
||||||
const [dateRange, setDateRange] = useState(() => {
|
const [dateRange, setDateRange] = useState(() => {
|
||||||
@@ -62,7 +34,7 @@ export default function ElectricDaily() {
|
|||||||
if (drillContext.selectedDate) {
|
if (drillContext.selectedDate) {
|
||||||
return { start: drillContext.selectedDate, end: drillContext.selectedDate };
|
return { start: drillContext.selectedDate, end: drillContext.selectedDate };
|
||||||
}
|
}
|
||||||
return getQuickRange('last15');
|
return getQuickDateRange('last15');
|
||||||
});
|
});
|
||||||
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||||
@@ -70,7 +42,7 @@ export default function ElectricDaily() {
|
|||||||
const [orders, setOrders] = useState<ElectricChargeOrderResponse | null>(null);
|
const [orders, setOrders] = useState<ElectricChargeOrderResponse | null>(null);
|
||||||
const [ordersError, setOrdersError] = useState<string | 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 selectedDate = drillContext.selectedDate;
|
||||||
const selectedDateRef = useRef(selectedDate);
|
const selectedDateRef = useRef(selectedDate);
|
||||||
selectedDateRef.current = 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 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 avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
|
||||||
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
|
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
|
||||||
const scopeLabel = pick === 'custom'
|
const scopeLabel = dateRangeModeLabel(pick);
|
||||||
? '自定义区间'
|
|
||||||
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
|
|
||||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||||||
|
|
||||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||||
const nextRange = getQuickRange(nextPick);
|
const nextRange = getQuickDateRange(nextPick);
|
||||||
setPick(nextPick);
|
setPick(nextPick);
|
||||||
setDateRange(nextRange);
|
setDateRange(nextRange);
|
||||||
commitDrillContext({
|
commitDrillContext({
|
||||||
@@ -146,7 +116,7 @@ export default function ElectricDaily() {
|
|||||||
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
const nextRange = { ...dateRange, [field]: value };
|
const nextRange = { ...dateRange, [field]: value };
|
||||||
const normalized = normalizeRange(nextRange.start, nextRange.end);
|
const normalized = normalizeDateRange(nextRange.start, nextRange.end);
|
||||||
setPick('custom');
|
setPick('custom');
|
||||||
setDateRange(nextRange);
|
setDateRange(nextRange);
|
||||||
commitDrillContext({
|
commitDrillContext({
|
||||||
@@ -196,60 +166,15 @@ export default function ElectricDaily() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<SurfaceCard className="p-2 md:p-3">
|
<SurfaceCard className="p-2 md:p-3">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
<AnalysisDateFilters
|
||||||
{QUICK_PICK_OPTIONS.map(opt => (
|
idPrefix="electric"
|
||||||
<button
|
mode={pick}
|
||||||
key={opt.id}
|
startDate={dateRange.start}
|
||||||
onClick={() => applyQuickPick(opt.id)}
|
endDate={dateRange.end}
|
||||||
aria-pressed={pick === opt.id}
|
onQuickPick={applyQuickPick}
|
||||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
onCustom={() => setPick('custom')}
|
||||||
pick === opt.id
|
onDateChange={updateDateRange}
|
||||||
? '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"
|
|
||||||
/>
|
|
||||||
</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">
|
<div className="mt-2 grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1">
|
||||||
{(['all', 'lingniu', 'external'] as const).map(c => (
|
{(['all', 'lingniu', 'external'] as const).map(c => (
|
||||||
|
|||||||
@@ -13,53 +13,25 @@ import {
|
|||||||
type HydrogenDrillContext,
|
type HydrogenDrillContext,
|
||||||
} from './hydrogen-drill-context';
|
} from './hydrogen-drill-context';
|
||||||
import HydrogenOrders from './HydrogenOrders';
|
import HydrogenOrders from './HydrogenOrders';
|
||||||
|
import AnalysisDateFilters from './AnalysisDateFilters';
|
||||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
import {
|
||||||
{ id: 'thisWeek', label: '本周' },
|
dateRangeModeLabel,
|
||||||
{ id: 'thisMonth', label: '本月' },
|
getQuickDateRange,
|
||||||
{ id: 'last15', label: '近 15 天' },
|
normalizeDateRange,
|
||||||
];
|
type DateRangeMode,
|
||||||
|
} from './date-range';
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HydrogenDaily() {
|
export default function HydrogenDaily() {
|
||||||
const [drillContext, setDrillContext] = useState<HydrogenDrillContext>(() => (
|
const [drillContext, setDrillContext] = useState<HydrogenDrillContext>(() => (
|
||||||
parseHydrogenDrillContext(window.location.search)
|
parseHydrogenDrillContext(window.location.search)
|
||||||
));
|
));
|
||||||
const [pick, setPick] = useState<RangeMode>(() => (
|
const [pick, setPick] = useState<DateRangeMode>(() => (
|
||||||
drillContext.startDate && drillContext.endDate ? 'custom' : 'last15'
|
drillContext.startDate && drillContext.endDate ? 'custom' : 'last15'
|
||||||
));
|
));
|
||||||
const [dateRange, setDateRange] = useState(() => (
|
const [dateRange, setDateRange] = useState(() => (
|
||||||
drillContext.startDate && drillContext.endDate
|
drillContext.startDate && drillContext.endDate
|
||||||
? { start: drillContext.startDate, end: drillContext.endDate }
|
? { start: drillContext.startDate, end: drillContext.endDate }
|
||||||
: getQuickRange('last15')
|
: getQuickDateRange('last15')
|
||||||
));
|
));
|
||||||
const [customer, setCustomer] = useState<CustomerType>(drillContext.vehicleScope);
|
const [customer, setCustomer] = useState<CustomerType>(drillContext.vehicleScope);
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
@@ -67,7 +39,7 @@ export default function HydrogenDaily() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [retryKey, setRetryKey] = useState(0);
|
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 selectedStationId = drillContext.level === 'station' ? drillContext.stationId : undefined;
|
||||||
const selectedStationName = drillContext.level === 'station'
|
const selectedStationName = drillContext.level === 'station'
|
||||||
? drillContext.stationName || `站点 #${drillContext.stationId}`
|
? drillContext.stationName || `站点 #${drillContext.stationId}`
|
||||||
@@ -105,9 +77,7 @@ export default function HydrogenDaily() {
|
|||||||
return ids.size;
|
return ids.size;
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
|
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
|
||||||
const scopeLabel = pick === 'custom'
|
const scopeLabel = dateRangeModeLabel(pick);
|
||||||
? '自定义区间'
|
|
||||||
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
|
|
||||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||||
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
|
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
|
||||||
const lowDay = trendData
|
const lowDay = trendData
|
||||||
@@ -122,7 +92,7 @@ export default function HydrogenDaily() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||||
const nextRange = getQuickRange(nextPick);
|
const nextRange = getQuickDateRange(nextPick);
|
||||||
setPick(nextPick);
|
setPick(nextPick);
|
||||||
setDateRange(nextRange);
|
setDateRange(nextRange);
|
||||||
commitDrillContext({
|
commitDrillContext({
|
||||||
@@ -139,7 +109,7 @@ export default function HydrogenDaily() {
|
|||||||
const nextRange = { ...dateRange, [field]: value };
|
const nextRange = { ...dateRange, [field]: value };
|
||||||
setPick('custom');
|
setPick('custom');
|
||||||
setDateRange(nextRange);
|
setDateRange(nextRange);
|
||||||
const normalized = normalizeRange(nextRange.start, nextRange.end);
|
const normalized = normalizeDateRange(nextRange.start, nextRange.end);
|
||||||
commitDrillContext({
|
commitDrillContext({
|
||||||
...drillContext,
|
...drillContext,
|
||||||
vehicleScope: customer,
|
vehicleScope: customer,
|
||||||
@@ -212,60 +182,15 @@ export default function HydrogenDaily() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<SurfaceCard className="p-2 md:p-3">
|
<SurfaceCard className="p-2 md:p-3">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
<AnalysisDateFilters
|
||||||
{QUICK_PICK_OPTIONS.map(opt => (
|
idPrefix="hydrogen"
|
||||||
<button
|
mode={pick}
|
||||||
key={opt.id}
|
startDate={dateRange.start}
|
||||||
onClick={() => applyQuickPick(opt.id)}
|
endDate={dateRange.end}
|
||||||
aria-pressed={pick === opt.id}
|
onQuickPick={applyQuickPick}
|
||||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
onCustom={() => setPick('custom')}
|
||||||
pick === opt.id
|
onDateChange={updateDateRange}
|
||||||
? '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"
|
|
||||||
/>
|
|
||||||
</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">
|
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||||
{(['lingniu', 'external'] as const).map(c => (
|
{(['lingniu', 'external'] as const).map(c => (
|
||||||
|
|||||||
@@ -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'), '自定义区间');
|
||||||
|
});
|
||||||
@@ -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],
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user