From 3809e785c1f07ceb7635b4d37cfb66ecf1675eec Mon Sep 17 00:00:00 2001 From: kkfluous Date: Wed, 29 Apr 2026 15:19:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(mileage):=20=E5=A4=96=E9=83=A8=E4=B8=89?= =?UTF-8?q?=E9=80=89=E7=AD=9B=E9=80=89=20+=20=E8=BD=A6=E7=89=8C=E5=A4=9A?= =?UTF-8?q?=E9=80=89=E7=B2=98=E8=B4=B4=20+=20=E8=BF=90=E8=90=A5=E5=8C=BA?= =?UTF-8?q?=E5=9F=9F=20+=20xlsx=20=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 外部行改为 批次型号 / 运营区域 / 车牌多选;按部门、按客户移入详情面板 - 车牌多选支持从 Excel 粘贴(换行/逗号/空格分隔),未匹配项显示警告 - 新增运营区域筛选:基于 136 批次区域映射(华东/华南/西南/西北) - 新增 xlsx 数据下载,导出当前筛选结果(带表头样式与列宽) Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 110 +++++++++++- package.json | 3 +- src/modules/mileage/MonitoringView.tsx | 176 ++++++++++++------- src/modules/mileage/PlateMultiSelect.tsx | 198 ++++++++++++++++++++++ src/modules/mileage/api.ts | 2 + src/modules/mileage/types.ts | 2 + src/modules/mileage/xlsx-export.ts | 81 +++++++++ src/server/routes/mileage/cache.ts | 19 ++- src/server/routes/mileage/monitoring.ts | 11 +- src/server/routes/mileage/region-map.json | 138 +++++++++++++++ src/server/routes/mileage/types.ts | 2 + 11 files changed, 670 insertions(+), 72 deletions(-) create mode 100644 src/modules/mileage/PlateMultiSelect.tsx create mode 100644 src/modules/mileage/xlsx-export.ts create mode 100644 src/server/routes/mileage/region-map.json diff --git a/package-lock.json b/package-lock.json index 9d2fe6f..4b27993 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ln-bi", - "version": "1.1.0", + "version": "1.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ln-bi", - "version": "1.1.0", + "version": "1.1.5", "dependencies": { "@hono/node-server": "^1.13.0", "@types/jsonwebtoken": "^9.0.10", @@ -19,7 +19,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^3.8.1", - "tsx": "^4.21.0" + "tsx": "^4.21.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/vite": "^4.1.14", @@ -1657,6 +1658,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1766,6 +1776,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1820,6 +1843,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1872,6 +1904,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2177,6 +2221,15 @@ } } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/framer-motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", @@ -3209,6 +3262,18 @@ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3973,6 +4038,24 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3991,6 +4074,27 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 58d50c7..a0f5484 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^3.8.1", - "tsx": "^4.21.0" + "tsx": "^4.21.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/vite": "^4.1.14", diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index 194e058..9ba4af9 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -1,13 +1,15 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { - Truck, Search, Filter, ChevronDown, + Truck, Filter, ChevronDown, Maximize2, Minimize2, RotateCcw, - ArrowUp, ArrowDown, ChevronsUp, + ArrowUp, ArrowDown, ChevronsUp, Download, } from 'lucide-react'; import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types'; import { fetchMonitoring } from './api'; import Blur from '../../components/Blur'; +import PlateMultiSelect from './PlateMultiSelect'; +import { exportMileageXlsx } from './xlsx-export'; const SearchableSelect = ({ options, @@ -102,15 +104,17 @@ export default function MonitoringView() { const [fullscreenLoading, setFullscreenLoading] = useState(false); // New filters from image - const [filterPlate, setFilterPlate] = useState('All'); + const [filterPlates, setFilterPlates] = useState([]); const [filterCustomer, setFilterCustomer] = useState('All'); const [filterProject, setFilterProject] = useState('All'); const [filterEntity, setFilterEntity] = useState('All'); const [filterRentStatus, setFilterRentStatus] = useState('All'); const [filterPlatePrefix, setFilterPlatePrefix] = useState('All'); const [filterTargetName, setFilterTargetName] = useState('All'); + const [filterRegion, setFilterRegion] = useState('All'); const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' }); const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' }); + const [exporting, setExporting] = useState(false); const [filterDate, setFilterDate] = useState(() => { const now = new Date(); if (now.getHours() < 5) now.setDate(now.getDate() - 1); @@ -119,7 +123,7 @@ export default function MonitoringView() { const [vehicles, setVehicles] = useState([]); const [stats, setStats] = useState({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }); - const [filterOptions, setFilterOptions] = useState({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] }); + const [filterOptions, setFilterOptions] = useState({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] }); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); @@ -147,7 +151,8 @@ export default function MonitoringView() { rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, targetName: filterTargetName !== 'All' ? filterTargetName : undefined, - plate: filterPlate !== 'All' ? filterPlate : undefined, + region: filterRegion !== 'All' ? filterRegion : undefined, + plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, mileageMin: appliedMileageRange.min || undefined, mileageMax: appliedMileageRange.max || undefined, date: filterDate || undefined, @@ -159,7 +164,7 @@ export default function MonitoringView() { setPage(1); setHasMore(d.page < d.totalPages); }).catch(() => {}).finally(() => setPageLoading(false)); - }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, appliedMileageRange, filterDate]); + }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]); // 加载更多 const loadMore = useCallback(() => { @@ -179,7 +184,8 @@ export default function MonitoringView() { rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, targetName: filterTargetName !== 'All' ? filterTargetName : undefined, - plate: filterPlate !== 'All' ? filterPlate : undefined, + region: filterRegion !== 'All' ? filterRegion : undefined, + plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, mileageMin: appliedMileageRange.min || undefined, mileageMax: appliedMileageRange.max || undefined, date: filterDate || undefined, @@ -188,13 +194,45 @@ export default function MonitoringView() { setPage(nextPage); setHasMore(nextPage < d.totalPages); }).catch(() => {}).finally(() => setLoadingMore(false)); - }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlate, appliedMileageRange, filterDate, page, loadingMore, hasMore]); + }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]); // 筛选/排序变化时重新加载 useEffect(() => { loadFirstPage(); }, [loadFirstPage]); + // 下载当前筛选结果为 xlsx + const handleDownload = useCallback(async () => { + if (exporting) return; + setExporting(true); + try { + const d = await fetchMonitoring({ + sortBy, + sortOrder, + limit: 9999, + page: 1, + search: searchTerm || undefined, + dept: filterDept !== 'All' ? filterDept : undefined, + customer: filterCustomer !== 'All' ? filterCustomer : undefined, + project: filterProject !== 'All' ? filterProject : undefined, + entity: filterEntity !== 'All' ? filterEntity : undefined, + rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, + platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, + targetName: filterTargetName !== 'All' ? filterTargetName : undefined, + region: filterRegion !== 'All' ? filterRegion : undefined, + plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, + mileageMin: appliedMileageRange.min || undefined, + mileageMax: appliedMileageRange.max || undefined, + date: filterDate || undefined, + }); + exportMileageXlsx(d.vehicles, { date: filterDate, sortBy }); + } catch (err) { + console.error('export failed', err); + } finally { + setExporting(false); + } + }, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]); + // 每分钟自动刷新 useEffect(() => { const timer = setInterval(loadFirstPage, 60 * 1000); @@ -260,14 +298,15 @@ export default function MonitoringView() { rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, targetName: filterTargetName !== 'All' ? filterTargetName : undefined, - plate: filterPlate !== 'All' ? filterPlate : undefined, + region: filterRegion !== 'All' ? filterRegion : undefined, + plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, date: filterDate || undefined, }).then(d => { setFullscreenVehicles(d.vehicles); setFullscreenStats(d.stats); setFilterOptions(d.filters); }).catch(() => {}).finally(() => setFullscreenLoading(false)); - }, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, filterDate, fullscreenRefresh]); + }, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, filterDate, fullscreenRefresh]); // 全屏时禁止背景滚动 useEffect(() => { @@ -391,14 +430,9 @@ export default function MonitoringView() {
车牌号 - + + {filterPlates.length === 0 ? '全部' : `已选 ${filterPlates.length}`} +
@@ -526,6 +560,14 @@ export default function MonitoringView() { > +
@@ -559,32 +601,32 @@ export default function MonitoringView() {
- {/* Bottom Row: Quick Filters & Advanced Filter Icon */} + {/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
-
@@ -612,23 +654,10 @@ export default function MonitoringView() { />
- {/* Project */} -
- - -
-
{/* Department */}
- + setFilterCustomer(e.target.value)} + > + + + {filterOptions.customers.map(c => )} + +
+
+ +
+ {/* Project */} +
+ + +
+ {/* Rent Status */}
@@ -669,19 +727,6 @@ export default function MonitoringView() {
- {/* Target Name */} -
- - -
- {/* Plate Prefix */}
@@ -724,11 +769,13 @@ export default function MonitoringView() { onClick={() => { setSearchTerm(''); setFilterDept('All'); - setFilterPlate('All'); + setFilterPlates([]); setFilterCustomer('All'); setFilterProject('All'); setFilterEntity('All'); setFilterPlatePrefix('All'); + setFilterTargetName('All'); + setFilterRegion('All'); setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' }); }} @@ -754,22 +801,23 @@ export default function MonitoringView() { {/* Active Filter Tags */} {(() => { const tags: { label: string; onClear: () => void }[] = []; + if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') }); + if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => setFilterRegion('All') }); + if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]} 等${filterPlates.length}`}`, onClear: () => setFilterPlates([]) }); if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') }); if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('All') }); if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => setFilterCustomer('All') }); if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') }); if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') }); - if (filterPlate !== 'All') tags.push({ label: `车牌: ${filterPlate}`, onClear: () => setFilterPlate('All') }); if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => setSearchTerm('') }); if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, min: '' })); setAppliedMileageRange(prev => ({ ...prev, min: '' })); } }); if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } }); - if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') }); - if (filterPlatePrefix !== 'All') tags.push({ label: `区域: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') }); + if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') }); if (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') }); if (tags.length === 0) return null; const clearAll = () => { setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All'); - setFilterPlate('All'); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All'); + setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All'); setFilterRegion('All'); setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' }); setFilterDate(''); }; diff --git a/src/modules/mileage/PlateMultiSelect.tsx b/src/modules/mileage/PlateMultiSelect.tsx new file mode 100644 index 0000000..278ac86 --- /dev/null +++ b/src/modules/mileage/PlateMultiSelect.tsx @@ -0,0 +1,198 @@ +import { useState, useRef, useEffect, useMemo } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { ChevronDown, X, AlertTriangle } from 'lucide-react'; + +interface Props { + allPlates: string[]; + selected: string[]; + onChange: (plates: string[]) => void; + placeholder?: string; +} + +function parseInput(text: string): string[] { + return text + .split(/[\s,;,;、]+/) + .map(s => s.trim()) + .filter(Boolean); +} + +export default function PlateMultiSelect({ allPlates, selected, onChange, placeholder = '按车牌(可多选/粘贴)' }: Props) { + const [isOpen, setIsOpen] = useState(false); + const [text, setText] = useState(''); + const [search, setSearch] = useState(''); + const [unmatched, setUnmatched] = useState([]); + const wrapRef = useRef(null); + + const allSet = useMemo(() => new Set(allPlates), [allPlates]); + + useEffect(() => { + if (!isOpen) return; + const handler = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setIsOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [isOpen]); + + const filtered = useMemo(() => { + if (!search) return allPlates.slice(0, 200); + const q = search.toLowerCase(); + return allPlates.filter(p => p.toLowerCase().includes(q)).slice(0, 200); + }, [allPlates, search]); + + const selectedSet = useMemo(() => new Set(selected), [selected]); + + const apply = (input: string) => { + const tokens = parseInput(input); + if (tokens.length === 0) return; + const matched: string[] = []; + const missed: string[] = []; + const seen = new Set(selected); + for (const t of tokens) { + if (allSet.has(t)) { + if (!seen.has(t)) { + matched.push(t); + seen.add(t); + } + } else { + missed.push(t); + } + } + if (matched.length > 0) onChange([...selected, ...matched]); + setUnmatched(missed); + setText(''); + }; + + const togglePlate = (plate: string) => { + if (selectedSet.has(plate)) { + onChange(selected.filter(p => p !== plate)); + } else { + onChange([...selected, plate]); + } + }; + + const removePlate = (plate: string) => { + onChange(selected.filter(p => p !== plate)); + }; + + const clearAll = () => { + onChange([]); + setUnmatched([]); + setText(''); + }; + + const display = selected.length === 0 + ? placeholder + : selected.length === 1 + ? selected[0] + : `${selected[0]} 等 ${selected.length} 个车牌`; + + return ( +
+
setIsOpen(o => !o)} + className={`w-full bg-slate-50 rounded-lg py-1.5 px-2 text-[10px] font-bold cursor-pointer flex items-center justify-between gap-1 ${selected.length > 0 ? 'text-blue-600 ring-1 ring-blue-200' : 'text-slate-600'}`} + > + {display} + +
+ + + {isOpen && ( + +
+