feat: 资产总览新增所属公司筛选,支持按归属主体过滤全页数据
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

- 后端:新增 /api/vehicles/subjects 端点返回公司列表+台数预览;所有聚合端点接受 ?subject= 参数按 tab_truck.org_id 对应的主体公司过滤
- 前端:标题下方新增 Scope Chip 单选下拉,支持搜索+台数预览,选中后全页 KPI/汇总/库存统计按公司联动刷新

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-15 16:50:25 +08:00
parent d6c31dd2b6
commit 820fde5547
3 changed files with 260 additions and 58 deletions

View File

@@ -30,10 +30,11 @@ import {
LabelList, LabelList,
} from 'recharts'; } from 'recharts';
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types'; import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api'; import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, type SubjectOption } from './api';
import type { WeeklyDetailItem } from './api'; import type { WeeklyDetailItem } from './api';
import { SearchSelect } from '../../components/SearchSelect'; import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect';
import Blur from '../../components/Blur';
// --- Constants --- // --- Constants ---
@@ -57,6 +58,13 @@ export default function AssetsModule() {
} }
}, [activeTab]); }, [activeTab]);
const [theme, setTheme] = useState<'soft' | 'minimal' | 'vibrant'>('soft'); const [theme, setTheme] = useState<'soft' | 'minimal' | 'vibrant'>('soft');
// 所属公司(归属主体)筛选 —— 影响全页聚合
const [selectedSubject, setSelectedSubject] = useState<string | null>(null);
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
const [subjectDropdownOpen, setSubjectDropdownOpen] = useState(false);
const [subjectSearch, setSubjectSearch] = useState('');
const subjectDropdownRef = useRef<HTMLDivElement>(null);
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set()); const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
const [expandedAssetTypes, setExpandedAssetTypes] = useState<Set<string>>(new Set()); const [expandedAssetTypes, setExpandedAssetTypes] = useState<Set<string>>(new Set());
const [showPlateNumbers, setShowPlateNumbers] = useState<{ const [showPlateNumbers, setShowPlateNumbers] = useState<{
@@ -140,12 +148,12 @@ export default function AssetsModule() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const [s, byType, dept, region, cust, inv] = await Promise.all([ const [s, byType, dept, region, cust, inv] = await Promise.all([
fetchSummary(), fetchSummary(selectedSubject),
fetchByType(), fetchByType(selectedSubject),
fetchDeptStats(), fetchDeptStats(selectedSubject),
fetchRegionStats(), fetchRegionStats(undefined, selectedSubject),
fetchCustomerStats(), fetchCustomerStats(selectedSubject),
fetchInventoryStats(), fetchInventoryStats(selectedSubject),
]); ]);
setSummary(s); setSummary(s);
setProcessedData(byType); setProcessedData(byType);
@@ -159,7 +167,7 @@ export default function AssetsModule() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [selectedSubject]);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -167,22 +175,43 @@ export default function AssetsModule() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadData]); }, [loadData]);
// 归属公司列表(仅首次加载,公司集合相对稳定)
useEffect(() => {
fetchSubjects().then(setSubjects).catch(() => setSubjects([]));
}, []);
// 点击外部关闭归属公司下拉
useEffect(() => {
if (!subjectDropdownOpen) return;
const handler = (e: MouseEvent) => {
if (subjectDropdownRef.current && !subjectDropdownRef.current.contains(e.target as Node)) {
setSubjectDropdownOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [subjectDropdownOpen]);
// Re-fetch region data when filters change // Re-fetch region data when filters change
useEffect(() => { useEffect(() => {
const hasFilter = regionFilters.customer || regionFilters.city || regionFilters.region; const hasFilter = regionFilters.customer || regionFilters.city || regionFilters.region;
if (hasFilter) { if (hasFilter) {
fetchRegionStats({ customer: regionFilters.customer || undefined, city: regionFilters.city || undefined, region: regionFilters.region || undefined }) fetchRegionStats(
.then(setRegionData).catch(() => {}); { customer: regionFilters.customer || undefined, city: regionFilters.city || undefined, region: regionFilters.region || undefined },
selectedSubject,
).then(setRegionData).catch(() => {});
} else { } else {
// No filters: use data from the main loadData cycle // No filters: use data from the main loadData cycle
fetchRegionStats().then(setRegionData).catch(() => {}); fetchRegionStats(undefined, selectedSubject).then(setRegionData).catch(() => {});
} }
}, [regionFilters]); }, [regionFilters, selectedSubject]);
// Fetch region chart data when view changes // Fetch region chart data when view changes
useEffect(() => { useEffect(() => {
fetchRegionChart(regionChartView, regionChartView === 'province' ? 5 : 8).then(setRegionChartData).catch(() => setRegionChartData([])); fetchRegionChart(regionChartView, regionChartView === 'province' ? 5 : 8, 'realtime', selectedSubject)
}, [regionChartView]); .then(setRegionChartData)
.catch(() => setRegionChartData([]));
}, [regionChartView, selectedSubject]);
// Load modal vehicles // Load modal vehicles
useEffect(() => { useEffect(() => {
@@ -235,11 +264,11 @@ export default function AssetsModule() {
else if (showPlateNumbers.isTrailer === false) params.vehicleType = '其他'; else if (showPlateNumbers.isTrailer === false) params.vehicleType = '其他';
} }
} }
fetchVehicleList(params) fetchVehicleList({ ...params, subject: selectedSubject })
.then(setModalVehicles) .then(setModalVehicles)
.catch(() => setModalVehicles([])) .catch(() => setModalVehicles([]))
.finally(() => setModalLoading(false)); .finally(() => setModalLoading(false));
}, [showPlateNumbers]); }, [showPlateNumbers, selectedSubject]);
const allTypesExpanded = processedData.length > 0 && processedData.every((t) => expandedAssetTypes.has(t.type)); const allTypesExpanded = processedData.length > 0 && processedData.every((t) => expandedAssetTypes.has(t.type));
@@ -439,9 +468,9 @@ export default function AssetsModule() {
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]); const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
useEffect(() => { useEffect(() => {
if (customerChartView === 'province') { if (customerChartView === 'province') {
fetchRegionChart('province', 5, 'vehicle').then(setCustomerProvinceData).catch(() => setCustomerProvinceData([])); fetchRegionChart('province', 5, 'vehicle', selectedSubject).then(setCustomerProvinceData).catch(() => setCustomerProvinceData([]));
} }
}, [customerChartView]); }, [customerChartView, selectedSubject]);
const customerPieData = useMemo(() => { const customerPieData = useMemo(() => {
if (customerChartView === 'region') { if (customerChartView === 'region') {
@@ -512,6 +541,115 @@ export default function AssetsModule() {
</div> </div>
</div> </div>
</div> </div>
{/* 归属公司作用域筛选 (Scope Chip) */}
<div className="flex items-center justify-center px-4 pt-1">
<div className="relative" ref={subjectDropdownRef}>
<button
type="button"
onClick={() => {
setSubjectDropdownOpen((o) => !o);
setSubjectSearch('');
}}
className={`group inline-flex items-center gap-1.5 h-7 pl-2.5 pr-2 rounded-full border text-[11px] font-normal transition-all cursor-pointer ${
selectedSubject
? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100'
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-300 hover:text-gray-700'
}`}
title={selectedSubject || '全部公司'}
>
<Filter size={11} className={selectedSubject ? 'text-blue-500' : 'text-gray-400'} />
<span className="max-w-[180px] truncate">
{selectedSubject || '全部公司'}
</span>
{selectedSubject ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setSelectedSubject(null);
}}
className="ml-0.5 w-3.5 h-3.5 inline-flex items-center justify-center rounded-full text-blue-500 hover:bg-blue-200 hover:text-blue-700 cursor-pointer"
aria-label="清除归属公司筛选"
>
×
</span>
) : (
<ChevronDown size={11} className="text-gray-400" />
)}
</button>
{subjectDropdownOpen && (
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1.5 w-[320px] max-h-[380px] bg-white border border-gray-200 rounded-lg shadow-lg z-50 flex flex-col">
<div className="p-2 border-b border-gray-100">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
<input
autoFocus
value={subjectSearch}
onChange={(e) => 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"
/>
</div>
</div>
<div className="overflow-y-auto flex-1 py-1">
<button
type="button"
onClick={() => {
setSelectedSubject(null);
setSubjectDropdownOpen(false);
}}
className={`w-full flex items-center justify-between px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
!selectedSubject ? 'text-blue-600 font-medium' : 'text-gray-700'
}`}
>
<span className="flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full ${!selectedSubject ? 'bg-blue-500' : 'bg-gray-300'}`} />
</span>
<span className="text-[10px] text-gray-400">
{subjects.reduce((s, x) => s + x.total, 0)}
</span>
</button>
<div className="my-1 mx-3 border-t border-gray-100" />
{subjects
.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase()))
.map((s) => {
const active = selectedSubject === s.name;
return (
<button
key={s.name}
type="button"
onClick={() => {
setSelectedSubject(s.name);
setSubjectDropdownOpen(false);
}}
className={`w-full flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
active ? 'text-blue-600 font-medium bg-blue-50/40' : 'text-gray-700'
}`}
title={s.name}
>
<span className="flex items-center gap-1.5 min-w-0">
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${active ? 'bg-blue-500' : 'bg-gray-300'}`} />
<span className="truncate">{s.name}</span>
</span>
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
{s.total}
<span className="mx-1 text-gray-200">·</span>
<span className="text-green-500"> {s.operating}</span>
</span>
</button>
);
})}
{subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && (
<div className="px-3 py-6 text-center text-[11px] text-gray-400"></div>
)}
</div>
</div>
)}
</div>
</div>
{/* Tab row */} {/* Tab row */}
<div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar"> <div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar">
{TABS.map(tab => ( {TABS.map(tab => (
@@ -1488,7 +1626,7 @@ export default function AssetsModule() {
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />} {isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
<span className="font-bold text-gray-700 text-xs">{m.manager}</span> <span className="font-bold text-gray-700 text-xs"><Blur>{m.manager}</Blur></span>
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -1570,7 +1708,7 @@ export default function AssetsModule() {
> >
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-1"> <td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-1">
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />} {isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
{m.manager} <Blur>{m.manager}</Blur>
</td> </td>
<td className="p-2 border-r border-gray-100 text-gray-600">{m.department}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{m.department}</td>
<td <td
@@ -1690,7 +1828,7 @@ export default function AssetsModule() {
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />} {isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
<span className="text-[11px] font-bold text-gray-700">{m.manager}</span> <span className="text-[11px] font-bold text-gray-700"><Blur>{m.manager}</Blur></span>
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -1769,7 +1907,7 @@ export default function AssetsModule() {
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />} {isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<h3 className="text-sm font-bold text-gray-800 shrink-0">{m.manager}</h3> <h3 className="text-sm font-bold text-gray-800 shrink-0"><Blur>{m.manager}</Blur></h3>
<span className="text-[11px] text-gray-500 shrink-0">{m.department}</span> <span className="text-[11px] text-gray-500 shrink-0">{m.department}</span>
<div <div
className="text-[11px] font-bold text-blue-600 whitespace-nowrap" className="text-[11px] font-bold text-blue-600 whitespace-nowrap"
@@ -2293,12 +2431,12 @@ export default function AssetsModule() {
> >
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-2"> <td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-2">
{isExpanded ? <ChevronDown size={14} className="text-emerald-600" /> : <ChevronRight size={14} className="text-gray-400" />} {isExpanded ? <ChevronDown size={14} className="text-emerald-600" /> : <ChevronRight size={14} className="text-gray-400" />}
{cust.customer} <Blur>{cust.customer}</Blur>
</td> </td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"> <td className="p-2 border-r border-gray-100 text-gray-600 text-center">
<span className="bg-gray-100 px-2 py-0.5 rounded text-[10px] font-medium">{cust.region}</span> <span className="bg-gray-100 px-2 py-0.5 rounded text-[10px] font-medium">{cust.region}</span>
</td> </td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{cust.manager}</td> <td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{cust.manager}</Blur></td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: false, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T` }); }}>{cust.t4_5}</td> <td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: false, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T` }); }}>{cust.t4_5}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: true, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T冷链` }); }}>{cust.t4_5c}</td> <td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: true, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T冷链` }); }}>{cust.t4_5c}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` }); }}>{cust.t18}</td> <td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` }); }}>{cust.t18}</td>
@@ -2313,7 +2451,7 @@ export default function AssetsModule() {
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div> <div className="text-[10px] text-gray-400 uppercase mb-1"></div>
<div className="text-sm font-bold text-gray-700">{cust.customer}</div> <div className="text-sm font-bold text-gray-700"><Blur>{cust.customer}</Blur></div>
</div> </div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div> <div className="text-[10px] text-gray-400 uppercase mb-1"></div>
@@ -2323,7 +2461,7 @@ export default function AssetsModule() {
</div> </div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div> <div className="text-[10px] text-gray-400 uppercase mb-1"></div>
<div className="text-sm font-bold text-gray-700">{cust.manager}</div> <div className="text-sm font-bold text-gray-700"><Blur>{cust.manager}</Blur></div>
</div> </div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div> <div className="text-[10px] text-gray-400 uppercase mb-1"></div>
@@ -2357,7 +2495,7 @@ export default function AssetsModule() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />} {isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />}
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-bold text-gray-800 text-sm">{cust.customer}</span> <span className="font-bold text-gray-800 text-sm"><Blur>{cust.customer}</Blur></span>
<span className="text-[10px] text-emerald-600 font-medium">{cust.region}</span> <span className="text-[10px] text-emerald-600 font-medium">{cust.region}</span>
</div> </div>
</div> </div>
@@ -2375,7 +2513,7 @@ export default function AssetsModule() {
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1"></div> <div className="text-[8px] text-gray-400 uppercase mb-1"></div>
<div className="text-[10px] font-bold text-gray-700">{cust.customer}</div> <div className="text-[10px] font-bold text-gray-700"><Blur>{cust.customer}</Blur></div>
</div> </div>
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1"></div> <div className="text-[8px] text-gray-400 uppercase mb-1"></div>
@@ -2385,7 +2523,7 @@ export default function AssetsModule() {
</div> </div>
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1"></div> <div className="text-[8px] text-gray-400 uppercase mb-1"></div>
<div className="text-xs font-bold text-gray-700">{cust.manager}</div> <div className="text-xs font-bold text-gray-700"><Blur>{cust.manager}</Blur></div>
</div> </div>
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1"></div> <div className="text-[8px] text-gray-400 uppercase mb-1"></div>
@@ -2580,8 +2718,8 @@ export default function AssetsModule() {
<tbody className="text-[11px]"> <tbody className="text-[11px]">
{filteredModalWeeklyDetail.map((v, i) => ( {filteredModalWeeklyDetail.map((v, i) => (
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}> <tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center">{v.plate_number}</td> <td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.customer_name || '—'}</td> <td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td> <td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
</tr> </tr>
))} ))}
@@ -2628,12 +2766,12 @@ export default function AssetsModule() {
{showPlateNumbers.source === 'customer' ? ( {showPlateNumbers.source === 'customer' ? (
<> <>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
<td className="p-2 border-r border-gray-100 font-medium text-gray-700">{v.customerManager || '—'}</td> <td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]">{v.subjectOrg || '—'}</td> <td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 font-bold text-gray-800">{v.customerName || '—'}</td> <td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td> <td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-center"> <td className="p-2 border-r border-gray-100 text-center">
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${ <span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
v.status === 'Operating' ? 'bg-green-100 text-green-700' : v.status === 'Operating' ? 'bg-green-100 text-green-700' :
@@ -2646,13 +2784,13 @@ export default function AssetsModule() {
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td> <td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td> <td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
<td className="p-2 text-gray-500 text-[10px]">{v.orgName || '—'}</td> <td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
</> </>
) : ( ) : (
<> <>
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td> <td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && ( {showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center">{v.customerName || '—'}</td> <td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
)} )}
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td> <td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td> <td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>

View File

@@ -11,12 +11,29 @@ import { fetchJson } from '../../auth/api-client';
const BASE = '/api/vehicles'; const BASE = '/api/vehicles';
export async function fetchSummary(): Promise<SummaryData> { export interface SubjectOption {
return fetchJson<SummaryData>(`${BASE}/summary`); name: string;
total: number;
inventory: number;
operating: number;
} }
export async function fetchByType(): Promise<TypeSummary[]> { function withSubject(path: string, subject?: string | null): string {
return fetchJson<TypeSummary[]>(`${BASE}/by-type`); if (!subject) return path;
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}subject=${encodeURIComponent(subject)}`;
}
export async function fetchSubjects(): Promise<SubjectOption[]> {
return fetchJson<SubjectOption[]>(`${BASE}/subjects`);
}
export async function fetchSummary(subject?: string | null): Promise<SummaryData> {
return fetchJson<SummaryData>(withSubject(`${BASE}/summary`, subject));
}
export async function fetchByType(subject?: string | null): Promise<TypeSummary[]> {
return fetchJson<TypeSummary[]>(withSubject(`${BASE}/by-type`, subject));
} }
export async function fetchVehicleList(params: { export async function fetchVehicleList(params: {
@@ -32,6 +49,7 @@ export async function fetchVehicleList(params: {
isTrailer?: string; isTrailer?: string;
department?: string; department?: string;
attendance?: string; attendance?: string;
subject?: string | null;
}): Promise<VehicleListItem[]> { }): Promise<VehicleListItem[]> {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params.batch) query.set('batch', params.batch); if (params.batch) query.set('batch', params.batch);
@@ -46,6 +64,7 @@ export async function fetchVehicleList(params: {
if (params.isTrailer) query.set('isTrailer', params.isTrailer); if (params.isTrailer) query.set('isTrailer', params.isTrailer);
if (params.department) query.set('department', params.department); if (params.department) query.set('department', params.department);
if (params.attendance) query.set('attendance', params.attendance); if (params.attendance) query.set('attendance', params.attendance);
if (params.subject) query.set('subject', params.subject);
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`); return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
} }
@@ -57,29 +76,40 @@ export interface WeeklyDetailItem {
customer_name: string | null; customer_name: string | null;
} }
export async function fetchDeptStats(): Promise<DeptGroup[]> { export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
return fetchJson<DeptGroup[]>(`${BASE}/dept-stats`); return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
} }
export async function fetchRegionStats(params?: { customer?: string; city?: string; region?: string }): Promise<RegionGroup[]> { export async function fetchRegionStats(
params?: { customer?: string; city?: string; region?: string },
subject?: string | null,
): Promise<RegionGroup[]> {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.customer) query.set('customer', params.customer); if (params?.customer) query.set('customer', params.customer);
if (params?.city) query.set('city', params.city); if (params?.city) query.set('city', params.city);
if (params?.region) query.set('region', params.region); if (params?.region) query.set('region', params.region);
if (subject) query.set('subject', subject);
const qs = query.toString(); const qs = query.toString();
return fetchJson<RegionGroup[]>(`${BASE}/region-stats${qs ? `?${qs}` : ''}`); return fetchJson<RegionGroup[]>(`${BASE}/region-stats${qs ? `?${qs}` : ''}`);
} }
export async function fetchCustomerStats(): Promise<CustomerStats[]> { export async function fetchCustomerStats(subject?: string | null): Promise<CustomerStats[]> {
return fetchJson<CustomerStats[]>(`${BASE}/customer-stats`); return fetchJson<CustomerStats[]>(withSubject(`${BASE}/customer-stats`, subject));
} }
export async function fetchInventoryStats(): Promise<RegionalInventoryStats[]> { export async function fetchInventoryStats(subject?: string | null): Promise<RegionalInventoryStats[]> {
return fetchJson<RegionalInventoryStats[]>(`${BASE}/inventory-stats`); return fetchJson<RegionalInventoryStats[]>(withSubject(`${BASE}/inventory-stats`, subject));
} }
export async function fetchRegionChart(groupBy: string, top = 8, source = 'realtime'): Promise<{ name: string; value: number }[]> { export async function fetchRegionChart(
return fetchJson<{ name: string; value: number }[]>(`${BASE}/region-chart?groupBy=${groupBy}&top=${top}&source=${source}`); groupBy: string,
top = 8,
source = 'realtime',
subject?: string | null,
): Promise<{ name: string; value: number }[]> {
return fetchJson<{ name: string; value: number }[]>(
withSubject(`${BASE}/region-chart?groupBy=${groupBy}&top=${top}&source=${source}`, subject),
);
} }
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> { export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {

View File

@@ -313,11 +313,21 @@ async function getVehicles(): Promise<Vehicle[]> {
async function getVehiclesForUser(c: Context): Promise<Vehicle[]> { async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
const all = await getVehicles(); const all = await getVehicles();
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined; const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
if (user) { let list = user ? filterByPermission(all, user) : all;
const filtered = filterByPermission(all, user); list = applySubjectFilter(c, list);
return maskCustomerNames(filtered); return maskCustomerNames(list);
} }
return maskCustomerNames(all);
// 归属公司筛选(所属公司 = tab_truck.org_id → org_name, 即 Vehicle.subjectOrg
function getSubjectParam(c: Context): string | null {
const raw = (c.req.query('subject') || '').trim();
return raw ? raw : null;
}
function applySubjectFilter(c: Context, vehicles: Vehicle[]): Vehicle[] {
const subject = getSubjectParam(c);
if (!subject) return vehicles;
return vehicles.filter((v) => (v.subjectOrg || '') === subject);
} }
function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record<string, number> { function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record<string, number> {
@@ -611,7 +621,7 @@ app.get('/by-batch', async (c) => {
// GET /api/vehicles/inventory-analysis — 库存分析,不设数据权限,对所有人开放 // GET /api/vehicles/inventory-analysis — 库存分析,不设数据权限,对所有人开放
app.get('/inventory-analysis', async (c) => { app.get('/inventory-analysis', async (c) => {
const vehicles = await getVehicles(); const vehicles = applySubjectFilter(c, await getVehicles());
const typeFilters = [ const typeFilters = [
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') }, { name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
@@ -978,7 +988,7 @@ app.get('/list', async (c) => {
// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放 // GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放
app.get('/inventory-stats', async (c) => { app.get('/inventory-stats', async (c) => {
const vehicles = await getVehicles(); const vehicles = applySubjectFilter(c, await getVehicles());
const inventory = vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal'); const inventory = vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
const TYPE_NAME_MAP: Record<string, string> = { const TYPE_NAME_MAP: Record<string, string> = {
@@ -1037,6 +1047,30 @@ app.get('/weekly-detail', async (c) => {
return c.json(masked); return c.json(masked);
}); });
// GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉
app.get('/subjects', async (c) => {
const all = await getVehicles();
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
const visible = user ? filterByPermission(all, user) : all;
const map = new Map<string, { total: number; inventory: number; operating: number }>();
for (const v of visible) {
const name = (v.subjectOrg || '').trim();
if (!name) continue;
if (!map.has(name)) map.set(name, { total: 0, inventory: 0, operating: 0 });
const s = map.get(name)!;
s.total += 1;
if (v.status === 'Inventory' || v.status === 'Abnormal') s.inventory += 1;
if (v.status === 'Operating') s.operating += 1;
}
const result = Array.from(map.entries())
.map(([name, stats]) => ({ name, ...stats }))
.sort((a, b) => b.total - a.total);
return c.json(result);
});
// GET /api/vehicles/refresh — force cache refresh // GET /api/vehicles/refresh — force cache refresh
app.get('/refresh', async (c) => { app.get('/refresh', async (c) => {
lastFetchTime = 0; lastFetchTime = 0;