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", "name": "ln-bi",
"private": true, "private": true,
"version": "1.1.7", "version": "1.1.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", "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 [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]); const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
const [filterRegion, setFilterRegion] = useState('All'); const [filterRegion, setFilterRegion] = useState('All');
const [filterBrands, setFilterBrands] = useState<string[]>([]);
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' }); const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' }); const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
@@ -286,7 +287,7 @@ export default function MonitoringView() {
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]); const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }); 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 [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number }[]>([]);
const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() })); const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() }));
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -342,6 +343,7 @@ export default function MonitoringView() {
mileageMax: appliedMileageRange.max || undefined, mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined, startDate: rangeStart || undefined,
endDate: rangeEnd || undefined, endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
}).then(d => { }).then(d => {
setVehicles(d.vehicles); setVehicles(d.vehicles);
setStats(d.stats); setStats(d.stats);
@@ -352,7 +354,7 @@ export default function MonitoringView() {
setPage(1); setPage(1);
setHasMore(d.page < d.totalPages); setHasMore(d.page < d.totalPages);
}).catch(() => {}).finally(() => setPageLoading(false)); }).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(() => { const loadMore = useCallback(() => {
@@ -378,12 +380,13 @@ export default function MonitoringView() {
mileageMax: appliedMileageRange.max || undefined, mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined, startDate: rangeStart || undefined,
endDate: rangeEnd || undefined, endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
}).then(d => { }).then(d => {
setVehicles(prev => [...prev, ...d.vehicles]); setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage); setPage(nextPage);
setHasMore(nextPage < d.totalPages); setHasMore(nextPage < d.totalPages);
}).catch(() => {}).finally(() => setLoadingMore(false)); }).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(() => { useEffect(() => {
@@ -422,6 +425,7 @@ export default function MonitoringView() {
mileageMax: appliedMileageRange.max || undefined, mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined, startDate: rangeStart || undefined,
endDate: rangeEnd || undefined, endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
}); });
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy }); exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
} catch (err) { } catch (err) {
@@ -429,7 +433,7 @@ export default function MonitoringView() {
} finally { } finally {
setExporting(false); 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(() => { useEffect(() => {
@@ -500,12 +504,13 @@ export default function MonitoringView() {
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
startDate: rangeStart || undefined, startDate: rangeStart || undefined,
endDate: rangeEnd || undefined, endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
}).then(d => { }).then(d => {
setFullscreenVehicles(d.vehicles); setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats); setFullscreenStats(d.stats);
setFilterOptions(d.filters); setFilterOptions(d.filters);
}).catch(() => {}).finally(() => setFullscreenLoading(false)); }).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(() => { useEffect(() => {
@@ -637,15 +642,51 @@ export default function MonitoringView() {
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase"> <th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span></span> <span></span>
<select <input
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" type="text"
value={filterCustomer} 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"
onChange={(e) => setFilterCustomer(e.target.value)} placeholder={filterCustomer === 'All' ? '全部' : filterCustomer}
> defaultValue=""
<option value="All"></option> onBlur={(e) => {
<option value="__EMPTY__"></option> const val = e.target.value.trim();
{filterOptions.customers.map(p => <option key={p} value={p}>{p}</option>)} setFilterCustomer(val || 'All');
</select> 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> </div>
</th> </th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase"> <th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
@@ -723,6 +764,7 @@ export default function MonitoringView() {
</td> </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-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"><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.rentStatus || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td> <td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
@@ -828,7 +870,7 @@ export default function MonitoringView() {
<button <button
onClick={() => setIsFilterOpen(!isFilterOpen)} 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} /> <Filter size={16} />
</button> </button>
@@ -930,18 +972,26 @@ export default function MonitoringView() {
{/* Customer */} {/* Customer */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label> <label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select <SearchableSelect
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" options={filterOptions.customers}
value={filterCustomer} value={filterCustomer}
onChange={(e) => setFilterCustomer(e.target.value)} onChange={setFilterCustomer}
> placeholder="搜索客户"
<option value="All"></option> />
<option value="__EMPTY__"></option>
{filterOptions.customers.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div> </div>
</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"> <div className="grid grid-cols-2 gap-3">
{/* Project */} {/* Project */}
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -1036,6 +1086,7 @@ export default function MonitoringView() {
setFilterRegion('All'); setFilterRegion('All');
setFilterMileageRange({ min: '', max: '' }); setFilterMileageRange({ min: '', max: '' });
setAppliedMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
setFilterBrands([]);
const today = defaultMileageDate(); const today = defaultMileageDate();
setRangeStart(today); setRangeStart(today);
setRangeEnd(today); setRangeEnd(today);
@@ -1071,6 +1122,10 @@ export default function MonitoringView() {
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') }); if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('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 (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 (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') }); if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') });
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => setSearchTerm('') }); 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'); setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetNames([]); setFilterRegion('All'); setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetNames([]); setFilterRegion('All');
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' }); setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
setFilterBrands([]);
const today = defaultMileageDate(); const today = defaultMileageDate();
setRangeStart(today); setRangeEnd(today); setRangeStart(today); setRangeEnd(today);
}; };

View File

@@ -24,6 +24,7 @@ export async function fetchMonitoring(params?: {
date?: string; date?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
brands?: string[];
}): Promise<MonitoringData> { }): Promise<MonitoringData> {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.sortBy) query.set('sortBy', params.sortBy); 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?.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?.plate) query.set('plate', params.plate);
if (params?.mileageMin) query.set('mileageMin', params.mileageMin); if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
if (params?.mileageMax) query.set('mileageMax', params.mileageMax); if (params?.mileageMax) query.set('mileageMax', params.mileageMax);

View File

@@ -14,6 +14,7 @@ export interface MonitoringVehicle {
entity: string | null; entity: string | null;
project: string | null; project: string | null;
region: string | null; region: string | null;
brand: string | null;
targetNames: string[]; targetNames: string[];
} }
@@ -34,6 +35,7 @@ export interface MonitoringFilters {
platePrefixes: { prefix: string; count: number }[]; platePrefixes: { prefix: string; count: number }[];
targetNames: string[]; targetNames: string[];
regions: string[]; regions: string[];
brands: string[];
} }
export interface MonitoringData { 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 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 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 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>(); const prefixCount = new Map<string, number>();
for (const v of vehicles) { 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 (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 { interface MileageRow {
@@ -199,6 +200,7 @@ function mergeVehicles(
entity: info.entity || null, entity: info.entity || null,
project: info.project || null, project: info.project || null,
region: regionMap[plate] || null, region: regionMap[plate] || null,
brand: info.brand_label || null,
targetNames: targetNamesByPlate.get(plate) || [], targetNames: targetNamesByPlate.get(plate) || [],
yesterdayKm: yesterdayMap.get(plate) || 0, yesterdayKm: yesterdayMap.get(plate) || 0,
}; };

View File

@@ -9,7 +9,7 @@ const app = new Hono();
const EMPTY_RESPONSE: MonitoringResponse = { const EMPTY_RESPONSE: MonitoringResponse = {
vehicles: [], vehicles: [],
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }, 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, total: 0,
page: 1, page: 1,
totalPages: 1, totalPages: 1,
@@ -20,6 +20,7 @@ function applyFilters(vehicles: CachedVehicle[], params: {
search: string; dept: string; customer: string; project: string; search: string; dept: string; customer: string; project: string;
entity: string; rentStatus: string; plate: string; platePrefix: string; entity: string; rentStatus: string; plate: string; platePrefix: string;
targetNames: string[]; region: string; mileageMin: string; mileageMax: string; targetNames: string[]; region: string; mileageMin: string; mileageMax: string;
brands: string[];
}): CachedVehicle[] { }): CachedVehicle[] {
let result = vehicles; 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.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.project) result = result.filter(v => v.project === params.project);
if (params.entity) result = result.filter(v => v.entity === params.entity); if (params.entity) result = result.filter(v => v.entity === params.entity);
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus); 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); const selectedTargets = new Set(params.targetNames);
result = result.filter(v => v.targetNames.some(targetName => selectedTargets.has(targetName))); 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.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax)); 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') || '', region: c.req.query('region') || '',
mileageMin: c.req.query('mileageMin') || '', mileageMin: c.req.query('mileageMin') || '',
mileageMax: c.req.query('mileageMax') || '', mileageMax: c.req.query('mileageMax') || '',
brands: c.req.queries('brand') || [],
}; };
let allVehicles: CachedVehicle[]; let allVehicles: CachedVehicle[];

View File

@@ -16,6 +16,7 @@ export interface CachedVehicle {
entity: string | null; entity: string | null;
project: string | null; project: string | null;
region: string | null; region: string | null;
brand: string | null;
targetNames: string[]; targetNames: string[];
yesterdayKm: number; yesterdayKm: number;
} }
@@ -37,6 +38,7 @@ export interface MonitoringFilters {
platePrefixes: PlatePrefix[]; platePrefixes: PlatePrefix[];
targetNames: string[]; targetNames: string[];
regions: string[]; regions: string[];
brands: string[];
} }
/** 监控缓存 */ /** 监控缓存 */
@@ -80,4 +82,5 @@ export interface VehicleInfoRow {
rent_status: string | null; rent_status: string | null;
entity: string | null; entity: string | null;
project: string | null; project: string | null;
brand_label: string | null;
} }

View File

@@ -1,7 +1,7 @@
import pool from '../../db.js'; import pool from '../../db.js';
import type { VehicleInfoRow } from './types.js'; import type { VehicleInfoRow } from './types.js';
/** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */ /** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目、品牌 */
export const VEHICLE_INFO_SQL = `SELECT export const VEHICLE_INFO_SQL = `SELECT
vi.plate_number AS plate, vi.plate_number AS plate,
vi.vin AS vin, vi.vin AS vin,
@@ -18,11 +18,34 @@ export const VEHICLE_INFO_SQL = `SELECT
ELSE vs.operation_status ELSE vs.operation_status
END AS rent_status, END AS rent_status,
NULLIF(vi.registered_ownership, '') AS entity, 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 FROM vehicle_info vi
LEFT JOIN vehicle_status vs LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id ON vs.vehicle_id = vi.id
AND vs.del_flag = 0 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 LEFT JOIN vehicle_lease_order_record vor
ON vor.vehicle_id = vi.id ON vor.vehicle_id = vi.id
AND vor.del_flag = '0' AND vor.del_flag = '0'