feat: improve asset inventory statistics and responsive UX (v1.1.16)

This commit is contained in:
shishengliang
2026-09-23 11:07:36 +08:00
parent 9b0980e349
commit b0cba26fbb
30 changed files with 626 additions and 498 deletions
+29 -29
View File
@@ -1,3 +1,4 @@
import './assets.css';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Truck,
@@ -129,6 +130,8 @@ export default function AssetsModule() {
const [regionChartData, setRegionChartData] = useState<{ name: string; value: number }[]>([]);
// Modal filter state
const [modalError, setModalError] = useState<string | null>(null);
const [modalRetry, setModalRetry] = useState(0);
const [modalFilters, setModalFilters] = useState({ plateNumber: '', model: '', brand: '', location: '' });
const [isModalFilterExpanded, setIsModalFilterExpanded] = useState(false);
@@ -227,30 +230,22 @@ export default function AssetsModule() {
.catch(() => setRegionChartData([]));
}, [regionChartView, selectedSubject]);
// Load modal vehicles
// Keep stale requests from overwriting a newer selection or a closed dialog.
useEffect(() => {
if (!showPlateNumbers) {
setModalVehicles([]);
setModalWeeklyDetail([]);
return;
}
let cancelled = false;
setModalVehicles([]);
setModalWeeklyDetail([]);
setModalError(null);
if (!showPlateNumbers) return;
setModalLoading(true);
const request = buildVehicleModalRequest(showPlateNumbers, selectedSubject);
if (request.kind === 'weekly') {
setModalVehicles([]);
fetchWeeklyDetail(request.type, request.filters)
.then(setModalWeeklyDetail)
.catch(() => setModalWeeklyDetail([]))
.finally(() => setModalLoading(false));
return;
}
setModalWeeklyDetail([]);
fetchVehicleList(request.params)
.then(setModalVehicles)
.catch(() => setModalVehicles([]))
.finally(() => setModalLoading(false));
}, [showPlateNumbers, selectedSubject]);
const load = request.kind === 'weekly'
? fetchWeeklyDetail(request.type, request.filters).then(rows=>{if(!cancelled)setModalWeeklyDetail(rows);})
: fetchVehicleList(request.params).then(rows=>{if(!cancelled)setModalVehicles(rows);});
load.catch(()=>{if(!cancelled)setModalError('车辆明细加载失败,请重试。');})
.finally(()=>{if(!cancelled)setModalLoading(false);});
return ()=>{cancelled=true;};
}, [showPlateNumbers, selectedSubject, modalRetry]);
const allTypesExpanded = processedData.length > 0 && processedData.every((t) => expandedAssetTypes.has(t.type));
@@ -473,7 +468,7 @@ export default function AssetsModule() {
const pendingRate = SUMMARY.totalAssets > 0 ? SUMMARY.pendingDelivery / SUMMARY.totalAssets * 100 : 0;
return (
<div className="min-h-screen bg-[var(--app-bg)] text-gray-800 font-sans p-3 md:p-6 relative">
<div className="asset-workspace min-h-screen bg-[var(--app-bg)] text-gray-800 font-sans p-3 md:p-6 relative">
<AssetsHeader
activeTab={activeTab}
setActiveTab={setActiveTab}
@@ -481,6 +476,7 @@ export default function AssetsModule() {
setTheme={setTheme}
lastUpdate={lastUpdate}
loading={loading}
onRefresh={loadData}
selectedSubject={selectedSubject}
setSelectedSubject={setSelectedSubject}
subjects={subjects}
@@ -491,6 +487,8 @@ export default function AssetsModule() {
subjectDropdownRef={subjectDropdownRef}
/>
{error && summary && <div role="alert" className="mb-4 flex items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"><span>更新失败,当前显示上次成功加载的数据。</span><button type="button" onClick={loadData} className="shrink-0 rounded-lg px-3 py-2 font-semibold underline">重试</button></div>}
{activeTab !== 'overview' && <p className="mb-4 text-xs text-slate-500">{activeTab==='department'?'按部门与负责人查看运营车辆,点击数量查看明细。':activeTab==='region'?'查看各区域的车辆分布,展开区域继续查看城市与车型。':'按客户查看运营车辆,客户名称已脱敏;点击合计查看车辆明细。'}</p>}
{/* Main Content Area */}
<div className="flex flex-col gap-6">
@@ -1135,12 +1133,12 @@ export default function AssetsModule() {
<AnimatePresence>
{isRegionFilterOpen && (
<>
<div className="fixed inset-0 z-40" />
<button type="button" aria-label="关闭筛选" className="fixed inset-0 z-40 bg-slate-900/30" onClick={()=>setIsRegionFilterOpen(false)} />
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-gray-100 z-50 p-4 text-gray-800"
className="asset-filter-panel fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-gray-100 z-50 p-4 text-gray-800"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-gray-900">区域筛选</h3>
@@ -1346,7 +1344,7 @@ export default function AssetsModule() {
{/* Customer Operations Statistics Section */}
<section className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden mb-6 min-h-[420px]">
<div className="p-3 sm:p-4 border-b border-gray-50 flex items-center justify-between bg-emerald-800 text-white relative">
<div className="p-3 sm:p-4 border-b border-gray-50 flex items-center justify-between bg-blue-50 text-slate-800 relative">
<div className="flex items-center gap-3">
<div className="w-1.5 h-6 bg-emerald-400 rounded-full"></div>
<div>
@@ -1361,7 +1359,7 @@ export default function AssetsModule() {
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
isCustomerFilterOpen || Object.values(customerFilters).some(v => Array.isArray(v) ? v.length > 0 : v !== '')
? 'bg-emerald-400 text-emerald-900 shadow-lg shadow-emerald-900/20'
: 'bg-emerald-700/50 text-emerald-100 hover:bg-emerald-700'
: 'bg-white text-blue-700 hover:bg-blue-100'
}`}
>
<Filter size={14} />
@@ -1375,14 +1373,14 @@ export default function AssetsModule() {
{isCustomerFilterOpen && (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-40" />
<button type="button" aria-label="关闭筛选" className="fixed inset-0 z-40 bg-slate-900/30" onClick={()=>setIsCustomerFilterOpen(false)} />
{/* Popover Content */}
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-gray-100 z-50 p-4 text-gray-800"
className="asset-filter-panel fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-gray-100 z-50 p-4 text-gray-800"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-gray-900">数据筛选</h3>
@@ -1485,7 +1483,7 @@ export default function AssetsModule() {
<div className="hidden lg:block overflow-x-auto">
<table className="w-full text-left border-collapse min-w-[1000px]">
<thead>
<tr className="bg-emerald-700 text-white text-[11px] uppercase tracking-wider border-b border-emerald-800">
<tr className="bg-slate-100 text-slate-600 text-[11px] uppercase tracking-wider border-b border-emerald-800">
<th className="p-2 font-semibold border-r border-emerald-600 w-40">客户名称</th>
<th className="p-2 font-semibold border-r border-emerald-600 w-24">所在区域</th>
<th className="p-2 font-semibold border-r border-emerald-600 w-24">关联业务负责人</th>
@@ -1691,6 +1689,8 @@ export default function AssetsModule() {
brands={uniqueModalBrands}
locations={uniqueModalLocations}
loading={modalLoading}
error={modalError}
onRetry={()=>setModalRetry(v=>v+1)}
weeklyDetails={modalWeeklyDetail}
filteredWeeklyDetails={filteredModalWeeklyDetail}
filteredVehicles={filteredModalVehicles}