diff --git a/package-lock.json b/package-lock.json index e3f05c2..98a4ae1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ln-bi", - "version": "1.1.15", + "version": "1.1.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ln-bi", - "version": "1.1.15", + "version": "1.1.16", "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", "@hono/node-server": "^1.13.0", diff --git a/package.json b/package.json index fb2e3d1..4226b3c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ln-bi", "private": true, - "version": "1.1.15", + "version": "1.1.16", "type": "module", "scripts": { "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", diff --git a/src/components/SearchSelect.tsx b/src/components/SearchSelect.tsx index fc59a12..619871a 100644 --- a/src/components/SearchSelect.tsx +++ b/src/components/SearchSelect.tsx @@ -1,71 +1,33 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; +import { useState, useEffect, useMemo, useRef, useId } from 'react'; import { ChevronDown } from 'lucide-react'; export function SearchSelect({ value, onChange, options, placeholder, className }: { - value: string; - onChange: (v: string) => void; - options: string[]; - placeholder: string; - className?: string; + value: string; onChange: (value: string) => void; options: string[]; placeholder: string; className?: string; }) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(''); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); - - const filtered = useMemo(() => { - if (!query) return options; - const q = query.toLowerCase(); - return options.filter((o) => o.toLowerCase().includes(q)); - }, [options, query]); - - const displayValue = value || ''; - - return ( -
-
setOpen(!open)} - > - { setQuery(e.target.value); if (!open) setOpen(true); }} - onFocus={() => { setOpen(true); setQuery(''); }} - /> - -
- {open && ( -
-
{ onChange(''); setQuery(''); setOpen(false); }} - > - {placeholder} -
- {filtered.map((o) => ( -
{ onChange(o); setQuery(''); setOpen(false); }} - > - {o} -
- ))} - {filtered.length === 0 && ( -
无匹配项
- )} -
- )} + const [open,setOpen]=useState(false); + const [query,setQuery]=useState(''); + const [active,setActive]=useState(0); + const ref=useRef(null); + const inputRef=useRef(null); + const id=useId(); + const filtered=useMemo(()=>options.filter(option=>option.toLowerCase().includes(query.toLowerCase())),[options,query]); + const choices=['',...filtered]; + const choose=(option:string)=>{onChange(option);setQuery('');setOpen(false);}; + useEffect(()=>{const outside=(event:MouseEvent)=>{if(ref.current && !ref.current.contains(event.target as Node))setOpen(false);};document.addEventListener('mousedown',outside);return()=>document.removeEventListener('mousedown',outside);},[]); + useEffect(()=>{if(open)document.getElementById(`${id}-${active}`)?.scrollIntoView({block:'nearest'});},[open,active,id]); + return
+
+ setOpen(true)} onFocus={()=>{setOpen(true);setQuery('');setActive(0);}} + onChange={e=>{setQuery(e.target.value);setActive(0);setOpen(true);}} + onKeyDown={e=>{if(e.key==='Escape'){e.stopPropagation();setOpen(false);}else if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();setOpen(true);setActive(i=>(i+(e.key==='ArrowDown'?1:-1)+choices.length)%choices.length);}else if(e.key==='Enter'&&open){e.preventDefault();choose(choices[active]??'');}else if(e.key==='Tab')setOpen(false);}} + /> +
- ); + {open &&
+ {choices.map((option,index)=>)} + {filtered.length===0 &&

无匹配项

} +
} +
; } diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 3bec638..0ecf4a5 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -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(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 ( -
+
+ {error && summary &&
更新失败,当前显示上次成功加载的数据。
} + {activeTab !== 'overview' &&

{activeTab==='department'?'按部门与负责人查看运营车辆,点击数量查看明细。':activeTab==='region'?'查看各区域的车辆分布,展开区域继续查看城市与车型。':'按客户查看运营车辆,客户名称已脱敏;点击合计查看车辆明细。'}

} {/* Main Content Area */}
@@ -1135,12 +1133,12 @@ export default function AssetsModule() { {isRegionFilterOpen && ( <> -
+ - ))} -
-
-
- {/* 归属公司作用域筛选 (Scope Chip) */} -
-
- - {subjectDropdownOpen && ( -
-
-
- - setSubjectSearch(e.target.value)} - placeholder="搜索公司名" - className="w-full h-7 pl-6 pr-2 text-[11px] bg-gray-50 border border-gray-100 rounded focus:outline-none focus:border-blue-300 focus:bg-white" - /> -
-
-
- -
- {subjects - .filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())) - .map((s) => { - const active = selectedSubject === s.name; - return ( - - ); - })} - {subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && ( -
未找到匹配公司
- )} -
-
- )} -
-
- - {/* Tab row */} -
- {TABS.map(tab => ( - - ))} -
- {/* Status row */} -
-
- - 数据更新时间:{lastUpdate.slice(0, 16)} -
- - 运维台账每日统计口径:当日17:00 -
+export function AssetsHeader(props: AssetsHeaderProps) { + const {activeTab,setActiveTab,selectedSubject,setSelectedSubject,subjectDropdownOpen,setSubjectDropdownOpen} = props; + const chooseSubject = (subject: string | null) => {setSelectedSubject(subject);setSubjectDropdownOpen(false);}; + return
+
+

资产管理

运营、库存与交还车,一处查看

+
+ +
- - - ); +
+
+
+ + {selectedSubject && } + {subjectDropdownOpen &&
{if(e.key==='Escape'){setSubjectDropdownOpen(false);e.stopPropagation();}}}> + +
+ + {props.subjects.filter(s=>s.name.includes(props.subjectSearch)).map(s=>)} + {props.subjects.filter(s=>s.name.includes(props.subjectSearch)).length===0 &&

没有匹配的公司

} +
+
} +
+

更新于 {props.lastUpdate.slice(11,16)} · 运维台账截至当日 17:00

+
+ +
; } diff --git a/src/modules/assets/components/FlowDetailModal.tsx b/src/modules/assets/components/FlowDetailModal.tsx index 5935a00..54ee692 100644 --- a/src/modules/assets/components/FlowDetailModal.tsx +++ b/src/modules/assets/components/FlowDetailModal.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useRef, useCallback } from 'react'; +import { useDialogFocus } from './useDialogFocus'; import { Download, X } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import type { FlowDetailItem, FlowType } from '../api'; @@ -22,6 +23,9 @@ export function FlowDetailModal({ setSelectedFlow, exportFlowDetails, }: FlowDetailModalProps) { + const dialogRef = useRef(null); + const close = useCallback(()=>setSelectedFlow(null),[setSelectedFlow]); + useDialogFocus(Boolean(selectedFlow),dialogRef,close); const dateLabel = selectedFlow ? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`) : ''; @@ -32,6 +36,7 @@ export function FlowDetailModal({ {selectedFlow && (
运维签章时间口径 · 单位:车次
-

+

{dateLabel} · {FLOW_META[selectedFlow.type].label}明细

diff --git a/src/modules/assets/components/OverviewView.tsx b/src/modules/assets/components/OverviewView.tsx index a9f2189..4610a34 100644 --- a/src/modules/assets/components/OverviewView.tsx +++ b/src/modules/assets/components/OverviewView.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { AssetSummarySection } from './overview/AssetSummarySection'; import { FlowStatisticsCard } from './overview/FlowStatisticsCard'; import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection'; @@ -5,8 +6,9 @@ import { SummaryMetrics } from './overview/SummaryMetrics'; import type { OverviewViewProps } from './overview/types'; export function OverviewView(props: OverviewViewProps) { + const [panel, setPanel] = useState<'inventory' | 'models' | 'flow'>('inventory'); return ( - <> +
+ + + + + +
); } diff --git a/src/modules/assets/components/VehicleDetailModal.tsx b/src/modules/assets/components/VehicleDetailModal.tsx index fa8b609..b87e902 100644 --- a/src/modules/assets/components/VehicleDetailModal.tsx +++ b/src/modules/assets/components/VehicleDetailModal.tsx @@ -1,4 +1,7 @@ -import React from 'react'; +import React, { useRef, useCallback } from 'react'; +import { useDialogFocus } from './useDialogFocus'; +import { VehicleMobileCards } from './VehicleMobileCards'; +import { AbnormalVehicleDetails, PendingVehicleDetails } from './AbnormalVehicleDetails'; import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import Blur from '../../../components/Blur'; @@ -19,6 +22,8 @@ interface VehicleDetailModalProps { brands: string[]; locations: string[]; loading: boolean; + error?: string | null; + onRetry?: () => void; weeklyDetails: WeeklyDetailItem[]; filteredWeeklyDetails: WeeklyDetailItem[]; filteredVehicles: VehicleListItem[]; @@ -35,24 +40,30 @@ export function VehicleDetailModal({ brands: uniqueModalBrands, locations: uniqueModalLocations, loading: modalLoading, + error, + onRetry, weeklyDetails: modalWeeklyDetail, filteredWeeklyDetails: filteredModalWeeklyDetail, filteredVehicles: filteredModalVehicles, }: VehicleDetailModalProps) { + const dialogRef = useRef(null); + const close = useCallback(() => setShowPlateNumbers(null), [setShowPlateNumbers]); + useDialogFocus(Boolean(showPlateNumbers), dialogRef, close); return ( <> {/* Vehicle Detail Modal */} {showPlateNumbers && ( -
+
-

+

{showPlateNumbers.title || ( (showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` : @@ -67,11 +78,11 @@ export function VehicleDetailModal({ showPlateNumbers.category === 'Returned' ? '已还车' : showPlateNumbers.category === 'Replaced' ? '已替换' : showPlateNumbers.category === 'Abnormal' ? '异动' : - showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` : + showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location === 'All' ? '总' : showPlateNumbers.location}库存` : showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}

-
@@ -90,7 +101,7 @@ export function VehicleDetailModal({ {/* Quick Search always visible when collapsed */} {!isModalFilterExpanded && (
e.stopPropagation()}> - setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="快速搜索车牌..." className="text-[11px] py-1 px-2" /> + setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="搜索车牌 / VIN" className="text-[11px] py-1 px-2" />
)}
- {modalLoading ? ( + {error ? ( +

{error}

+ ) : modalLoading ? (
+ ) : showPlateNumbers.category === 'Abnormal' ? ( + + ) : showPlateNumbers.category === 'Pending' ? ( + ) : modalWeeklyDetail.length > 0 ? (
@@ -177,7 +194,9 @@ export function VehicleDetailModal({
) : ( -
+ <> + +
@@ -223,7 +242,7 @@ export function VehicleDetailModal({
{v.customerName || '—'} {v.plateNumber || v.vin || '—'} - @@ -259,12 +278,13 @@ export function VehicleDetailModal({
+ )}
- 共计 {filteredModalWeeklyDetail.length > 0 ? filteredModalWeeklyDetail.length : filteredModalVehicles.length} 台车辆 + 共计 {['Delivered','Returned','Replaced'].includes(showPlateNumbers.category || '') ? filteredModalWeeklyDetail.length : filteredModalVehicles.length} 台车辆