Files
LokiStack/frontend/src/pages/logs/components/TimeRangePicker.tsx
T
opsx f3733f0bfc feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂
- i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏
- 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条
- 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器
- UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正
- 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简
- 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
2026-10-27 17:11:38 +08:00

75 lines
5.3 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CalendarRange, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getUser } from '@/lib/auth-storage'
// 预设时间范围:unit 决定文案键(minutes/hours),count 用于插值与复数
const presets = [
{ unit: 'minutes', count: 5, ms: 300000 },
{ unit: 'minutes', count: 15, ms: 900000 },
{ unit: 'hours', count: 1, ms: 3600000 },
{ unit: 'hours', count: 6, ms: 21600000 },
{ unit: 'hours', count: 24, ms: 86400000 },
] as const
const localTime = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
const historyKey = () => `lokistack.logs.range-history.${getUser()?.id ?? 'anonymous'}`
type SavedRange = { start: string; end: string }
function readHistory(): SavedRange[] {
try {
const value: unknown = JSON.parse(localStorage.getItem(historyKey()) ?? '[]')
return Array.isArray(value) ? value.filter((item): item is SavedRange => typeof item?.start === 'string' && typeof item?.end === 'string').slice(0, 5) : []
} catch { return [] }
}
export function TimeRangePicker({ start, end, onChange }: { start: string; end: string; onChange: (start: string, end: string) => void }) {
const { t } = useTranslation('logs')
const [open, setOpen] = useState(false)
const [draftStart, setDraftStart] = useState(start)
const [draftEnd, setDraftEnd] = useState(end)
const [history, setHistory] = useState(readHistory)
const [selection, setSelection] = useState({ start, end, ms: 300000 as number | null })
const root = useRef<HTMLDivElement>(null)
useEffect(() => {
const close = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false) }
document.addEventListener('pointerdown', close)
return () => document.removeEventListener('pointerdown', close)
}, [])
function choose(nextStart: string, nextEnd: string, nextPreset: number | null = null) {
if (!(new Date(nextStart).getTime() < new Date(nextEnd).getTime())) return
setSelection({ start: nextStart, end: nextEnd, ms: nextPreset })
onChange(nextStart, nextEnd)
setDraftStart(nextStart); setDraftEnd(nextEnd); setOpen(false)
const next = [{ start: nextStart, end: nextEnd }, ...history.filter(item => item.start !== nextStart || item.end !== nextEnd)].slice(0, 5)
setHistory(next)
try { localStorage.setItem(historyKey(), JSON.stringify(next)) } catch { /* Storage may be unavailable. */ }
}
const presetLabel = (item: (typeof presets)[number]) => item.unit === 'minutes'
? t('range.lastMinutes', { count: item.count })
: t('range.lastHours', { count: item.count })
const activePreset = selection.start === start && selection.end === end ? presets.find(item => item.ms === selection.ms) : undefined
const label = activePreset ? presetLabel(activePreset) : t('range.custom')
const rangeText = `${new Date(start).toLocaleString()} ~ ${new Date(end).toLocaleString()}`
return <div ref={root} className="relative min-w-0">
<Button type="button" variant="outline" aria-expanded={open} className="h-9 max-w-full gap-2" onClick={() => { setDraftStart(start); setDraftEnd(end); setOpen(!open) }}>
<CalendarRange className="size-4 shrink-0" /><span className="shrink-0 font-medium">{label}</span><span className="hidden truncate font-normal text-muted-foreground xl:inline">{rangeText}</span><ChevronDown className="size-4 shrink-0" />
</Button>
{open && <div className="absolute left-0 top-full z-40 mt-1 w-[min(92vw,520px)] rounded-lg border bg-popover p-4 shadow-xl">
<h2 className="text-sm font-medium">{t('range.title')}</h2>
<div className="mt-3 grid grid-cols-2 gap-1 sm:grid-cols-3">{presets.map(item => <Button key={item.ms} type="button" size="sm" variant={activePreset?.ms === item.ms ? 'secondary' : 'ghost'} className="justify-start" onClick={() => { const now = Date.now(); choose(localTime(new Date(now - item.ms)), localTime(new Date(now)), item.ms) }}>{presetLabel(item)}</Button>)}</div>
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
<label className="text-xs text-muted-foreground">{t('range.start')}<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftStart} onChange={event => setDraftStart(event.target.value)} /></label>
<label className="text-xs text-muted-foreground">{t('range.end')}<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftEnd} onChange={event => setDraftEnd(event.target.value)} /></label>
</div>
<div className="mt-2 flex justify-end"><Button type="button" size="sm" disabled={!(new Date(draftStart).getTime() < new Date(draftEnd).getTime())} onClick={() => choose(draftStart, draftEnd)}>{t('range.apply')}</Button></div>
{history.length > 0 && <div className="mt-3 border-t pt-3"><h3 className="mb-1 text-xs font-medium text-muted-foreground">{t('range.recent')}</h3><div className="max-h-32 space-y-0.5 overflow-y-auto">{history.map(item => <button type="button" key={`${item.start}-${item.end}`} className="block w-full truncate rounded px-2 py-1.5 text-left text-xs hover:bg-muted" onClick={() => choose(item.start, item.end)}>{new Date(item.start).toLocaleString()} ~ {new Date(item.end).toLocaleString()}</button>)}</div></div>}
</div>}
</div>
}