feat(mileage): 外部三选筛选 + 车牌多选粘贴 + 运营区域 + xlsx 下载
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- 外部行改为 批次型号 / 运营区域 / 车牌多选;按部门、按客户移入详情面板
- 车牌多选支持从 Excel 粘贴(换行/逗号/空格分隔),未匹配项显示警告
- 新增运营区域筛选:基于 136 批次区域映射(华东/华南/西南/西北)
- 新增 xlsx 数据下载,导出当前筛选结果(带表头样式与列宽)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-29 15:19:00 +08:00
parent d1acdafa7e
commit 3809e785c1
11 changed files with 670 additions and 72 deletions

110
package-lock.json generated
View File

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

View File

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

View File

@@ -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<string[]>([]);
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<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: [] });
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ 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() {
<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={filterPlate}
onChange={(e) => setFilterPlate(e.target.value)}
>
<option value="All"></option>
{plateNumbers.map(p => <option key={p} value={p}>{p}</option>)}
</select>
<span className="text-[9px] text-slate-500 font-normal">
{filterPlates.length === 0 ? '全部' : `已选 ${filterPlates.length}`}
</span>
</div>
</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
@@ -526,6 +560,14 @@ export default function MonitoringView() {
>
<Maximize2 size={14} />
</button>
<button
onClick={handleDownload}
disabled={exporting}
className="p-1 text-slate-300 hover:text-blue-600 transition-colors disabled:text-slate-200"
title="下载当前筛选结果"
>
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
</button>
</div>
<div className="flex items-center gap-1.5 mt-1">
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
@@ -559,32 +601,32 @@ export default function MonitoringView() {
</div>
</div>
{/* Bottom Row: Quick Filters & Advanced Filter Icon */}
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
<div className="flex items-center gap-2">
<div className="flex-1 grid grid-cols-3 gap-1.5">
<SearchableSelect
options={['__EMPTY__', ...departments]}
value={filterDept}
onChange={setFilterDept}
placeholder="按部门"
options={filterOptions.targetNames}
value={filterTargetName}
onChange={setFilterTargetName}
placeholder="批次型号"
/>
<SearchableSelect
options={['__EMPTY__', ...filterOptions.customers]}
value={filterCustomer}
onChange={setFilterCustomer}
placeholder="按客户"
options={filterOptions.regions}
value={filterRegion}
onChange={setFilterRegion}
placeholder="运营区域"
/>
<SearchableSelect
options={plateNumbers}
value={filterPlate}
onChange={setFilterPlate}
<PlateMultiSelect
allPlates={plateNumbers}
selected={filterPlates}
onChange={setFilterPlates}
placeholder="按车牌"
/>
</div>
<button
onClick={() => setIsFilterOpen(!isFilterOpen)}
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlate !== 'All' || filterProject !== 'All' ? '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' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
>
<Filter size={16} />
</button>
@@ -612,6 +654,37 @@ export default function MonitoringView() {
/>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Department */}
<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"
value={filterDept}
onChange={(e) => setFilterDept(e.target.value)}
>
<option value="All"></option>
<option value="__EMPTY__"></option>
{departments.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
{/* 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"
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>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Project */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
@@ -625,21 +698,6 @@ export default function MonitoringView() {
</select>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Department */}
<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"
value={filterDept}
onChange={(e) => setFilterDept(e.target.value)}
>
<option value="All"></option>
<option value="__EMPTY__"></option>
{departments.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
{/* Rent Status */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
@@ -669,19 +727,6 @@ export default function MonitoringView() {
</div>
</div>
{/* Target Name */}
<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"
value={filterTargetName}
onChange={(e) => setFilterTargetName(e.target.value)}
>
<option value="All"></option>
{filterOptions.targetNames.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
{/* Plate Prefix */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
@@ -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('');
};

View File

@@ -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<string[]>([]);
const wrapRef = useRef<HTMLDivElement>(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 (
<div className="relative" ref={wrapRef}>
<div
onClick={() => 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'}`}
>
<span className="truncate">{display}</span>
<ChevronDown size={10} className="text-slate-400 flex-shrink-0" />
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="absolute z-50 left-0 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl w-[280px] max-w-[calc(100vw-32px)]"
style={{ minWidth: '100%' }}
>
<div className="p-2 space-y-2">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onPaste={(e) => {
const pasted = e.clipboardData.getData('text');
if (pasted && /[\s,;,;、]/.test(pasted)) {
e.preventDefault();
apply(text + (text ? ' ' : '') + pasted);
}
}}
placeholder="粘贴或输入车牌&#10;支持换行/逗号/空格分隔"
className="w-full bg-slate-50 border-none rounded-lg p-2 text-[11px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30 resize-none"
rows={3}
/>
<div className="flex justify-between items-center">
<span className="text-[10px] text-slate-400"> <span className="font-bold text-blue-600">{selected.length}</span> </span>
<div className="flex gap-1">
<button
onClick={() => apply(text)}
disabled={!text.trim()}
className="px-2 py-1 bg-blue-600 text-white rounded-md text-[10px] font-bold disabled:bg-slate-200 disabled:text-slate-400"
></button>
<button
onClick={clearAll}
className="px-2 py-1 bg-slate-100 text-slate-500 rounded-md text-[10px] font-bold"
></button>
</div>
</div>
{unmatched.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 space-y-1">
<div className="flex items-center gap-1 text-amber-700">
<AlertTriangle size={10} />
<span className="text-[10px] font-bold">{unmatched.length} </span>
<button
onClick={() => setUnmatched([])}
className="ml-auto text-amber-500 hover:text-amber-700"
><X size={10} /></button>
</div>
<div className="text-[10px] text-amber-600 break-all max-h-16 overflow-y-auto leading-relaxed">
{unmatched.join('')}
</div>
</div>
)}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1 max-h-20 overflow-y-auto p-1 bg-slate-50 rounded-lg">
{selected.map(p => (
<span key={p} className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-white border border-blue-100 text-blue-600 rounded text-[10px] font-bold">
{p}
<button onClick={() => removePlate(p)} className="text-blue-400 hover:text-blue-700"><X size={9} /></button>
</span>
))}
</div>
)}
<div className="border-t border-slate-100 pt-2">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索车牌"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30"
/>
<div className="mt-1 max-h-40 overflow-y-auto">
{filtered.map(p => (
<div
key={p}
onClick={() => togglePlate(p)}
className={`px-2 py-1 text-[10px] font-bold cursor-pointer flex items-center gap-1.5 rounded ${selectedSet.has(p) ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50'}`}
>
<span className={`w-3 h-3 rounded border ${selectedSet.has(p) ? 'bg-blue-600 border-blue-600' : 'border-slate-300'} flex items-center justify-center`}>
{selectedSet.has(p) && <span className="text-white text-[8px] leading-none"></span>}
</span>
{p}
</div>
))}
{filtered.length === 0 && (
<div className="px-2 py-1 text-[10px] text-slate-300 italic"></div>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -16,6 +16,7 @@ export async function fetchMonitoring(params?: {
rentStatus?: string;
platePrefix?: string;
targetName?: string;
region?: string;
plate?: string;
mileageMin?: string;
mileageMax?: string;
@@ -34,6 +35,7 @@ export async function fetchMonitoring(params?: {
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
if (params?.targetName) query.set('targetName', params.targetName);
if (params?.region) query.set('region', params.region);
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

@@ -12,6 +12,7 @@ export interface MonitoringVehicle {
rentStatus: string | null;
entity: string | null;
project: string | null;
region: string | null;
}
export interface MonitoringStats {
@@ -30,6 +31,7 @@ export interface MonitoringFilters {
rentStatuses: string[];
platePrefixes: { prefix: string; count: number }[];
targetNames: string[];
regions: string[];
}
export interface MonitoringData {

View File

@@ -0,0 +1,81 @@
import * as XLSX from 'xlsx';
import type { MonitoringVehicle } from './types';
interface ExportContext {
date: string;
sortBy: 'today' | 'total';
}
const HEADERS = [
'状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
'运营区域', '今日里程(km)', '累计里程(km)',
] as const;
function statusLabel(v: MonitoringVehicle): string {
if (!v.isDataSynced) return '未对接';
return v.isOnline ? '在线' : '离线';
}
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
if (!v.isDataSynced) return '未对接';
if (kind === 'today') return Math.max(0, Math.round(v.dailyKm || 0));
return v.totalKm != null ? Math.round(v.totalKm) : '未对接';
}
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
const data: (string | number)[][] = [
[...HEADERS],
...vehicles.map(v => [
statusLabel(v),
v.plate,
v.customer || '',
v.department || '',
v.project || '',
v.rentStatus || '',
v.region || '',
mileageCell(v, 'today'),
mileageCell(v, 'total'),
]),
];
const ws = XLSX.utils.aoa_to_sheet(data);
ws['!cols'] = [
{ wch: 8 }, // 状态
{ wch: 12 }, // 车牌号
{ wch: 28 }, // 客户
{ wch: 14 }, // 业务部门
{ wch: 16 }, // 项目
{ wch: 10 }, // 租赁状态
{ wch: 12 }, // 运营区域
{ wch: 14 }, // 今日里程
{ wch: 14 }, // 累计里程
];
ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
// 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
for (let c = 0; c < HEADERS.length; c++) {
const ref = XLSX.utils.encode_cell({ r: 0, c });
if (ws[ref]) {
(ws[ref] as { s?: unknown }).s = {
font: { bold: true, color: { rgb: 'FFFFFF' } },
fill: { fgColor: { rgb: '2563EB' } },
alignment: { horizontal: 'center', vertical: 'center' },
};
}
}
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '里程明细');
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const dateTag = ctx.date ? ctx.date.replace(/-/g, '') : `${y}${m}${d}`;
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? '今日' : '累计'}.xlsx`;
XLSX.writeFile(wb, filename);
}

View File

@@ -1,8 +1,17 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const regionMap: Record<string, string> = JSON.parse(
readFileSync(join(__dirname, 'region-map.json'), 'utf8')
);
const REGION_ORDER = ['华东区域', '华南区域', '西南区域', '西北区域', '华北区域', '华中区域', '东北区域'];
let monitoringCache: MonitoringCache | null = null;
export function getCache(): MonitoringCache | null {
@@ -38,7 +47,14 @@ function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): Monitor
.map(([prefix, count]) => ({ prefix, count }))
.sort((a, b) => b.count - a.count);
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames };
const regionSet = new Set(vehicles.map(v => v.region).filter((r): r is string => r !== null));
const regions = Array.from(regionSet).sort((a, b) => {
const ai = REGION_ORDER.indexOf(a);
const bi = REGION_ORDER.indexOf(b);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions };
}
interface MileageRow {
@@ -99,6 +115,7 @@ function mergeVehicles(
rentStatus: info?.rent_status || null,
entity: info?.entity || null,
project: info?.project || null,
region: regionMap[m.plate] || null,
yesterdayKm: yesterdayMap.get(m.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: [] },
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] },
total: 0,
page: 1,
totalPages: 1,
@@ -19,7 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
function applyFilters(vehicles: CachedVehicle[], params: {
search: string; dept: string; customer: string; project: string;
entity: string; rentStatus: string; plate: string; platePrefix: string;
targetName: string; mileageMin: string; mileageMax: string;
targetName: string; region: string; mileageMin: string; mileageMax: string;
}): CachedVehicle[] {
let result = vehicles;
@@ -36,8 +36,12 @@ function applyFilters(vehicles: CachedVehicle[], params: {
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);
if (params.plate) result = result.filter(v => v.plate === params.plate);
if (params.plate) {
const wanted = new Set(params.plate.split(',').map(s => s.trim()).filter(Boolean));
if (wanted.size > 0) result = result.filter(v => wanted.has(v.plate));
}
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
if (params.region) result = result.filter(v => v.region === params.region);
if (params.targetName) {
const cache = getCache();
const tPlates = cache?.targetPlatesMap.get(params.targetName);
@@ -66,6 +70,7 @@ app.get('/', async (c) => {
plate: c.req.query('plate') || '',
platePrefix: c.req.query('platePrefix') || '',
targetName: c.req.query('targetName') || '',
region: c.req.query('region') || '',
mileageMin: c.req.query('mileageMin') || '',
mileageMax: c.req.query('mileageMax') || '',
};

View File

@@ -0,0 +1,138 @@
{
"粤AGP2009": "华南区域",
"粤AGP2011": "华南区域",
"粤AGP2017": "华南区域",
"粤AGP2032": "华南区域",
"粤AGP2035": "华东区域",
"粤AGP3027": "华东区域",
"粤AGP3029": "华南区域",
"粤AGP3071": "华南区域",
"粤AGP3078": "华东区域",
"粤AGP3079": "华东区域",
"粤AGP3082": "西南区域",
"粤AGP3087": "西南区域",
"粤AGP3097": "华南区域",
"粤AGP3486": "西北区域",
"粤AGP3502": "华南区域",
"粤AGP3503": "华东区域",
"粤AGP3505": "西南区域",
"粤AGP3506": "华东区域",
"粤AGP3509": "西南区域",
"粤AGP3513": "华东区域",
"粤AGP3515": "华东区域",
"粤AGP3605": "华东区域",
"粤AGP3607": "华东区域",
"粤AGP3609": "华东区域",
"粤AGP3612": "华东区域",
"粤AGP3615": "华南区域",
"粤AGP3617": "西北区域",
"粤AGP3625": "华东区域",
"粤AGP3627": "华东区域",
"粤AGP3631": "西南区域",
"粤AGP3642": "华南区域",
"粤AGP3645": "华南区域",
"粤AGP3649": "华东区域",
"粤AGP3651": "华东区域",
"粤AGP3659": "华东区域",
"粤AGP3660": "华南区域",
"粤AGP3667": "华南区域",
"粤AGP3672": "华南区域",
"粤AGP3673": "西南区域",
"粤AGP3690": "华东区域",
"粤AGP3692": "华东区域",
"粤AGP3695": "华东区域",
"粤AGP4223": "华南区域",
"粤AGP4318": "华东区域",
"粤AGP4321": "华东区域",
"粤AGP4325": "华南区域",
"粤AGP4335": "华东区域",
"粤AGP4355": "华东区域",
"粤AGP4377": "华东区域",
"粤AGP4386": "华东区域",
"粤AGP4396": "西南区域",
"粤AGP4422": "华南区域",
"粤AGP4435": "华南区域",
"粤AGP4451": "华南区域",
"粤AGP4482": "华南区域",
"粤AGP4486": "华南区域",
"粤AGP4489": "华东区域",
"粤AGP4502": "华南区域",
"粤AGP4522": "华东区域",
"粤AGP4538": "华南区域",
"粤AGP4548": "华东区域",
"粤AGP4566": "华南区域",
"粤AGP4569": "华南区域",
"粤AGP4583": "华东区域",
"粤AGP4586": "华东区域",
"粤AGP4587": "西南区域",
"粤AGP4596": "华南区域",
"粤AGP4597": "华东区域",
"粤AGP4599": "华东区域",
"粤AGP4623": "华东区域",
"粤AGP4629": "华南区域",
"粤AGP5165": "华东区域",
"粤AGP5167": "华东区域",
"粤AGP5169": "华南区域",
"粤AGP5301": "华东区域",
"粤AGP5350": "华南区域",
"粤AGP5351": "华东区域",
"粤AGP5357": "华东区域",
"粤AGP5363": "华南区域",
"粤AGP5379": "华东区域",
"粤AGP5613": "华南区域",
"粤AGP5615": "华东区域",
"粤AGP5617": "华东区域",
"粤AGP5621": "西南区域",
"粤AGP5622": "华东区域",
"粤AGP5623": "华南区域",
"粤AGP5642": "华东区域",
"粤AGP5643": "西北区域",
"粤AGP5646": "华东区域",
"粤AGP5651": "华东区域",
"粤AGP5661": "华东区域",
"粤AGP5681": "华南区域",
"粤AGP5691": "华东区域",
"粤AGP5710": "华东区域",
"粤AGP5711": "西北区域",
"粤AGP5712": "华东区域",
"粤AGP5719": "华南区域",
"粤AGP5749": "华东区域",
"粤AGP5760": "华东区域",
"粤AGP5763": "华东区域",
"粤AGP5769": "华东区域",
"粤AGP5770": "华东区域",
"粤AGP5791": "西北区域",
"粤AGP5792": "华南区域",
"粤AGP5797": "华东区域",
"粤AGP7016": "华南区域",
"粤AGP7019": "西南区域",
"粤AGP7022": "华南区域",
"粤AGP7026": "华东区域",
"粤AGP7047": "华东区域",
"粤AGP9330": "华南区域",
"粤AGP9346": "华东区域",
"粤AGP9347": "华南区域",
"粤AGP9350": "华东区域",
"粤AGP9351": "华南区域",
"粤AGP9702": "华东区域",
"粤AGP9703": "西北区域",
"粤AGP9706": "华东区域",
"粤AGP9707": "华东区域",
"粤AGP9713": "华东区域",
"粤AGP9717": "华南区域",
"粤AGP9721": "华东区域",
"粤AGP9726": "华南区域",
"粤AGP9731": "华南区域",
"粤AGP9735": "华东区域",
"粤AGP9739": "华南区域",
"粤AGP9751": "华南区域",
"粤AGP9753": "华东区域",
"粤AGP9755": "华东区域",
"粤AGP9759": "华东区域",
"粤AGP9782": "华东区域",
"粤AGP9790": "华南区域",
"粤AGP9791": "华东区域",
"粤AGP9817": "华南区域",
"粤AGP9827": "华南区域",
"粤AGP9836": "华南区域"
}

View File

@@ -14,6 +14,7 @@ export interface CachedVehicle {
rentStatus: string | null;
entity: string | null;
project: string | null;
region: string | null;
yesterdayKm: number;
}
@@ -33,6 +34,7 @@ export interface MonitoringFilters {
rentStatuses: string[];
platePrefixes: PlatePrefix[];
targetNames: string[];
regions: string[];
}
/** 监控缓存 */