refactor: unify BI date range filters
This commit is contained in:
@@ -151,6 +151,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
||||
- 已完成里程、电能、氢能主要 URL 状态。
|
||||
- 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。
|
||||
- 已统一共享加载、空数据和故障组件的顶层卡片与明细内联边界,消除能源明细中的嵌套状态卡片。
|
||||
- 已统一电能、氢能和 ETC 日期范围输入;电能与氢能共用快捷区间算法和单一输入事件路径。
|
||||
- 已完成并发同参 GET 去重。
|
||||
|
||||
### 阶段 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 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="搜索"
|
||||
>
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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