feat(ui): 品类筛选框升级为可输入关键词过滤的 Combobox

【问题】
询价发布助手筛选工具栏的品类下拉框有 53+ 个选项,用户难以快速定位。

【改动】
- 把品类 filter 从统一 .map(select) 里拆出,改成自定义 Combobox:
  - input + 浮动下拉面板 (z-30)
  - 关键词过滤 (toLowerCase + includes 模糊匹配,中文友好)
  - 键盘导航: ↓/↑ 高亮 / Enter 选中 / Esc 关闭 / Backspace 空时清空筛选
  - 鼠标: hover 高亮 / click 选中 / click outside 关闭 (useEffect + ref)
  - 选中项右侧显示 ✓
  - 已选非默认时显示 × 清空按钮
- 其他 5 个 filter (公告日期/星级/甲方级别/来源平台/负责人) 保持原生 select 不变
- 状态: catInput / catOpen / catHighlightIdx / catRef,useMemo 缓存过滤结果

【体验】
- 输入'钢' → 立即显示 钢材/钢格栅 等候选
- 输入'电缆' → 显示 电线电缆
- 空 input + Enter → 选中'全部品类' 等价清空筛选
- 已选'钢材'显示在 placeholder + 右侧 × 按钮

【部署】
web 重新构建 + sync .next 到生产 + PM2 重启 pingtai-web
This commit is contained in:
Sisyphus 2026-07-01 14:18:13 +08:00
parent 1a92a3c095
commit a822d7782b

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { API_BASE } from '@/lib/apiBase' import { API_BASE } from '@/lib/apiBase'
interface Lead { interface Lead {
@ -115,6 +115,30 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
const [fLevel, setFLevel] = useState('全部级别') const [fLevel, setFLevel] = useState('全部级别')
const [fCat, setFCat] = useState('全部品类') const [fCat, setFCat] = useState('全部品类')
const [catOptions, setCatOptions] = useState<string[]>(['全部品类']) const [catOptions, setCatOptions] = useState<string[]>(['全部品类'])
const [catInput, setCatInput] = useState('')
const [catOpen, setCatOpen] = useState(false)
const [catHighlightIdx, setCatHighlightIdx] = useState(0)
const catRef = useRef<HTMLDivElement>(null)
const filteredCatOptions = useMemo(() => {
const q = catInput.trim().toLowerCase()
if (!q) return catOptions
return catOptions.filter((c) => c.toLowerCase().includes(q))
}, [catInput, catOptions])
useEffect(() => {
if (!catOpen) return
const handler = (e: MouseEvent) => {
if (catRef.current && !catRef.current.contains(e.target as Node)) setCatOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [catOpen])
useEffect(() => { setCatHighlightIdx(0) }, [catInput, catOpen])
const selectCat = (cat: string) => {
setFCat(cat)
setCatInput('')
setCatOpen(false)
setCatHighlightIdx(0)
}
const [fSource, setFSource] = useState('全部来源') const [fSource, setFSource] = useState('全部来源')
const [fPerson, setFPerson] = useState('全部人员') const [fPerson, setFPerson] = useState('全部人员')
@ -402,7 +426,6 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
{ label: '公告日期', value: fTime, set: setFTime, opts: ['全部日期', '今天', '昨天', '本周', '本月'] }, { label: '公告日期', value: fTime, set: setFTime, opts: ['全部日期', '今天', '昨天', '本周', '本月'] },
{ label: '星级', value: fStar, set: setFStar, opts: ['全部星级', '5星', '4星', '3星', '2星及以下'] }, { label: '星级', value: fStar, set: setFStar, opts: ['全部星级', '5星', '4星', '3星', '2星及以下'] },
{ label: '甲方级别', value: fLevel, set: setFLevel, opts: LEVEL_OPTIONS }, { label: '甲方级别', value: fLevel, set: setFLevel, opts: LEVEL_OPTIONS },
{ label: '品类', value: fCat, set: setFCat, opts: catOptions },
{ label: '来源平台', value: fSource, set: setFSource, opts: SOURCE_OPTIONS }, { label: '来源平台', value: fSource, set: setFSource, opts: SOURCE_OPTIONS },
{ label: '负责人', value: fPerson, set: setFPerson, opts: PERSON_OPTIONS }, { label: '负责人', value: fPerson, set: setFPerson, opts: PERSON_OPTIONS },
].map(f => ( ].map(f => (
@ -413,6 +436,54 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
</select> </select>
</div> </div>
))} ))}
<div ref={catRef} className="flex flex-col gap-1 relative">
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">品类</span>
<div className="relative">
<input
type="text"
value={catInput || (fCat === '全部品类' ? '' : fCat)}
placeholder={fCat === '全部品类' ? '输入关键词搜索品类' : fCat}
onFocus={() => setCatOpen(true)}
onChange={e => { setCatInput(e.target.value); setCatOpen(true) }}
onKeyDown={e => {
if (e.key === 'ArrowDown') { e.preventDefault(); setCatHighlightIdx(i => Math.min(filteredCatOptions.length - 1, i + 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setCatHighlightIdx(i => Math.max(0, i - 1)) }
else if (e.key === 'Enter') { e.preventDefault(); if (filteredCatOptions[catHighlightIdx]) selectCat(filteredCatOptions[catHighlightIdx]) }
else if (e.key === 'Escape') { setCatOpen(false); setCatInput('') }
else if (e.key === 'Backspace' && !catInput && fCat !== '全部品类') { e.preventDefault(); selectCat('全部品类') }
}}
className="bg-surface-container-low border border-border-subtle rounded pl-2 pr-6 py-1 text-xs w-32 focus:ring-secondary outline-none placeholder:text-slate-400"
/>
{fCat !== '全部品类' && (
<button
type="button"
onClick={() => selectCat('全部品类')}
className="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full text-[10px]"
title="清空筛选"
>×</button>
)}
</div>
{catOpen && (
<div className="absolute top-full left-0 mt-1 w-48 max-h-64 overflow-y-auto bg-white border border-border-subtle rounded-md shadow-lg z-30">
{filteredCatOptions.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-slate-400">无匹配品类</div>
) : (
filteredCatOptions.map((c, idx) => (
<button
key={c}
type="button"
onMouseEnter={() => setCatHighlightIdx(idx)}
onClick={() => selectCat(c)}
className={`w-full text-left px-3 py-1.5 text-xs flex items-center justify-between ${idx === catHighlightIdx ? 'bg-secondary text-white' : 'hover:bg-slate-50 text-slate-700'}`}
>
<span>{c}</span>
{fCat === c && <span className={idx === catHighlightIdx ? 'text-white' : 'text-secondary'}>✓</span>}
</button>
))
)}
</div>
)}
</div>
</div> </div>
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">
<span className="text-xs text-on-surface-variant">批量操作:</span> <span className="text-xs text-on-surface-variant">批量操作:</span>