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 {
|
import {
|
||||||
Truck,
|
Truck,
|
||||||
Warehouse,
|
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 { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
|
||||||
import type { WeeklyDetailItem } 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 ---
|
// --- Constants ---
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'overview', label: '总览' },
|
{ id: 'overview', label: '总览' },
|
||||||
@@ -983,10 +1053,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-slate-400 block mb-1">车型名称</label>
|
<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">
|
<SearchSelect value={inventoryFilters.model} onChange={(v) => setInventoryFilters({...inventoryFilters, model: v})} options={uniqueInventoryModels} placeholder="全部车型" />
|
||||||
<option value="">全部车型</option>
|
|
||||||
{uniqueInventoryModels.map(m => <option key={m} value={m}>{m}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -1872,10 +1939,7 @@ export default function App() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">客户名称</label>
|
<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 }))}>
|
<SearchSelect value={regionFilters.customer} onChange={(v) => setRegionFilters(prev => ({ ...prev, customer: v }))} options={uniqueCustomerNames} placeholder="所有客户" className="text-xs py-2 px-2" />
|
||||||
<option value="">所有客户</option>
|
|
||||||
{uniqueCustomerNames.map(c => <option key={c} value={c}>{c}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">客户名称</label>
|
<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 }))}>
|
<SearchSelect value={customerFilters.customer} onChange={(v) => setCustomerFilters(prev => ({ ...prev, customer: v }))} options={uniqueCustomerNames} placeholder="所有客户" className="text-xs py-2 px-2" />
|
||||||
<option value="">所有客户</option>
|
|
||||||
{uniqueCustomerNames.map(c => <option key={c} value={c}>{c}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">业务负责人</label>
|
<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 }))}>
|
<SearchSelect value={customerFilters.manager} onChange={(v) => setCustomerFilters(prev => ({ ...prev, manager: v }))} options={uniqueCustomerManagers} placeholder="所有负责人" className="text-xs py-2 px-2" />
|
||||||
<option value="">所有负责人</option>
|
|
||||||
{uniqueCustomerManagers.map(m => <option key={m} value={m}>{m}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -2466,10 +2524,7 @@ export default function App() {
|
|||||||
{/* Quick Search always visible when collapsed */}
|
{/* Quick Search always visible when collapsed */}
|
||||||
{!isModalFilterExpanded && (
|
{!isModalFilterExpanded && (
|
||||||
<div className="w-40 sm:w-64" onClick={(e) => e.stopPropagation()}>
|
<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">
|
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="快速搜索车牌..." className="text-[11px] py-1 px-2" />
|
||||||
<option value="">快速搜索车牌...</option>
|
|
||||||
{uniqueModalPlates.map(p => <option key={p} value={p}>{p}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<motion.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="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3 border-t border-gray-100 mt-1">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="block text-[10px] text-gray-500 font-medium">车牌号码</label>
|
<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">
|
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="全部车牌" className="text-[11px] py-1.5 px-2" />
|
||||||
<option value="">全部车牌</option>
|
|
||||||
{uniqueModalPlates.map(p => <option key={p} value={p}>{p}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="block text-[10px] text-gray-500 font-medium">车型型号</label>
|
<label className="block text-[10px] text-gray-500 font-medium">车型型号</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user