feat(frontend): analysis/logs 筛选下拉改可搜索 Select

数据源换 ui/select;命名空间复用 SearchableValueSelect 工具条态(可搜、全部置顶、loading);与 ahead 功能 tip 分开。
This commit is contained in:
Codex committed 2026-10-28 22:02:45 +08:00
1 parent 11d0fff764
commit 016d3ecdfd
5 files changed
+197 -86

No files matched your search

@@ -1,68 +1 @@
import { useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input'
interface SearchableValueSelectProps {
label: string
values: string[]
value: string
onChange: (value: string) => void
placeholder?: string
allowEmpty?: boolean
loading?: boolean
disabled?: boolean
}
/** A searchable single-value combobox for Loki label values. */
export function SearchableValueSelect({ label, values, value, onChange, placeholder, allowEmpty = false, loading = false, disabled = false }: SearchableValueSelectProps) {
const { t } = useTranslation('logs')
const [open, setOpen] = useState(false)
const [filter, setFilter] = useState('')
const [active, setActive] = useState(0)
const root = useRef<HTMLDivElement>(null)
const id = useId()
const choices = values.filter(item => item.toLowerCase().includes(filter.toLowerCase()))
useEffect(() => {
const close = (event: MouseEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [])
function choose(next: string) {
onChange(next)
setFilter('')
setActive(0)
setOpen(false)
}
return <div ref={root} className="relative min-w-48">
<label className="block text-sm font-medium mb-1" htmlFor={id}>{label}</label>
<Input
id={id}
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={`${id}-options`}
disabled={disabled}
value={open ? filter : value}
placeholder={placeholder ?? t('select.placeholder')}
onFocus={() => { setFilter(''); setOpen(true) }}
onChange={event => { setFilter(event.target.value); setActive(0); setOpen(true) }}
onKeyDown={event => {
if (event.key === 'Escape') setOpen(false)
if (event.key === 'ArrowDown') { event.preventDefault(); setActive(current => Math.max(0, Math.min(current + 1, choices.length - 1))) }
if (event.key === 'ArrowUp') { event.preventDefault(); setActive(current => Math.max(0, current - 1)) }
if (event.key === 'Enter' && open && choices[active]) { event.preventDefault(); choose(choices[active]) }
}}
/>
{open && <div id={`${id}-options`} role="listbox" className="absolute z-30 mt-1 max-h-56 w-full overflow-auto rounded-md border bg-popover p-1 shadow-lg">
{allowEmpty && <button type="button" role="option" aria-selected={!value} className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted" onMouseDown={event => event.preventDefault()} onClick={() => choose('')}>{t('select.any')}</button>}
{loading ? <p className="px-2 py-1.5 text-sm text-muted-foreground">{t('select.loading')}</p> : choices.length === 0 ? <p className="px-2 py-1.5 text-sm text-muted-foreground">{t('select.noMatch')}</p> : choices.slice(0, 200).map((item, index) =>
<button key={item} type="button" role="option" aria-selected={item === value} className={`block w-full rounded px-2 py-1.5 text-left text-sm ${index === active ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`} onMouseDown={event => event.preventDefault()} onClick={() => choose(item)}>{item}</button>
)}
</div>}
</div>
}
export { SearchableValueSelect } from '@/components/SearchableValueSelect'
+9 -1
View File
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
import { useTranslation } from 'react-i18next'
import '@/i18n/domains'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { PageContainer } from '@/components/PageContainer'
import { TimeRangePicker } from '@/components/TimeRangePicker'
@@ -178,7 +179,14 @@ export default function LogsPage() {
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<select aria-label={t('toolbar.sourceAria')} className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm text-foreground" value={sourceId} onChange={event => setSourceId(Number(event.target.value))}>{sources.length === 0 && <option value={0}>{t('toolbar.sourceEmptyOption')}</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select>
<Select value={sourceId ? String(sourceId) : undefined} onValueChange={value => setSourceId(Number(value))} disabled={sources.length === 0}>
<SelectTrigger className="h-9 min-w-40" aria-label={t('toolbar.sourceAria')}>
<SelectValue placeholder={t('toolbar.sourceEmptyOption')} />
</SelectTrigger>
<SelectContent>
{sources.map(source => <SelectItem key={source.id} value={String(source.id)}>{source.name}</SelectItem>)}
</SelectContent>
</Select>
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
<label className="flex items-center gap-1 text-xs text-muted-foreground">{t('toolbar.limitLabel')}<Input type="number" min={1} max={1000} value={limit} onChange={event => setLimit(Math.min(1000, Math.max(1, Number(event.target.value))))} className="h-9 w-24" /></label>
</div>