feat: 自定义SearchSelect组件,支持下拉+模糊搜索+iOS兼容
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增SearchSelect组件:输入框可打字模糊过滤,下拉列表点击选择 - 客户名称、业务负责人、车型名称、车牌号码使用SearchSelect - 短选项列表(区域/城市/品牌/部门/业务员)保持原生select - 点击外部自动关闭下拉,已选中项高亮,无匹配显示提示 - iOS Safari完全兼容(不依赖datalist) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
102
src/App.tsx
102
src/App.tsx
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Truck,
|
||||
Warehouse,
|
||||
@@ -35,6 +35,76 @@ import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup,
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
|
||||
import type { WeeklyDetailItem } from './api';
|
||||
|
||||
// --- SearchSelect Component ---
|
||||
function SearchSelect({ value, onChange, options, placeholder, className }: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: string[];
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return options;
|
||||
const q = query.toLowerCase();
|
||||
return options.filter((o) => o.toLowerCase().includes(q));
|
||||
}, [options, query]);
|
||||
|
||||
const displayValue = value || '';
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div
|
||||
className={`flex items-center bg-white border border-gray-200 rounded-lg shadow-sm cursor-pointer transition-all focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:border-blue-500 ${className || 'text-xs py-1.5 px-2'}`}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 outline-none bg-transparent min-w-0 text-inherit"
|
||||
placeholder={displayValue || placeholder}
|
||||
value={open ? query : displayValue}
|
||||
onChange={(e) => { setQuery(e.target.value); if (!open) setOpen(true); }}
|
||||
onFocus={() => { setOpen(true); setQuery(''); }}
|
||||
/>
|
||||
<ChevronDown size={14} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-xl max-h-48 overflow-auto">
|
||||
<div
|
||||
className="px-2 py-1.5 text-xs text-gray-400 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => { onChange(''); setQuery(''); setOpen(false); }}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
{filtered.map((o) => (
|
||||
<div
|
||||
key={o}
|
||||
className={`px-2 py-1.5 text-xs cursor-pointer hover:bg-blue-50 transition-colors ${o === value ? 'bg-blue-50 text-blue-600 font-bold' : 'text-gray-700'}`}
|
||||
onClick={() => { onChange(o); setQuery(''); setOpen(false); }}
|
||||
>
|
||||
{o}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-2 py-3 text-xs text-gray-400 text-center">无匹配项</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
const TABS = [
|
||||
{ id: 'overview', label: '总览' },
|
||||
@@ -983,10 +1053,7 @@ export default function App() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">车型名称</label>
|
||||
<select value={inventoryFilters.model} onChange={(e) => setInventoryFilters({...inventoryFilters, model: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部车型</option>
|
||||
{uniqueInventoryModels.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<SearchSelect value={inventoryFilters.model} onChange={(v) => setInventoryFilters({...inventoryFilters, model: v})} options={uniqueInventoryModels} placeholder="全部车型" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -1872,10 +1939,7 @@ export default function App() {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">客户名称</label>
|
||||
<select className="w-full bg-white border border-gray-200 rounded-lg py-2 px-2 text-xs focus:ring-2 focus:ring-slate-500/20 focus:border-slate-500 outline-none transition-all cursor-pointer shadow-sm" value={regionFilters.customer} onChange={(e) => setRegionFilters(prev => ({ ...prev, customer: e.target.value }))}>
|
||||
<option value="">所有客户</option>
|
||||
{uniqueCustomerNames.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<SearchSelect value={regionFilters.customer} onChange={(v) => setRegionFilters(prev => ({ ...prev, customer: v }))} options={uniqueCustomerNames} placeholder="所有客户" className="text-xs py-2 px-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -2180,18 +2244,12 @@ export default function App() {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">客户名称</label>
|
||||
<select className="w-full bg-white border border-gray-200 rounded-lg py-2 px-2 text-xs focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 outline-none transition-all cursor-pointer shadow-sm" value={customerFilters.customer} onChange={(e) => setCustomerFilters(prev => ({ ...prev, customer: e.target.value }))}>
|
||||
<option value="">所有客户</option>
|
||||
{uniqueCustomerNames.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<SearchSelect value={customerFilters.customer} onChange={(v) => setCustomerFilters(prev => ({ ...prev, customer: v }))} options={uniqueCustomerNames} placeholder="所有客户" className="text-xs py-2 px-2" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">业务负责人</label>
|
||||
<select className="w-full bg-white border border-gray-200 rounded-lg py-2 px-2 text-xs focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 outline-none transition-all cursor-pointer shadow-sm" value={customerFilters.manager} onChange={(e) => setCustomerFilters(prev => ({ ...prev, manager: e.target.value }))}>
|
||||
<option value="">所有负责人</option>
|
||||
{uniqueCustomerManagers.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<SearchSelect value={customerFilters.manager} onChange={(v) => setCustomerFilters(prev => ({ ...prev, manager: v }))} options={uniqueCustomerManagers} placeholder="所有负责人" className="text-xs py-2 px-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -2466,10 +2524,7 @@ export default function App() {
|
||||
{/* Quick Search always visible when collapsed */}
|
||||
{!isModalFilterExpanded && (
|
||||
<div className="w-40 sm:w-64" onClick={(e) => e.stopPropagation()}>
|
||||
<select value={modalFilters.plateNumber} onChange={(e) => setModalFilters({...modalFilters, plateNumber: e.target.value})} className="w-full text-[11px] px-2 py-1 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">快速搜索车牌...</option>
|
||||
{uniqueModalPlates.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="快速搜索车牌..." className="text-[11px] py-1 px-2" />
|
||||
</div>
|
||||
)}
|
||||
<motion.div
|
||||
@@ -2492,10 +2547,7 @@ export default function App() {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3 border-t border-gray-100 mt-1">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">车牌号码</label>
|
||||
<select value={modalFilters.plateNumber} onChange={(e) => setModalFilters({...modalFilters, plateNumber: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部车牌</option>
|
||||
{uniqueModalPlates.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="全部车牌" className="text-[11px] py-1.5 px-2" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">车型型号</label>
|
||||
|
||||
Reference in New Issue
Block a user