feat(mileage): 外部三选筛选 + 车牌多选粘贴 + 运营区域 + xlsx 下载
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
110
package-lock.json
generated
110
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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('');
|
||||
};
|
||||
|
||||
198
src/modules/mileage/PlateMultiSelect.tsx
Normal file
198
src/modules/mileage/PlateMultiSelect.tsx
Normal 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="粘贴或输入车牌 支持换行/逗号/空格分隔"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
81
src/modules/mileage/xlsx-export.ts
Normal file
81
src/modules/mileage/xlsx-export.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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') || '',
|
||||
};
|
||||
|
||||
138
src/server/routes/mileage/region-map.json
Normal file
138
src/server/routes/mileage/region-map.json
Normal 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": "华南区域"
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/** 监控缓存 */
|
||||
|
||||
Reference in New Issue
Block a user