Files
MoShiro/web/display/src/ranges.tsx
T
justme 14b0de3544 feat: node info, build info and acrylic web theme
Squash of recent dev work: return_session login, backend switcher, line
detail timeline, overview strips, node platform/country/ISP info with flags
and icons, build info (version, tag, time) behind site.show_build, and the
acrylic display theme.
2026-10-06 21:24:25 +08:00

181 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLInputElement>(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 (
<div className="range-picker">
<div className="row wrap">
<span className="row" style={{ gap: 4 }}>
<button type="button" className="btn icon small" disabled={dayStart <= firstDay} onClick={() => onChange({ dayStart: step(-1), window: null })} aria-label="前一天">
<ChevronLeft size={16} />
</button>
<button type="button" className="day-pick" onClick={() => picker.current?.showPicker?.()}>
{fmtDayLabel(dayStart, nowMs)}
<input
ref={picker}
type="date"
value={dayKey(dayStart)}
min={dayKey(firstDay)}
max={dayKey(today)}
onChange={(e) => {
const picked = parseDay(e.target.value)
if (picked !== null) onChange({ dayStart: Math.min(Math.max(picked, firstDay), today), window: null })
}}
aria-hidden="true"
tabIndex={-1}
/>
</button>
<button type="button" className="btn icon small" disabled={dayStart >= today} onClick={() => onChange({ dayStart: step(1), window: null })} aria-label="后一天">
<ChevronRight size={16} />
</button>
</span>
{dayStart !== today ? (
<button type="button" className="btn small" onClick={() => onChange({ dayStart: today, window: null })}>
回到今天
</button>
) : null}
</div>
{children}
<div className="row wrap" style={{ marginTop: 8, justifyContent: 'space-between' }}>
<Segmented
options={WINDOWS.map((w) => {
const ok = available.includes(w.value)
return { value: w.value, label: w.label, disabled: !ok, title: ok ? undefined : '这一天的数据没有这么长' }
})}
value={active}
onChange={onWindow}
/>
<Segmented<'24' | '48'>
options={[
{ value: '24', label: '24 小时刻度', title: '缩略图显示一天' },
{ value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' },
]}
value={String(scale) as '24' | '48'}
onChange={(v) => onScale(v === '48' ? 48 : 24)}
/>
</div>
</div>
)
}