feat: 车牌区域筛选、型号批次筛选、回到顶部修复、删除涨跌幅
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增车牌区域筛选(粤/沪/浙+数量),替代旧地区代码 - 新增型号批次筛选(从考核目标名称筛选车辆) - 客户/部门增加"无值"选项筛选空值 - 修复回到顶部按钮在iOS上失效 - 删除KPI卡片涨跌幅百分比显示 - 全屏刷新按钮实际触发数据重新加载+加载动画 - 统计报表全屏刷新按钮修复 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -106,7 +106,8 @@ export default function MonitoringView() {
|
||||
const [filterProject, setFilterProject] = useState('All');
|
||||
const [filterEntity, setFilterEntity] = useState('All');
|
||||
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
||||
const [filterRegionCode, setFilterRegionCode] = useState('All');
|
||||
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
||||
const [filterTargetName, setFilterTargetName] = useState('All');
|
||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||
const [filterDate, setFilterDate] = useState(() => {
|
||||
@@ -117,7 +118,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: [] });
|
||||
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] });
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
@@ -141,6 +142,8 @@ export default function MonitoringView() {
|
||||
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,
|
||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
@@ -153,7 +156,7 @@ export default function MonitoringView() {
|
||||
setPage(1);
|
||||
setHasMore(d.page < d.totalPages);
|
||||
}).catch(() => {});
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlate, appliedMileageRange, filterDate]);
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, appliedMileageRange, filterDate]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
@@ -171,6 +174,8 @@ export default function MonitoringView() {
|
||||
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,
|
||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
@@ -226,8 +231,9 @@ export default function MonitoringView() {
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
window.scrollTo(0, 0);
|
||||
document.documentElement.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
};
|
||||
|
||||
const filteredVehicles = vehicles;
|
||||
@@ -249,6 +255,8 @@ export default function MonitoringView() {
|
||||
dept: filterDept !== 'All' ? filterDept : undefined,
|
||||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||
date: filterDate || undefined,
|
||||
}).then(d => {
|
||||
@@ -626,18 +634,29 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Region Code */}
|
||||
{/* Target Name */}
|
||||
<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
|
||||
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={filterRegionCode}
|
||||
onChange={(e) => setFilterRegionCode(e.target.value)}
|
||||
value={filterTargetName}
|
||||
onChange={(e) => setFilterTargetName(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
<option value="330400">330400 (嘉兴)</option>
|
||||
<option value="440100">440100 (广州)</option>
|
||||
<option value="110100">110100 (北京)</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>
|
||||
<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={filterPlatePrefix}
|
||||
onChange={(e) => setFilterPlatePrefix(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
{filterOptions.platePrefixes.map(p => <option key={p.prefix} value={p.prefix}>{p.prefix}({p.count})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -674,7 +693,7 @@ export default function MonitoringView() {
|
||||
setFilterCustomer('All');
|
||||
setFilterProject('All');
|
||||
setFilterEntity('All');
|
||||
setFilterRegionCode('All');
|
||||
setFilterPlatePrefix('All');
|
||||
setFilterMileageRange({ min: '', max: '' });
|
||||
setAppliedMileageRange({ min: '', max: '' });
|
||||
}}
|
||||
@@ -709,12 +728,13 @@ export default function MonitoringView() {
|
||||
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 (filterRegionCode !== 'All') tags.push({ label: `地区: ${filterRegionCode}`, onClear: () => setFilterRegionCode('All') });
|
||||
if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('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(''); setFilterRegionCode('All');
|
||||
setFilterPlate('All'); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All');
|
||||
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
|
||||
setFilterDate('');
|
||||
};
|
||||
@@ -741,11 +761,6 @@ export default function MonitoringView() {
|
||||
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
|
||||
{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()}
|
||||
<span className="text-[8px] text-slate-400">km</span>
|
||||
{sortBy === 'today' && stats.yesterdayTotal > 0 && (() => {
|
||||
const change = ((stats.totalToday - stats.yesterdayTotal) / stats.yesterdayTotal) * 100;
|
||||
const isUp = change >= 0;
|
||||
return <span className={`text-[9px] font-bold ${isUp ? 'text-blue-400' : 'text-rose-400'}`}>{isUp ? '\u2191' : '\u2193'}{Math.abs(change).toFixed(1)}%</span>;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
|
||||
|
||||
@@ -19,6 +19,8 @@ export async function fetchMonitoring(params?: {
|
||||
project?: string;
|
||||
entity?: string;
|
||||
rentStatus?: string;
|
||||
platePrefix?: string;
|
||||
targetName?: string;
|
||||
plate?: string;
|
||||
mileageMin?: string;
|
||||
mileageMax?: string;
|
||||
@@ -35,6 +37,8 @@ export async function fetchMonitoring(params?: {
|
||||
if (params?.project) query.set('project', params.project);
|
||||
if (params?.entity) query.set('entity', params.entity);
|
||||
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?.plate) query.set('plate', params.plate);
|
||||
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
||||
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface MonitoringFilters {
|
||||
projects: string[];
|
||||
entities: string[];
|
||||
rentStatuses: string[];
|
||||
platePrefixes: { prefix: string; count: number }[];
|
||||
targetNames: string[];
|
||||
}
|
||||
|
||||
export interface MonitoringData {
|
||||
|
||||
@@ -45,7 +45,8 @@ interface CachedVehicle {
|
||||
interface MonitoringCache {
|
||||
vehicles: CachedVehicle[];
|
||||
stats: { totalToday: number; totalAll: number; vehicleCount: number };
|
||||
filters: { departments: string[]; customers: string[]; plates: string[]; projects: string[]; entities: string[]; rentStatuses: string[] };
|
||||
filters: { departments: string[]; customers: string[]; plates: string[]; projects: string[]; entities: string[]; rentStatuses: string[]; platePrefixes: { prefix: string; count: number }[]; targetNames: string[] };
|
||||
targetPlatesMap: Map<string, Set<string>>;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ async function refreshMonitoringCache() {
|
||||
const start = Date.now();
|
||||
|
||||
// 并行查询两个数据库
|
||||
const [mileageResult, yesterdayResult, infoRows] = await Promise.all([
|
||||
const [mileageResult, yesterdayResult, infoRows, targetRows] = await Promise.all([
|
||||
(async () => {
|
||||
const [dateRows] = await mileagePool.execute(
|
||||
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
|
||||
@@ -85,6 +86,9 @@ async function refreshMonitoringCache() {
|
||||
return map;
|
||||
})(),
|
||||
pool.execute(VEHICLE_INFO_SQL).then(([rows]) => rows as any[]),
|
||||
pool.execute(`SELECT t.id, t.target_name, v.plate_number FROM tab_mileage_assessment_target t
|
||||
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
|
||||
WHERE t.is_deleted = 0`).then(([rows]) => rows as any[]),
|
||||
]);
|
||||
|
||||
// 车辆关联信息 map
|
||||
@@ -93,6 +97,17 @@ async function refreshMonitoringCache() {
|
||||
infoMap.set(row.plate, row);
|
||||
}
|
||||
|
||||
// 型号批次→车牌映射
|
||||
const targetNameMap = new Map<number, string>();
|
||||
const targetPlatesMap = new Map<string, Set<string>>(); // targetName -> plates
|
||||
for (const r of targetRows) {
|
||||
targetNameMap.set(r.id, r.target_name);
|
||||
const set = targetPlatesMap.get(r.target_name) || new Set();
|
||||
set.add(r.plate_number);
|
||||
targetPlatesMap.set(r.target_name, set);
|
||||
}
|
||||
const targetNames = Array.from(targetPlatesMap.keys());
|
||||
|
||||
// 去重:同一 plate 取 daily_km 最大的
|
||||
const mileageMap = new Map<string, any>();
|
||||
for (const row of mileageResult) {
|
||||
@@ -143,11 +158,18 @@ async function refreshMonitoringCache() {
|
||||
const projects = Array.from(new Set(vehicles.map(v => v.project).filter(Boolean))) as string[];
|
||||
const entities = Array.from(new Set(vehicles.map(v => v.entity).filter(Boolean))) as string[];
|
||||
const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter(Boolean))) as string[];
|
||||
const prefixCount = new Map<string, number>();
|
||||
for (const v of vehicles) {
|
||||
const p = v.plate.charAt(0);
|
||||
prefixCount.set(p, (prefixCount.get(p) || 0) + 1);
|
||||
}
|
||||
const platePrefixes = Array.from(prefixCount.entries()).map(([prefix, count]) => ({ prefix, count })).sort((a, b) => b.count - a.count);
|
||||
|
||||
monitoringCache = {
|
||||
vehicles,
|
||||
stats: { totalToday, totalAll, vehicleCount: vehicles.length },
|
||||
filters: { departments, customers, plates, projects, entities, rentStatuses },
|
||||
filters: { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames },
|
||||
targetPlatesMap,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -200,7 +222,7 @@ async function queryDateMileage(dateStr: string): Promise<{ vehicles: CachedVehi
|
||||
|
||||
// GET /monitoring — 从缓存取数据(或指定日期实时查询),支持筛选/排序/分页
|
||||
app.get('/monitoring', async (c) => {
|
||||
const emptyResponse = { vehicles: [], stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }, filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [] }, total: 0, page: 1, totalPages: 1, updatedAt: new Date().toISOString() };
|
||||
const emptyResponse = { vehicles: [], stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }, filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] }, total: 0, page: 1, totalPages: 1, updatedAt: new Date().toISOString() };
|
||||
|
||||
const sortBy = c.req.query('sortBy') || 'today';
|
||||
const sortOrder = c.req.query('sortOrder') || 'desc';
|
||||
@@ -213,6 +235,8 @@ app.get('/monitoring', async (c) => {
|
||||
const entity = c.req.query('entity') || '';
|
||||
const mileageMin = c.req.query('mileageMin') || '';
|
||||
const mileageMax = c.req.query('mileageMax') || '';
|
||||
const platePrefix = c.req.query('platePrefix') || '';
|
||||
const targetName = c.req.query('targetName') || '';
|
||||
const plate = c.req.query('plate') || '';
|
||||
const rentStatus = c.req.query('rentStatus') || '';
|
||||
const date = c.req.query('date') || '';
|
||||
@@ -236,6 +260,8 @@ app.get('/monitoring', async (c) => {
|
||||
projects: Array.from(new Set(allVehicles.map(v => v.project).filter(Boolean))) as string[],
|
||||
entities: Array.from(new Set(allVehicles.map(v => v.entity).filter(Boolean))) as string[],
|
||||
rentStatuses: Array.from(new Set(allVehicles.map(v => v.rentStatus).filter(Boolean))) as string[],
|
||||
platePrefixes: (() => { const m = new Map<string, number>(); for (const v of allVehicles) { const p = v.plate.charAt(0); m.set(p, (m.get(p) || 0) + 1); } return Array.from(m.entries()).map(([prefix, count]) => ({ prefix, count })).sort((a, b) => b.count - a.count); })(),
|
||||
targetNames: monitoringCache?.filters.targetNames || [],
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('monitoring date query error:', e);
|
||||
@@ -264,6 +290,12 @@ app.get('/monitoring', async (c) => {
|
||||
if (entity) vehicles = vehicles.filter(v => v.entity === entity);
|
||||
if (rentStatus) vehicles = vehicles.filter(v => v.rentStatus === rentStatus);
|
||||
if (plate) vehicles = vehicles.filter(v => v.plate === plate);
|
||||
if (platePrefix) vehicles = vehicles.filter(v => v.plate.startsWith(platePrefix));
|
||||
if (targetName && monitoringCache?.targetPlatesMap) {
|
||||
const tPlates = monitoringCache.targetPlatesMap.get(targetName);
|
||||
if (tPlates) vehicles = vehicles.filter(v => tPlates.has(v.plate));
|
||||
else vehicles = [];
|
||||
}
|
||||
if (mileageMin) vehicles = vehicles.filter(v => v.dailyKm >= Number(mileageMin));
|
||||
if (mileageMax) vehicles = vehicles.filter(v => v.dailyKm <= Number(mileageMax));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user