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.
181 lines
7.8 KiB
TypeScript
181 lines
7.8 KiB
TypeScript
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>
|
||
)
|
||
}
|