feat(search): expand default search scope and support specific field filtering

This commit is contained in:
rustmailer
2026-03-19 15:44:20 +08:00
parent 2228e98410
commit 884fdeba10
24 changed files with 236 additions and 150 deletions
+2 -2
View File
@@ -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(
+2 -2
View File
@@ -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>
+126 -118
View File
@@ -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>