feat(mileage): add brand multi-select filter and customer fuzzy search
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- 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
This commit is contained in:
kkfluous
2026-07-15 14:25:11 +08:00
parent ae4b5d30e7
commit 2b31f8efe8
8 changed files with 127 additions and 29 deletions

View File

@@ -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\"",

View File

@@ -277,6 +277,7 @@ export default function MonitoringView() {
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
const [filterRegion, setFilterRegion] = useState('All');
const [filterBrands, setFilterBrands] = useState<string[]>([]);
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<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] });
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ 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() {
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1">
<span></span>
<select
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
value={filterCustomer}
onChange={(e) => setFilterCustomer(e.target.value)}
>
<option value="All"></option>
<option value="__EMPTY__"></option>
{filterOptions.customers.map(p => <option key={p} value={p}>{p}</option>)}
</select>
<input
type="text"
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30 w-20"
placeholder={filterCustomer === 'All' ? '全部' : filterCustomer}
defaultValue=""
onBlur={(e) => {
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 = '';
}
}}
/>
</div>
</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1">
<span></span>
<input
type="text"
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30 w-16"
placeholder={filterBrands.length === 0 ? '全部' : `已选${filterBrands.length}`}
defaultValue=""
onBlur={(e) => {
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 = '';
}
}}
/>
</div>
</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
@@ -723,6 +764,7 @@ export default function MonitoringView() {
</td>
<td className="px-3 py-2 text-xs font-bold text-white"><Blur>{v.plate}</Blur></td>
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.brand || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
<td className="px-3 py-2 text-right">
@@ -828,7 +870,7 @@ export default function MonitoringView() {
<button
onClick={() => setIsFilterOpen(!isFilterOpen)}
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 || filterBrands.length > 0 ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
>
<Filter size={16} />
</button>
@@ -930,18 +972,26 @@ export default function MonitoringView() {
{/* Customer */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
<SearchableSelect
options={filterOptions.customers}
value={filterCustomer}
onChange={(e) => setFilterCustomer(e.target.value)}
>
<option value="All"></option>
<option value="__EMPTY__"></option>
{filterOptions.customers.map(c => <option key={c} value={c}>{c}</option>)}
</select>
onChange={setFilterCustomer}
placeholder="搜索客户"
/>
</div>
</div>
{/* Brand multi-select */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<BatchMultiSelect
options={filterOptions.brands}
selected={filterBrands}
onChange={setFilterBrands}
placeholder="选择品牌"
/>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Project */}
<div className="space-y-1.5">
@@ -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);
};

View File

@@ -24,6 +24,7 @@ export async function fetchMonitoring(params?: {
date?: string;
startDate?: string;
endDate?: string;
brands?: string[];
}): Promise<MonitoringData> {
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);

View File

@@ -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 {

View File

@@ -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<string, number>();
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,
};

View File

@@ -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[];

View File

@@ -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;
}

View File

@@ -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'