mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat(search): expand default search scope and support specific field filtering
This commit is contained in:
@@ -8,7 +8,7 @@ export function FilterResetButton() {
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const { t } = useTranslation()
|
||||
const { q, ...restFilters } = filter;
|
||||
|
||||
|
||||
const activeFiltersCount = Object.keys(restFilters).filter(key => {
|
||||
const value = restFilters[key];
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
@@ -19,7 +19,7 @@ export function FilterResetButton() {
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setFilter(q ? { q } : {})}
|
||||
className={cn(
|
||||
|
||||
@@ -32,10 +32,10 @@ export function DataTableToolbar<TData>({
|
||||
<TagFilterPopover />
|
||||
<TimePopover />
|
||||
<MoreFiltersPopover />
|
||||
<FilterResetButton />
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-auto lg:ml-0">
|
||||
<DataTableViewOptions table={table} />
|
||||
<FilterResetButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,63 +1,61 @@
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Search, X, Clock, Trash2, Info } from "lucide-react"
|
||||
import { Search, X, Clock, Trash2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useSearchContext } from "./context"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
const STORAGE_KEY = "mail_search_history"
|
||||
const STORAGE_KEY = "bichon_mail_search_history"
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
type SearchField = "text" | "subject" | "body"
|
||||
const SEARCH_FIELDS: SearchField[] = ["text", "subject", "body"]
|
||||
|
||||
export function TextSearchInput() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [value, setValue] = useState(filter.text || "")
|
||||
|
||||
const [value, setValue] = useState("")
|
||||
const [field, setField] = useState<SearchField>("text")
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const activeField = SEARCH_FIELDS.find(key => !!filter[key]) || "text"
|
||||
const activeValue = filter[activeField] as string || ""
|
||||
|
||||
setField(activeField)
|
||||
setValue(activeValue)
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
setHistory(JSON.parse(saved))
|
||||
}
|
||||
if (saved) setHistory(JSON.parse(saved))
|
||||
} catch (err) {
|
||||
console.warn("Failed to load search history", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setValue(filter.text || "")
|
||||
}, [filter.text])
|
||||
const applyFilter = (currentField: SearchField, searchTerm: string) => {
|
||||
const trimmed = searchTerm.trim()
|
||||
|
||||
|
||||
const saveToHistory = (term: string) => {
|
||||
if (!term.trim()) return
|
||||
|
||||
setHistory(prev => {
|
||||
const trimmed = term.trim()
|
||||
const withoutCurrent = prev.filter(item => item !== trimmed)
|
||||
const newHistory = [trimmed, ...withoutCurrent].slice(0, MAX_HISTORY)
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory))
|
||||
} catch (err) {
|
||||
console.warn("Failed to save search history", err)
|
||||
setFilter((prev) => {
|
||||
const next = { ...prev }
|
||||
SEARCH_FIELDS.forEach(f => {
|
||||
delete next[f]
|
||||
})
|
||||
if (trimmed) {
|
||||
next[currentField] = trimmed
|
||||
}
|
||||
|
||||
return newHistory
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
const trimmed = value.trim()
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
text: trimmed || undefined
|
||||
}))
|
||||
if (trimmed) {
|
||||
saveToHistory(trimmed)
|
||||
}
|
||||
@@ -65,37 +63,31 @@ export function TextSearchInput() {
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSearch()
|
||||
}
|
||||
const saveToHistory = (term: string) => {
|
||||
setHistory((prev) => {
|
||||
const trimmed = term.trim()
|
||||
const newHistory = [trimmed, ...prev.filter((item) => item !== trimmed)].slice(0, MAX_HISTORY)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory))
|
||||
return newHistory
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearch = () => applyFilter(field, value)
|
||||
|
||||
const handleClear = () => {
|
||||
setValue("")
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.text
|
||||
return next
|
||||
})
|
||||
setShowHistory(false)
|
||||
applyFilter(field, "")
|
||||
}
|
||||
|
||||
const handleSelectHistory = (term: string) => {
|
||||
setValue(term)
|
||||
setShowHistory(false)
|
||||
// 如果需要点击历史立即搜索,可以在这里调用 handleSearch()
|
||||
applyFilter(field, term)
|
||||
}
|
||||
|
||||
const handleClearHistory = () => {
|
||||
const handleClearHistory = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setHistory([])
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch (err) {
|
||||
console.warn("Failed to clear search history", err)
|
||||
}
|
||||
setShowHistory(false)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -108,91 +100,107 @@ export function TextSearchInput() {
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const isActive = !!filter.text?.trim()
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full max-w-[550px] min-w-[280px]">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setShowHistory(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('search_input.placeholder')}
|
||||
className={cn(
|
||||
"h-9 pl-9 pr-9 text-sm",
|
||||
isActive && "border-primary/50 focus-visible:ring-primary/30"
|
||||
)}
|
||||
/>
|
||||
{value && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<div ref={containerRef} className="relative w-full max-w-[620px] min-w-[320px]">
|
||||
<div className="flex items-center rounded-md border bg-background focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/30 transition-all">
|
||||
<Select
|
||||
value={field}
|
||||
onValueChange={(val) => {
|
||||
const newField = val as SearchField
|
||||
setField(newField)
|
||||
if (value.trim()) applyFilter(newField, value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-9 w-[110px] md:w-[130px] border-r border-border rounded-r-none",
|
||||
"text-xs md:text-sm bg-transparent focus:ring-0 focus:ring-offset-0 shadow-none border-y-0 border-l-0"
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 px-5"
|
||||
onClick={handleSearch}
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
{t('search_input.button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索范围提示 */}
|
||||
<div className="flex items-center gap-1 px-1 opacity-60">
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t('search_input.hint')}
|
||||
</span>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[240px]">
|
||||
<SelectItem value="text" className="font-medium cursor-pointer text-xs">
|
||||
{t("search_input.all")}
|
||||
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
|
||||
{t("search_input.all_fields_desc")}
|
||||
</p>
|
||||
</SelectItem>
|
||||
<SelectItem value="subject" className="cursor-pointer text-xs">
|
||||
{t("search_input.subject")}
|
||||
</SelectItem>
|
||||
<SelectItem value="body" className="cursor-pointer text-xs">
|
||||
{t("search_input.body")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setShowHistory(true)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder={t("search_input.placeholder")}
|
||||
className="h-9 border-none shadow-none focus-visible:ring-0 pl-9 pr-10 text-sm bg-transparent w-full"
|
||||
/>
|
||||
{value && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 mr-1.5 px-3 text-xs md:px-5 md:text-sm"
|
||||
onClick={handleSearch}
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
{t("search_input.button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showHistory && (
|
||||
<div className="absolute top-10 left-0 w-full mt-1 bg-popover border rounded-md shadow-md z-50 max-h-[280px] overflow-auto">
|
||||
<div className="py-1.5 px-3 text-xs text-muted-foreground font-medium border-b flex items-center justify-between sticky top-0 bg-popover z-10">
|
||||
<div className="absolute top-full left-0 w-full mt-1 bg-popover border rounded-md shadow-lg z-50 max-h-[300px] overflow-hidden flex flex-col">
|
||||
<div className="py-2 px-3 text-[10px] uppercase tracking-wider text-muted-foreground font-semibold border-b flex items-center justify-between bg-muted/30">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t('search_input.recent_title')}
|
||||
{t("search_input.recent_title")}
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearHistory}
|
||||
className="text-xs text-destructive hover:text-destructive/80 flex items-center gap-1 hover:underline"
|
||||
className="text-destructive hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t('search_input.clear_history')}
|
||||
{t("search_input.clear_history")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.length > 0 ? (
|
||||
history.map((term, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors flex items-center gap-2"
|
||||
onClick={() => handleSelectHistory(term)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{term}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-4 text-xs text-center text-muted-foreground">
|
||||
{t('search_input.no_history')}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-auto py-1">
|
||||
{history.length > 0 ? (
|
||||
history.map((term, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 group"
|
||||
onClick={() => handleSelectHistory(term)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground group-hover:text-primary" />
|
||||
<span className="truncate flex-1">{term}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-6 text-sm text-center text-muted-foreground">
|
||||
{t("search_input.no_history")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user