From 2b31f8efe8b414fe36edbe34d762ffc3485af4ec Mon Sep 17 00:00:00 2001 From: kkfluous Date: Wed, 15 Jul 2026 14:25:11 +0800 Subject: [PATCH] feat(mileage): add brand multi-select filter and customer fuzzy search - Add brand multi-select filter (BatchMultiSelect) with 17 brand options - Replace customer select with SearchableSelect for fuzzy search - Server-side: add brand to vehicle-info SQL, cache, filters, and monitoring - Customer filter now uses case-insensitive includes match on server side - Add brand column to fullscreen table header and body - Mobile-adaptive: compact inputs, constrained widths, scrollable dropdowns - Bump version to 1.1.8 --- package.json | 2 +- src/modules/mileage/MonitoringView.tsx | 102 +++++++++++++++++----- src/modules/mileage/api.ts | 6 ++ src/modules/mileage/types.ts | 2 + src/server/routes/mileage/cache.ts | 4 +- src/server/routes/mileage/monitoring.ts | 10 ++- src/server/routes/mileage/types.ts | 3 + src/server/routes/mileage/vehicle-info.ts | 27 +++++- 8 files changed, 127 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 805b434..ee3c5f7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ln-bi", "private": true, - "version": "1.1.7", + "version": "1.1.8", "type": "module", "scripts": { "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index aa1a4da..a91fcfc 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -277,6 +277,7 @@ export default function MonitoringView() { const [filterPlatePrefix, setFilterPlatePrefix] = useState('All'); const [filterTargetNames, setFilterTargetNames] = useState([]); const [filterRegion, setFilterRegion] = useState('All'); + const [filterBrands, setFilterBrands] = useState([]); const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' }); const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' }); const [exporting, setExporting] = useState(false); @@ -286,7 +287,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: [], regions: [] }); + const [filterOptions, setFilterOptions] = useState({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] }); const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number }[]>([]); const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() })); const [total, setTotal] = useState(0); @@ -342,6 +343,7 @@ export default function MonitoringView() { mileageMax: appliedMileageRange.max || undefined, startDate: rangeStart || undefined, endDate: rangeEnd || undefined, + brands: filterBrands.length > 0 ? filterBrands : undefined, }).then(d => { setVehicles(d.vehicles); setStats(d.stats); @@ -352,7 +354,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, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd]); + }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]); // 加载更多 const loadMore = useCallback(() => { @@ -378,12 +380,13 @@ export default function MonitoringView() { mileageMax: appliedMileageRange.max || undefined, startDate: rangeStart || undefined, endDate: rangeEnd || undefined, + brands: filterBrands.length > 0 ? filterBrands : undefined, }).then(d => { setVehicles(prev => [...prev, ...d.vehicles]); setPage(nextPage); setHasMore(nextPage < d.totalPages); }).catch(() => {}).finally(() => setLoadingMore(false)); - }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore]); + }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands]); // 筛选/排序变化时重新加载 useEffect(() => { @@ -422,6 +425,7 @@ export default function MonitoringView() { mileageMax: appliedMileageRange.max || undefined, startDate: rangeStart || undefined, endDate: rangeEnd || undefined, + brands: filterBrands.length > 0 ? filterBrands : undefined, }); exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy }); } catch (err) { @@ -429,7 +433,7 @@ export default function MonitoringView() { } finally { setExporting(false); } - }, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd]); + }, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]); // 每分钟自动刷新 useEffect(() => { @@ -500,12 +504,13 @@ export default function MonitoringView() { plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, startDate: rangeStart || undefined, endDate: rangeEnd || undefined, + brands: filterBrands.length > 0 ? filterBrands : undefined, }).then(d => { setFullscreenVehicles(d.vehicles); setFullscreenStats(d.stats); setFilterOptions(d.filters); }).catch(() => {}).finally(() => setFullscreenLoading(false)); - }, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh]); + }, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands]); // 全屏时禁止背景滚动 useEffect(() => { @@ -637,15 +642,51 @@ export default function MonitoringView() {
客户 - + { + const val = e.target.value.trim(); + setFilterCustomer(val || 'All'); + e.target.value = ''; + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const val = (e.target as HTMLInputElement).value.trim(); + setFilterCustomer(val || 'All'); + (e.target as HTMLInputElement).value = ''; + } + }} + /> +
+ + +
+ 品牌 + { + const val = e.target.value.trim(); + if (val) { + setFilterBrands(filterBrands.includes(val) ? filterBrands.filter(b => b !== val) : [...filterBrands, val]); + } + e.target.value = ''; + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const val = (e.target as HTMLInputElement).value.trim(); + if (val) { + setFilterBrands(filterBrands.includes(val) ? filterBrands.filter(b => b !== val) : [...filterBrands, val]); + } + (e.target as HTMLInputElement).value = ''; + } + }} + />
@@ -723,6 +764,7 @@ export default function MonitoringView() { {v.plate} {v.customer || '-'} + {v.brand || '-'} {v.rentStatus || '-'} {v.department || '-'} @@ -828,7 +870,7 @@ export default function MonitoringView() { @@ -930,18 +972,26 @@ export default function MonitoringView() { {/* Customer */}
- + onChange={setFilterCustomer} + placeholder="搜索客户" + />
+ {/* Brand multi-select */} +
+ + +
+
{/* Project */}
@@ -1036,6 +1086,7 @@ export default function MonitoringView() { setFilterRegion('All'); setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' }); + setFilterBrands([]); const today = defaultMileageDate(); setRangeStart(today); setRangeEnd(today); @@ -1071,6 +1122,10 @@ export default function MonitoringView() { 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 (filterBrands.length > 0) tags.push({ + label: `品牌: ${filterBrands.length === 1 ? filterBrands[0] : `${filterBrands[0]} 等${filterBrands.length}`}`, + onClear: () => setFilterBrands([]) + }); if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') }); if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') }); if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => setSearchTerm('') }); @@ -1090,6 +1145,7 @@ export default function MonitoringView() { setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All'); setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetNames([]); setFilterRegion('All'); setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' }); + setFilterBrands([]); const today = defaultMileageDate(); setRangeStart(today); setRangeEnd(today); }; diff --git a/src/modules/mileage/api.ts b/src/modules/mileage/api.ts index 8159197..2b2e099 100644 --- a/src/modules/mileage/api.ts +++ b/src/modules/mileage/api.ts @@ -24,6 +24,7 @@ export async function fetchMonitoring(params?: { date?: string; startDate?: string; endDate?: string; + brands?: string[]; }): Promise { const query = new URLSearchParams(); if (params?.sortBy) query.set('sortBy', params.sortBy); @@ -44,6 +45,11 @@ export async function fetchMonitoring(params?: { }); } if (params?.region) query.set('region', params.region); + if (params?.brands) { + params.brands.forEach(brand => { + if (brand) query.append('brand', brand); + }); + } if (params?.plate) query.set('plate', params.plate); if (params?.mileageMin) query.set('mileageMin', params.mileageMin); if (params?.mileageMax) query.set('mileageMax', params.mileageMax); diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts index 6971c71..a1078c2 100644 --- a/src/modules/mileage/types.ts +++ b/src/modules/mileage/types.ts @@ -14,6 +14,7 @@ export interface MonitoringVehicle { entity: string | null; project: string | null; region: string | null; + brand: string | null; targetNames: string[]; } @@ -34,6 +35,7 @@ export interface MonitoringFilters { platePrefixes: { prefix: string; count: number }[]; targetNames: string[]; regions: string[]; + brands: string[]; } export interface MonitoringData { diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts index 1cbdaf4..2c44163 100644 --- a/src/server/routes/mileage/cache.ts +++ b/src/server/routes/mileage/cache.ts @@ -37,6 +37,7 @@ function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): Monitor const projects = Array.from(new Set(vehicles.map(v => v.project).filter((p): p is string => p !== null))); const entities = Array.from(new Set(vehicles.map(v => v.entity).filter((e): e is string => e !== null))); const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter((r): r is string => r !== null))); + const brands = Array.from(new Set(vehicles.map(v => v.brand).filter((b): b is string => b !== null))).sort(); const prefixCount = new Map(); for (const v of vehicles) { @@ -54,7 +55,7 @@ function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): Monitor return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); }); - return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions }; + return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions, brands }; } interface MileageRow { @@ -199,6 +200,7 @@ function mergeVehicles( entity: info.entity || null, project: info.project || null, region: regionMap[plate] || null, + brand: info.brand_label || null, targetNames: targetNamesByPlate.get(plate) || [], yesterdayKm: yesterdayMap.get(plate) || 0, }; diff --git a/src/server/routes/mileage/monitoring.ts b/src/server/routes/mileage/monitoring.ts index 8cf7f17..750c6ce 100644 --- a/src/server/routes/mileage/monitoring.ts +++ b/src/server/routes/mileage/monitoring.ts @@ -9,7 +9,7 @@ const app = new Hono(); const EMPTY_RESPONSE: MonitoringResponse = { vehicles: [], stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }, - filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] }, + filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] }, total: 0, page: 1, totalPages: 1, @@ -20,6 +20,7 @@ function applyFilters(vehicles: CachedVehicle[], params: { search: string; dept: string; customer: string; project: string; entity: string; rentStatus: string; plate: string; platePrefix: string; targetNames: string[]; region: string; mileageMin: string; mileageMax: string; + brands: string[]; }): CachedVehicle[] { let result = vehicles; @@ -32,7 +33,7 @@ function applyFilters(vehicles: CachedVehicle[], params: { ); } if (params.dept) result = result.filter(v => params.dept === '__EMPTY__' ? !v.department : v.department === params.dept); - if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : v.customer === params.customer); + if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : (v.customer || '').toLowerCase().includes(params.customer.toLowerCase())); if (params.project) result = result.filter(v => v.project === params.project); if (params.entity) result = result.filter(v => v.entity === params.entity); if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus); @@ -46,6 +47,10 @@ function applyFilters(vehicles: CachedVehicle[], params: { const selectedTargets = new Set(params.targetNames); result = result.filter(v => v.targetNames.some(targetName => selectedTargets.has(targetName))); } + if (params.brands.length > 0) { + const selectedBrands = new Set(params.brands); + result = result.filter(v => v.brand != null && selectedBrands.has(v.brand)); + } if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin)); if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax)); @@ -112,6 +117,7 @@ app.get('/', async (c) => { region: c.req.query('region') || '', mileageMin: c.req.query('mileageMin') || '', mileageMax: c.req.query('mileageMax') || '', + brands: c.req.queries('brand') || [], }; let allVehicles: CachedVehicle[]; diff --git a/src/server/routes/mileage/types.ts b/src/server/routes/mileage/types.ts index c7d1abc..b450622 100644 --- a/src/server/routes/mileage/types.ts +++ b/src/server/routes/mileage/types.ts @@ -16,6 +16,7 @@ export interface CachedVehicle { entity: string | null; project: string | null; region: string | null; + brand: string | null; targetNames: string[]; yesterdayKm: number; } @@ -37,6 +38,7 @@ export interface MonitoringFilters { platePrefixes: PlatePrefix[]; targetNames: string[]; regions: string[]; + brands: string[]; } /** 监控缓存 */ @@ -80,4 +82,5 @@ export interface VehicleInfoRow { rent_status: string | null; entity: string | null; project: string | null; + brand_label: string | null; } diff --git a/src/server/routes/mileage/vehicle-info.ts b/src/server/routes/mileage/vehicle-info.ts index 52ca65d..8f5d181 100644 --- a/src/server/routes/mileage/vehicle-info.ts +++ b/src/server/routes/mileage/vehicle-info.ts @@ -1,7 +1,7 @@ import pool from '../../db.js'; import type { VehicleInfoRow } from './types.js'; -/** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目) */ +/** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目、品牌) */ export const VEHICLE_INFO_SQL = `SELECT vi.plate_number AS plate, vi.vin AS vin, @@ -18,11 +18,34 @@ export const VEHICLE_INFO_SQL = `SELECT ELSE vs.operation_status END AS rent_status, NULLIF(vi.registered_ownership, '') AS entity, - COALESCE(c.project_name, vor.project_name) AS project + COALESCE(c.project_name, vor.project_name) AS project, + CASE vm.brand + WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END + WHEN 'yuejin' THEN '跃进' + WHEN 'feichi' THEN '飞驰' + WHEN 'sulong' THEN '苏龙' + WHEN 'higer' THEN '海格' + WHEN 'dongfeng' THEN '东风' + WHEN 'yutong' THEN '宇通' + WHEN 'chufeng' THEN '楚风' + WHEN 'tonghua' THEN '通华' + WHEN 'maxus' THEN '大通' + WHEN 'mingwei' THEN '明威' + WHEN 'wanfeng' THEN '万风' + WHEN 'shujie' THEN '舒捷' + WHEN 'denza' THEN '腾势' + WHEN 'hongyan' THEN '红岩' + WHEN 'yuanchang brand' THEN '远程牌' + WHEN 'others' THEN '其他' + ELSE vm.brand + END AS brand_label FROM vehicle_info vi LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0 +LEFT JOIN vehicle_model vm + ON vm.id = vi.vehicle_model_id + AND vm.del_flag = '0' LEFT JOIN vehicle_lease_order_record vor ON vor.vehicle_id = vi.id AND vor.del_flag = '0'