import { ChevronLeft, ChevronRight } from 'lucide-react' import { useRef, type ReactNode } from 'react' import { Segmented } from '@shared/ui' const MIN = 60_000 const HOUR = 60 * MIN const DAY = 24 * HOUR /** 对比页的时间范围:最近一段。 */ export const PRESETS = [ { value: '5m', label: '5 分钟', ms: 5 * MIN }, { value: '15m', label: '15 分钟', ms: 15 * MIN }, { value: '30m', label: '30 分钟', ms: 30 * MIN }, { value: '1d', label: '1 天', ms: DAY }, { value: '3d', label: '3 天', ms: 3 * DAY }, { value: '7d', label: '7 天', ms: 7 * DAY }, { value: '14d', label: '14 天', ms: 14 * DAY }, { value: '30d', label: '一个月', ms: 30 * DAY }, ] as const export type PresetValue = (typeof PRESETS)[number]['value'] /** 线路详情页:先选哪一天,再选这一天里看多长。`day` = 整个自然日。 */ export const WINDOWS = [ { value: '30m', label: '30 分钟', ms: 30 * MIN }, { value: '1h', label: '1 小时', ms: HOUR }, { value: '3h', label: '3 小时', ms: 3 * HOUR }, { value: '6h', label: '6 小时', ms: 6 * HOUR }, { value: '12h', label: '12 小时', ms: 12 * HOUR }, { value: 'day', label: '整天', ms: DAY }, ] as const export type WindowValue = (typeof WINDOWS)[number]['value'] export const isWindow = (v: string | null): v is WindowValue => WINDOWS.some((w) => w.value === v) const pad = (n: number) => String(n).padStart(2, '0') /** 本地时区的 YYYY-MM-DD。 */ export function dayKey(ms: number): string { const d = new Date(ms) return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` } export function parseDay(key: string | null): number | null { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key ?? '') if (!m) return null const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) return Number.isNaN(d.getTime()) ? null : d.getTime() } /** 本地时区这一天的 [起点, 终点)。 */ export function dayBounds(ms: number): { start: number; end: number } { const d = new Date(ms) return { start: new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(), end: new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime() } } const clock = (ms: number) => `${pad(new Date(ms).getHours())}:${pad(new Date(ms).getMinutes())}` const fullDay = (ms: number) => `${new Date(ms).getFullYear()}年${new Date(ms).getMonth() + 1}月${new Date(ms).getDate()}日` const shortDay = (ms: number) => `${new Date(ms).getMonth() + 1}月${new Date(ms).getDate()}日` /** 一段时间的文字:同一天写一次日期(2026年10月5日 21:06–23:00),跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。 */ export function fmtSpan(fromMs: number, toMs: number, short = false): string { const day = short ? shortDay : fullDay const end = Math.max(fromMs, toMs - 1) // 正好到次日 0 点的写成 24:00 return dayKey(fromMs) === dayKey(end) ? `${day(fromMs)} ${clock(fromMs)}–${clock(toMs) === '00:00' ? '24:00' : clock(toMs)}` : `${day(fromMs)} ${clock(fromMs)} – ${day(end)} ${clock(toMs)}` } export const fmtDayLabel = (startMs: number, nowMs: number) => { const d = new Date(startMs) const today = dayKey(startMs) === dayKey(nowMs) return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日${today ? '(今天)' : ''}` } /** 数据不足整档时允许 10% 的误差:29 分钟的数据算得上“30 分钟”。 */ const TOLERANCE = 0.9 /** 这一天里有多长的数据(毫秒),决定哪些档位可选;`null` = 这一天没有数据。 */ export function availableWindows(spanMs: number | null): WindowValue[] { if (spanMs === null) return [] const list = WINDOWS.filter((w) => w.value === 'day' || spanMs >= w.ms * TOLERANCE).map((w) => w.value) return list.length > 0 ? list : ['day'] } /** 默认:今天看最近 1 小时,往日看整天。 */ export function defaultWindow(isToday: boolean, available: WindowValue[]): WindowValue { if (!isToday) return 'day' return available.includes('1h') ? '1h' : (available.filter((w) => w !== 'day').pop() ?? 'day') } interface Props { /** 选中那天的 0 点(本地时区)。 */ dayStart: number /** 选区(毫秒):长度正好等于某一档时,那一档高亮。 */ fromMs: number toMs: number /** 换了一天(选区回到默认)。 */ onChange: (next: { dayStart: number; window: WindowValue | null }) => void /** 点了一档时长:30 分钟、1 小时、3 小时、6 小时、12 小时、整天。 */ onWindow: (window: WindowValue) => void /** 缩略图的刻度:24 小时(一天)或 48 小时(连续两天,可以跨零点)。 */ scale: 24 | 48 onScale: (scale: 24 | 48) => void /** 这条线路最早有数据的时间;`null` = 没有数据。 */ sinceMs: number | null nowMs: number /** 这一天里数据的跨度(毫秒);未知或没有数据是 `null`。 */ spanMs: number | null /** 放在日期和档位下面(缩略图)。 */ children?: ReactNode } export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, onScale, sinceMs, nowMs, spanMs, children }: Props) { const picker = useRef(null) const today = dayBounds(nowMs).start const firstDay = sinceMs === null ? today : dayBounds(sinceMs).start const available = availableWindows(spanMs) const length = toMs - fromMs const active: WindowValue = WINDOWS.find((w) => Math.abs(length - w.ms) < 1.5 * MIN)?.value ?? ('' as WindowValue) const step = (days: number) => { const d = new Date(dayStart) return new Date(d.getFullYear(), d.getMonth(), d.getDate() + days).getTime() } return (
{dayStart !== today ? ( ) : null}
{children}
{ const ok = available.includes(w.value) return { value: w.value, label: w.label, disabled: !ok, title: ok ? undefined : '这一天的数据没有这么长' } })} value={active} onChange={onWindow} /> options={[ { value: '24', label: '24 小时刻度', title: '缩略图显示一天' }, { value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' }, ]} value={String(scale) as '24' | '48'} onChange={(v) => onScale(v === '48' ? 48 : 24)} />
) }