Files
MoShiro/web/display/src/ranges.tsx
T
justme d1be70cda2 feat(web): 线路详情页的时间轴、EWMA 平滑和加载进度
- 先选日期,再在连续 48 小时(或 24 小时)的丢包缩略图上拖选区;选区可以跨零点,
  点轴或刻度时离得近的端点贴过去,悬停预览会动的端点;没有数据的时段画成斜纹
- 刻度下方的时长快捷档:30 分钟、1 小时、3 小时、6 小时、12 小时、整天
- 指标卡片平铺,丢包等标明 UDP/TCP;图表按 UDP、TCP、评分分组
- 图表数据刷新时原地更新(不再整张重建),毫秒到 1 秒以上显示成秒
- EWMA 平滑开关(关/轻/中/重),原始数据以淡线垫底
- 数据加载时卡片上显示进度条和占位;useQuery 区分定时刷新和用户触发的加载
- 前端可以在页面里指定后端地址和令牌(配合后端 Monitor__CorsOrigins)
2026-10-06 14:48:58 +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>
)
}