- 品牌: 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 未翻译、词典双逗号等
75 lines
5.3 KiB
TypeScript
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>
|
|
}
|