191 lines
7.6 KiB
TypeScript
191 lines
7.6 KiB
TypeScript
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||
import { useMemo } from 'react'
|
||
import { Segmented } from '@shared/ui'
|
||
|
||
const MIN = 60_000
|
||
const DAY = 24 * 60 * MIN
|
||
|
||
/** 线路详情页的时间范围:只提供这条线路“有数据”的档位。 */
|
||
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']
|
||
|
||
/** 进入某个月之后的切片:整月,或者从月初起按固定长度翻页。 */
|
||
export const SLICES = [
|
||
{ value: 'all', label: '整月', ms: 0 },
|
||
{ value: '14d', label: '14 天', ms: 14 * DAY },
|
||
{ value: '7d', label: '7 天', ms: 7 * DAY },
|
||
{ value: '3d', label: '3 天', ms: 3 * DAY },
|
||
{ value: '1d', label: '1 天', ms: DAY },
|
||
] as const
|
||
|
||
export type SliceValue = (typeof SLICES)[number]['value']
|
||
|
||
/** 数据不足整档时允许 10% 的误差:29 分钟的数据算得上“30 分钟”。至少保留最小的一档。 */
|
||
const TOLERANCE = 0.9
|
||
|
||
export function availablePresets(spanMs: number | null) {
|
||
if (spanMs === null) return [PRESETS[0]]
|
||
const list = PRESETS.filter((p) => spanMs >= p.ms * TOLERANCE)
|
||
return list.length > 0 ? list : [PRESETS[0]]
|
||
}
|
||
|
||
/** 默认选“有数据的最大档”,但最长到 1 天(再长的范围图会很挤,用户可以自己点)。 */
|
||
export function defaultPreset(spanMs: number | null): PresetValue {
|
||
const list = availablePresets(spanMs).filter((p) => p.ms <= DAY)
|
||
return list[list.length - 1]?.value ?? '5m'
|
||
}
|
||
|
||
export interface MonthInfo {
|
||
key: string // YYYY-MM
|
||
label: string
|
||
start: number
|
||
end: number // 不含
|
||
}
|
||
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
|
||
export function monthOf(ms: number): MonthInfo {
|
||
const d = new Date(ms)
|
||
const start = new Date(d.getFullYear(), d.getMonth(), 1).getTime()
|
||
const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()
|
||
return { key: `${d.getFullYear()}-${pad(d.getMonth() + 1)}`, label: `${d.getFullYear()} 年 ${d.getMonth() + 1} 月`, start, end }
|
||
}
|
||
|
||
/** 从有数据的第一个月到当前月,新的在前。 */
|
||
export function monthsWithData(sinceMs: number | null, nowMs: number): MonthInfo[] {
|
||
if (sinceMs === null) return []
|
||
const months: MonthInfo[] = []
|
||
let cursor = monthOf(sinceMs)
|
||
const last = monthOf(nowMs)
|
||
while (cursor.start <= last.start) {
|
||
months.push(cursor)
|
||
cursor = monthOf(cursor.end)
|
||
}
|
||
return months.reverse()
|
||
}
|
||
|
||
export interface RangeState {
|
||
/** 没有 month 就是“最近一段”。 */
|
||
preset: PresetValue
|
||
month: string | null
|
||
slice: SliceValue
|
||
page: number
|
||
}
|
||
|
||
export interface ResolvedRange {
|
||
query: { range?: string; from?: string; to?: string; anchor?: string }
|
||
/** 主机历史只支持 ≥ 1 小时的 range,这里给一个对应的值。 */
|
||
hostRange: string
|
||
label: string
|
||
}
|
||
|
||
const fmtDay = (ms: number) => {
|
||
const d = new Date(ms)
|
||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||
}
|
||
|
||
export function resolveRange(state: RangeState, months: MonthInfo[], nowMs: number): ResolvedRange {
|
||
if (!state.month) {
|
||
const hostRange = state.preset === '5m' || state.preset === '15m' || state.preset === '30m' ? '1h' : state.preset
|
||
return { query: { range: state.preset, anchor: 'latest' }, hostRange, label: PRESETS.find((p) => p.value === state.preset)?.label ?? state.preset }
|
||
}
|
||
const month = months.find((m) => m.key === state.month) ?? monthOf(nowMs)
|
||
const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0]
|
||
const end = Math.min(month.end, nowMs)
|
||
let from = month.start
|
||
let to = end
|
||
if (slice.ms > 0) {
|
||
from = month.start + state.page * slice.ms
|
||
to = Math.min(from + slice.ms, end)
|
||
}
|
||
const spanDays = Math.max(1, Math.round((to - from) / DAY))
|
||
return {
|
||
query: { from: new Date(from).toISOString(), to: new Date(to).toISOString() },
|
||
hostRange: spanDays >= 30 ? '30d' : `${Math.max(1, spanDays)}d`,
|
||
label: slice.ms > 0 ? `${month.label} ${fmtDay(from)} – ${fmtDay(to - 1)}` : month.label,
|
||
}
|
||
}
|
||
|
||
/** 一个月里按固定长度能翻几页。 */
|
||
function pagesIn(month: MonthInfo, sliceMs: number, nowMs: number, sinceMs: number | null): { first: number; last: number } {
|
||
const start = Math.max(month.start, sinceMs === null ? month.start : sinceMs - (sinceMs % DAY))
|
||
const end = Math.min(month.end, nowMs)
|
||
return { first: Math.max(0, Math.floor((start - month.start) / sliceMs)), last: Math.max(0, Math.ceil((end - month.start) / sliceMs) - 1) }
|
||
}
|
||
|
||
interface Props {
|
||
state: RangeState
|
||
onChange: (next: RangeState) => void
|
||
sinceMs: number | null
|
||
nowMs: number
|
||
}
|
||
|
||
export function RangePicker({ state, onChange, sinceMs, nowMs }: Props) {
|
||
const spanMs = sinceMs === null ? null : nowMs - sinceMs
|
||
const presets = useMemo(() => availablePresets(spanMs), [spanMs])
|
||
const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs])
|
||
const month = state.month ? months.find((m) => m.key === state.month) : undefined
|
||
const monthLength = month ? Math.min(month.end, nowMs) - Math.max(month.start, sinceMs ?? month.start) : 0
|
||
const slices = SLICES.filter((s) => s.ms === 0 || monthLength >= s.ms * TOLERANCE)
|
||
const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0]
|
||
const pages = month && slice.ms > 0 ? pagesIn(month, slice.ms, nowMs, sinceMs) : undefined
|
||
|
||
return (
|
||
<div className="range-picker">
|
||
<div className="row wrap">
|
||
<Segmented
|
||
options={PRESETS.map((p) => {
|
||
const ok = presets.some((x) => x.value === p.value)
|
||
return { value: p.value, label: p.label, disabled: !ok, title: ok ? undefined : '这条线路的数据还没有这么长' }
|
||
})}
|
||
value={state.month ? ('' as PresetValue) : state.preset}
|
||
onChange={(v) => onChange({ ...state, preset: v, month: null, page: 0 })}
|
||
/>
|
||
{months.length > 0 ? (
|
||
<select
|
||
className="select"
|
||
style={{ width: 'auto' }}
|
||
disabled={spanMs === null || spanMs < DAY}
|
||
title={spanMs !== null && spanMs >= DAY ? undefined : '数据超过 1 天之后可以按月查看'}
|
||
value={state.month ?? ''}
|
||
onChange={(e) => onChange({ ...state, month: e.target.value || null, slice: 'all', page: 0 })}
|
||
aria-label="按月查看"
|
||
>
|
||
<option value="">按月查看</option>
|
||
{months.map((m) => (
|
||
<option key={m.key} value={m.key}>
|
||
{m.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : null}
|
||
</div>
|
||
{month ? (
|
||
<div className="row wrap" style={{ marginTop: 12 }}>
|
||
<Segmented options={slices.map((s) => ({ value: s.value, label: s.label }))} value={state.slice} onChange={(v) => onChange({ ...state, slice: v, page: 0 })} />
|
||
{pages ? (
|
||
<span className="row" style={{ gap: 4 }}>
|
||
<button type="button" className="btn icon small" disabled={state.page <= pages.first} onClick={() => onChange({ ...state, page: state.page - 1 })} aria-label="上一段">
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
<button type="button" className="btn icon small" disabled={state.page >= pages.last} onClick={() => onChange({ ...state, page: state.page + 1 })} aria-label="下一段">
|
||
<ChevronRight size={16} />
|
||
</button>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|