feat: add tab navigation, recharts charts, adapt to latest prototype
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
401
src/App.tsx
401
src/App.tsx
@@ -13,13 +13,38 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
|
Users,
|
||||||
|
MapPin,
|
||||||
|
Building2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
LabelList,
|
||||||
|
} 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 } from './api';
|
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats } from './api';
|
||||||
import type { WeeklyDetailItem } from './api';
|
import type { WeeklyDetailItem } from './api';
|
||||||
|
|
||||||
|
// --- Constants ---
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'overview', label: '总览' },
|
||||||
|
{ id: 'department', label: '按部门' },
|
||||||
|
{ id: 'region', label: '按区域' },
|
||||||
|
{ id: 'customer', label: '按客户' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
|
||||||
const [theme, setTheme] = useState<'soft' | 'minimal' | 'vibrant'>('soft');
|
const [theme, setTheme] = useState<'soft' | 'minimal' | 'vibrant'>('soft');
|
||||||
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());
|
||||||
@@ -77,6 +102,10 @@ export default function App() {
|
|||||||
const [inventoryFilters, setInventoryFilters] = useState({ region: '', city: '', brand: '', batch: '', model: '' });
|
const [inventoryFilters, setInventoryFilters] = useState({ region: '', city: '', brand: '', batch: '', model: '' });
|
||||||
const [isInventoryFilterOpen, setIsInventoryFilterOpen] = useState(false);
|
const [isInventoryFilterOpen, setIsInventoryFilterOpen] = useState(false);
|
||||||
|
|
||||||
|
// Chart view states
|
||||||
|
const [customerChartView, setCustomerChartView] = useState<'region' | 'city'>('region');
|
||||||
|
const [regionChartView, setRegionChartView] = useState<'region' | 'city'>('region');
|
||||||
|
|
||||||
// Modal filter state
|
// Modal filter state
|
||||||
const [modalFilters, setModalFilters] = useState({ plateNumber: '', model: '', brand: '', location: '' });
|
const [modalFilters, setModalFilters] = useState({ plateNumber: '', model: '', brand: '', location: '' });
|
||||||
const [isModalFilterExpanded, setIsModalFilterExpanded] = useState(false);
|
const [isModalFilterExpanded, setIsModalFilterExpanded] = useState(false);
|
||||||
@@ -358,50 +387,74 @@ export default function App() {
|
|||||||
const SUMMARY = summary!;
|
const SUMMARY = summary!;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6">
|
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 pb-20 md:pb-6">
|
||||||
{/* Main Title and Global Descriptions */}
|
{/* Compact Header Bar */}
|
||||||
<div className="mb-6 text-center relative">
|
<div className="sticky top-0 z-40 -mx-6 -mt-6 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">羚牛氢能车辆资产</h1>
|
{/* Title row */}
|
||||||
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-1 text-[11px] text-gray-500">
|
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<h1 className="text-base font-semibold text-gray-800 tracking-wide">羚牛氢能车辆资产</h1>
|
||||||
<span className="w-1 h-1 rounded-full bg-blue-500"></span>
|
{/* Right: status + theme */}
|
||||||
最后更新: {lastUpdate}
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||||
</div>
|
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
|
||||||
<div className="flex items-center gap-1.5">
|
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse inline-block" />
|
||||||
<span className="w-1 h-1 rounded-full bg-green-500"></span>
|
<span>{lastUpdate}</span>
|
||||||
每分钟更新
|
|
||||||
</div>
|
</div>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1 text-[10px] text-gray-400">
|
||||||
<Loader2 className="animate-spin" size={10} />
|
<Loader2 className="animate-spin" size={10} />
|
||||||
刷新中...
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
|
||||||
|
{(['soft','minimal','vibrant'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTheme(t)}
|
||||||
|
className={`px-2 py-0.5 rounded-md transition-all ${theme === t ? 'bg-white text-blue-600 shadow-sm font-semibold' : 'text-gray-400 hover:text-gray-600'}`}
|
||||||
|
>
|
||||||
|
{t === 'soft' ? '柔和' : t === 'minimal' ? '简约' : '经典'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Theme Switcher */}
|
</div>
|
||||||
<div className="absolute top-0 right-0 hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
|
{/* Tab row */}
|
||||||
|
<div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar">
|
||||||
|
{TABS.map(tab => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setTheme('soft')}
|
key={tab.id}
|
||||||
className={`px-2 py-1 rounded-md transition-all ${theme === 'soft' ? 'bg-white text-blue-600 shadow-sm font-bold' : 'text-gray-500 hover:text-gray-700'}`}
|
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||||
|
className={`relative px-4 py-2 text-[13px] font-normal transition-all whitespace-nowrap ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'text-blue-600 font-medium'
|
||||||
|
: 'text-gray-400 hover:text-gray-500'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
柔和
|
{tab.label}
|
||||||
</button>
|
{activeTab === tab.id && (
|
||||||
<button
|
<motion.div
|
||||||
onClick={() => setTheme('minimal')}
|
layoutId="activeTab"
|
||||||
className={`px-2 py-1 rounded-md transition-all ${theme === 'minimal' ? 'bg-white text-blue-600 shadow-sm font-bold' : 'text-gray-500 hover:text-gray-700'}`}
|
className="absolute bottom-0 left-2 right-2 h-[1.5px] bg-blue-600 rounded-full"
|
||||||
>
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
简约
|
/>
|
||||||
</button>
|
)}
|
||||||
<button
|
|
||||||
onClick={() => setTheme('vibrant')}
|
|
||||||
className={`px-2 py-1 rounded-md transition-all ${theme === 'vibrant' ? 'bg-white text-blue-600 shadow-sm font-bold' : 'text-gray-500 hover:text-gray-700'}`}
|
|
||||||
>
|
|
||||||
经典
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Status row */}
|
||||||
|
<div className="flex items-center justify-center gap-4 py-1.5 text-[10px] text-gray-400">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
|
||||||
|
最后更新: {lastUpdate}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-green-400 animate-pulse inline-block" />
|
||||||
|
每分钟更新一次
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<>
|
||||||
{/* Header Summary - Ultra Compact */}
|
{/* Header Summary - Ultra Compact */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2 mb-6">
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2 mb-6">
|
||||||
{/* Total Assets */}
|
{/* Total Assets */}
|
||||||
@@ -487,9 +540,13 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<>
|
||||||
{/* Asset Summary Table with Dimension Switch */}
|
{/* Asset Summary Table with Dimension Switch */}
|
||||||
<div className="bg-white rounded-sm border border-gray-100 shadow-sm overflow-hidden mb-6">
|
<div className="bg-white rounded-sm border border-gray-100 shadow-sm overflow-hidden mb-6">
|
||||||
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
||||||
@@ -601,18 +658,14 @@ export default function App() {
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => toggleAssetType(typeGroup.type)}
|
onClick={() => toggleAssetType(typeGroup.type)}
|
||||||
>
|
>
|
||||||
{expandedAssetTypes.has(typeGroup.type) ? (
|
|
||||||
<td colSpan={14} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} />
|
|
||||||
<span>{typeGroup.type}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<td className={`p-3 font-bold border-r border-gray-100 ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
<td className={`p-3 font-bold border-r border-gray-100 ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) ? (
|
||||||
|
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} />
|
||||||
|
) : (
|
||||||
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} />
|
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} />
|
||||||
|
)}
|
||||||
<span>{typeGroup.type}</span>
|
<span>{typeGroup.type}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -670,7 +723,6 @@ export default function App() {
|
|||||||
) : ''}
|
) : ''}
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -790,6 +842,79 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</motion.tr>
|
</motion.tr>
|
||||||
|
{expandedModels.has(model.model) && model.batches.map((batch) => (
|
||||||
|
<motion.tr
|
||||||
|
key={batch.batch}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="border-b border-gray-50 bg-purple-50/20"
|
||||||
|
>
|
||||||
|
<td className="p-3 border-r border-gray-100"></td>
|
||||||
|
<td className="p-3 border-r border-gray-100 pl-10 text-[10px] text-purple-600 italic truncate">{batch.batch}</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: batch.batch, model: model.model, location: 'All' }); }}
|
||||||
|
className="text-purple-500 hover:underline font-medium"
|
||||||
|
>{batch.total}</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100">
|
||||||
|
{batch.inventory > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: batch.batch, model: model.model, location: 'All', category: 'Inventory' }); }}
|
||||||
|
className="text-purple-500 hover:underline font-medium"
|
||||||
|
>{batch.inventory}</button>
|
||||||
|
) : ''}
|
||||||
|
</td>
|
||||||
|
{['嘉兴', '广东', '北京', '新疆', '其他'].map((reg) => (
|
||||||
|
<td key={reg} className="p-3 text-center border-r border-gray-100">
|
||||||
|
{(batch.inventoryRegions[reg] || 0) > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: batch.batch, model: model.model, location: reg, category: 'Inventory' }); }}
|
||||||
|
className="text-purple-500 hover:underline font-medium"
|
||||||
|
>{batch.inventoryRegions[reg]}</button>
|
||||||
|
) : ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="p-3 text-center border-r border-gray-100">
|
||||||
|
{batch.pending > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: batch.batch, model: model.model, location: 'All', category: 'Pending' }); }}
|
||||||
|
className="text-purple-500 hover:underline font-medium"
|
||||||
|
>{batch.pending}</button>
|
||||||
|
) : ''}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 bg-green-50/5">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: batch.batch, model: model.model, location: 'All', category: 'Operating' }); }}
|
||||||
|
className="text-purple-600 hover:underline font-medium"
|
||||||
|
>{batch.operating}</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 bg-blue-50/5">
|
||||||
|
{batch.weeklyDelivered > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: batch.batch, model: model.model, location: 'All', category: 'Delivered' }); }}
|
||||||
|
className="text-purple-500 hover:underline font-medium"
|
||||||
|
>{batch.weeklyDelivered}</button>
|
||||||
|
) : ''}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 bg-orange-50/5">
|
||||||
|
{batch.weeklyReturned > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: batch.batch, model: model.model, location: 'All', category: 'Returned' }); }}
|
||||||
|
className="text-purple-500 hover:underline font-medium"
|
||||||
|
>{batch.weeklyReturned}</button>
|
||||||
|
) : ''}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center bg-purple-50/5">
|
||||||
|
{batch.weeklyReplaced > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: batch.batch, model: model.model, location: 'All', category: 'Replaced' }); }}
|
||||||
|
className="text-purple-500 hover:underline font-medium"
|
||||||
|
>{batch.weeklyReplaced}</button>
|
||||||
|
) : ''}
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -1378,8 +1503,11 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Department Operations Statistics */}
|
{activeTab === 'department' && (
|
||||||
|
/* Department Operations Statistics */
|
||||||
<section className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden mb-6">
|
<section className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden mb-6">
|
||||||
<div className="p-3 sm:p-4 border-b border-gray-50 flex items-center justify-between gap-4">
|
<div className="p-3 sm:p-4 border-b border-gray-50 flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1902,6 +2030,61 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'region' && (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Region Distribution Chart */}
|
||||||
|
<section className="bg-white rounded-2xl shadow-sm border border-gray-100 p-4 sm:p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-800">区域资产分布概览</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setRegionChartView('region')}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${regionChartView === 'region' ? 'bg-white text-blue-700 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>按区域</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRegionChartView('city')}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${regionChartView === 'city' ? 'bg-white text-blue-700 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>按城市</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-64 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={(() => {
|
||||||
|
if (regionChartView === 'region') {
|
||||||
|
const regions: { [key: string]: number } = {};
|
||||||
|
customerData.forEach(item => {
|
||||||
|
regions[item.region] = (regions[item.region] || 0) + item.total;
|
||||||
|
});
|
||||||
|
return Object.entries(regions).map(([name, value]) => ({ name, value }));
|
||||||
|
} else {
|
||||||
|
const cities: { [key: string]: number } = {};
|
||||||
|
customerData.forEach(item => {
|
||||||
|
cities[item.city] = (cities[item.city] || 0) + item.total;
|
||||||
|
});
|
||||||
|
return Object.entries(cities)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
}
|
||||||
|
})()}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
|
||||||
|
cursor={{ fill: '#f8fafc' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={regionChartView === 'city' ? 20 : 40}>
|
||||||
|
<LabelList dataKey="value" position="top" style={{ fill: '#64748b', fontSize: 11, fontWeight: 600 }} />
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Region - Vehicle - Customer Section */}
|
{/* Region - Vehicle - Customer Section */}
|
||||||
<section className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden mb-6">
|
<section className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden mb-6">
|
||||||
@@ -2206,6 +2389,102 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'customer' && (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Customer Region Distribution Chart */}
|
||||||
|
<section className="bg-white rounded-2xl shadow-sm border border-gray-100 p-4 sm:p-6">
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-1.5 h-6 bg-emerald-500 rounded-full"></div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-800">客户运营地区占比</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setCustomerChartView('region')}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${customerChartView === 'region' ? 'bg-white text-emerald-600 shadow-sm' : 'text-gray-400 hover:text-gray-600'}`}
|
||||||
|
>按区域</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCustomerChartView('city')}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${customerChartView === 'city' ? 'bg-white text-emerald-600 shadow-sm' : 'text-gray-400 hover:text-gray-600'}`}
|
||||||
|
>按城市</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const PIE_COLORS = ['#6366f1','#06b6d4','#f59e0b','#f43f5e','#10b981','#a855f7','#94a3b8'];
|
||||||
|
let pieData: { name: string; value: number }[] = [];
|
||||||
|
if (customerChartView === 'region') {
|
||||||
|
const map: { [k: string]: number } = {};
|
||||||
|
customerData.forEach(item => { map[item.region] = (map[item.region] || 0) + item.total; });
|
||||||
|
pieData = Object.entries(map).map(([name, value]) => ({ name, value })).sort((a,b) => b.value - a.value);
|
||||||
|
} else {
|
||||||
|
const map: { [k: string]: number } = {};
|
||||||
|
customerData.forEach(item => { map[item.city] = (map[item.city] || 0) + item.total; });
|
||||||
|
const tot = Object.values(map).reduce((a,b) => a+b, 0);
|
||||||
|
const threshold = tot * 0.05;
|
||||||
|
let other = 0;
|
||||||
|
Object.entries(map).forEach(([name, value]) => {
|
||||||
|
if (value >= threshold) pieData.push({ name, value });
|
||||||
|
else other += value;
|
||||||
|
});
|
||||||
|
if (other > 0) pieData.push({ name: '其他', value: other });
|
||||||
|
pieData.sort((a,b) => b.value - a.value);
|
||||||
|
}
|
||||||
|
const grandTotal = pieData.reduce((s,d) => s + d.value, 0);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 sm:gap-8 items-center">
|
||||||
|
{/* Donut chart */}
|
||||||
|
<div className="relative flex-shrink-0" style={{ width: 200, height: 200 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%" cy="50%"
|
||||||
|
innerRadius={68} outerRadius={90}
|
||||||
|
paddingAngle={3}
|
||||||
|
startAngle={90} endAngle={-270}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{pieData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} stroke="white" strokeWidth={2} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [`${value} 辆`, '']}
|
||||||
|
contentStyle={{ borderRadius: '10px', border: 'none', boxShadow: '0 8px 24px -4px rgba(0,0,0,0.12)', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
{/* Center label */}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-2xl font-bold text-gray-800">{grandTotal}</span>
|
||||||
|
<span className="text-xs text-gray-400 mt-0.5">辆</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Custom legend */}
|
||||||
|
<div className="flex-1 w-full space-y-2.5">
|
||||||
|
{pieData.map((item, i) => {
|
||||||
|
const pct = grandTotal > 0 ? (item.value / grandTotal * 100) : 0;
|
||||||
|
const color = PIE_COLORS[i % PIE_COLORS.length];
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-2.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ background: color }} />
|
||||||
|
<span className="text-sm text-gray-600 flex-1 min-w-0 truncate">{item.name}</span>
|
||||||
|
<div className="w-20 h-1.5 bg-gray-100 rounded-full overflow-hidden flex-shrink-0">
|
||||||
|
<div className="h-full rounded-full transition-all" style={{ width: `${pct}%`, background: color }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-gray-700 w-6 text-right flex-shrink-0">{item.value}</span>
|
||||||
|
<span className="text-xs text-gray-400 w-9 text-right flex-shrink-0">{pct.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Customer Operations Statistics Section */}
|
{/* Customer Operations Statistics Section */}
|
||||||
<section className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden mb-6">
|
<section className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden mb-6">
|
||||||
@@ -2545,6 +2824,8 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Vehicle Detail Modal */}
|
{/* Vehicle Detail Modal */}
|
||||||
@@ -2838,18 +3119,34 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer / Navigation */}
|
{/* Footer / Navigation */}
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden">
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-40">
|
||||||
<button className="flex flex-col items-center text-blue-600">
|
<button
|
||||||
<Activity size={20} />
|
onClick={() => setActiveTab('overview')}
|
||||||
<span className="text-[10px] mt-1">资产汇总</span>
|
className={`flex flex-col items-center ${activeTab === 'overview' ? 'text-blue-600' : 'text-gray-400'}`}
|
||||||
|
>
|
||||||
|
<Truck size={20} />
|
||||||
|
<span className="text-[10px] mt-1">资产总览</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="flex flex-col items-center text-gray-400">
|
<button
|
||||||
<Warehouse size={20} />
|
onClick={() => setActiveTab('department')}
|
||||||
<span className="text-[10px] mt-1">库存分布</span>
|
className={`flex flex-col items-center ${activeTab === 'department' ? 'text-blue-600' : 'text-gray-400'}`}
|
||||||
|
>
|
||||||
|
<Users size={20} />
|
||||||
|
<span className="text-[10px] mt-1">运营部门</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="flex flex-col items-center text-gray-400">
|
<button
|
||||||
<History size={20} />
|
onClick={() => setActiveTab('region')}
|
||||||
<span className="text-[10px] mt-1">运营记录</span>
|
className={`flex flex-col items-center ${activeTab === 'region' ? 'text-blue-600' : 'text-gray-400'}`}
|
||||||
|
>
|
||||||
|
<MapPin size={20} />
|
||||||
|
<span className="text-[10px] mt-1">运营区域</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('customer')}
|
||||||
|
className={`flex flex-col items-center ${activeTab === 'customer' ? 'text-blue-600' : 'text-gray-400'}`}
|
||||||
|
>
|
||||||
|
<Building2 size={20} />
|
||||||
|
<span className="text-[10px] mt-1">运营客户</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user