feat: 按城市改为按省份,数据从realtime表province获取,展示前5
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 后端region-chart支持groupBy=province,从realtime表读取省份 - 区域柱状图和客户饼图"按城市"改为"按省份" - 省份展示前5,其余合入"其他" - 前端state类型从'city'改为'province' Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
56
src/App.tsx
56
src/App.tsx
@@ -189,8 +189,8 @@ export default function App() {
|
|||||||
const [draftInventoryFilters, setDraftInventoryFilters] = useState({ region: '', city: '', brand: '', type: '', model: '' });
|
const [draftInventoryFilters, setDraftInventoryFilters] = useState({ region: '', city: '', brand: '', type: '', model: '' });
|
||||||
|
|
||||||
// Chart view states
|
// Chart view states
|
||||||
const [customerChartView, setCustomerChartView] = useState<'region' | 'city'>('region');
|
const [customerChartView, setCustomerChartView] = useState<'region' | 'province'>('region');
|
||||||
const [regionChartView, setRegionChartView] = useState<'region' | 'city'>('region');
|
const [regionChartView, setRegionChartView] = useState<'region' | 'province'>('region');
|
||||||
const [regionChartData, setRegionChartData] = useState<{ name: string; value: number }[]>([]);
|
const [regionChartData, setRegionChartData] = useState<{ name: string; value: number }[]>([]);
|
||||||
|
|
||||||
// Modal filter state
|
// Modal filter state
|
||||||
@@ -250,7 +250,7 @@ export default function App() {
|
|||||||
|
|
||||||
// Fetch region chart data when view changes
|
// Fetch region chart data when view changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRegionChart(regionChartView, regionChartView === 'city' ? 5 : 8).then(setRegionChartData).catch(() => setRegionChartData([]));
|
fetchRegionChart(regionChartView, regionChartView === 'province' ? 5 : 8).then(setRegionChartData).catch(() => setRegionChartData([]));
|
||||||
}, [regionChartView]);
|
}, [regionChartView]);
|
||||||
|
|
||||||
// Load modal vehicles
|
// Load modal vehicles
|
||||||
@@ -505,38 +505,22 @@ export default function App() {
|
|||||||
return mp;
|
return mp;
|
||||||
}), [modalWeeklyDetail, modalFilters.plateNumber]);
|
}), [modalWeeklyDetail, modalFilters.plateNumber]);
|
||||||
|
|
||||||
|
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (customerChartView === 'province') {
|
||||||
|
fetchRegionChart('province', 5).then(setCustomerProvinceData).catch(() => setCustomerProvinceData([]));
|
||||||
|
}
|
||||||
|
}, [customerChartView]);
|
||||||
|
|
||||||
const customerPieData = useMemo(() => {
|
const customerPieData = useMemo(() => {
|
||||||
let data: { name: string; value: number }[] = [];
|
|
||||||
if (customerChartView === 'region') {
|
if (customerChartView === 'region') {
|
||||||
const map: Record<string, number> = {};
|
const map: Record<string, number> = {};
|
||||||
customerData.forEach(item => { map[item.region] = (map[item.region] || 0) + item.total; });
|
customerData.forEach(item => { map[item.region] = (map[item.region] || 0) + item.total; });
|
||||||
data = Object.entries(map).map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value);
|
return Object.entries(map).map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value);
|
||||||
} else {
|
} else {
|
||||||
const cityMap: Record<string, number> = {};
|
return customerProvinceData;
|
||||||
const cityToRegion: Record<string, string> = {};
|
|
||||||
customerData.forEach(item => {
|
|
||||||
cityMap[item.city] = (cityMap[item.city] || 0) + item.total;
|
|
||||||
if (!cityToRegion[item.city]) cityToRegion[item.city] = item.region;
|
|
||||||
});
|
|
||||||
const tot = Object.values(cityMap).reduce((a, b) => a + b, 0);
|
|
||||||
const threshold = tot * 0.05;
|
|
||||||
let other = 0;
|
|
||||||
Object.entries(cityMap).forEach(([name, value]) => {
|
|
||||||
if (value >= threshold) data.push({ name, value });
|
|
||||||
else other += value;
|
|
||||||
});
|
|
||||||
if (other > 0) data.push({ name: '其他', value: other });
|
|
||||||
// Sort by region group, then by value within group
|
|
||||||
const regionOrder = ['华东', '华南', '华中', '华北', '西北', '西南', '其他'];
|
|
||||||
data.sort((a, b) => {
|
|
||||||
const ra = regionOrder.indexOf(cityToRegion[a.name] || '其他');
|
|
||||||
const rb = regionOrder.indexOf(cityToRegion[b.name] || '其他');
|
|
||||||
if (ra !== rb) return ra - rb;
|
|
||||||
return b.value - a.value;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return data;
|
}, [customerData, customerChartView, customerProvinceData]);
|
||||||
}, [customerData, customerChartView]);
|
|
||||||
|
|
||||||
const watermarkText = useMemo(() => `羚牛氢能-${new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-')}`, [lastUpdate]);
|
const watermarkText = useMemo(() => `羚牛氢能-${new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-')}`, [lastUpdate]);
|
||||||
|
|
||||||
@@ -1953,9 +1937,9 @@ export default function App() {
|
|||||||
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'}`}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setRegionChartView('city')}
|
onClick={() => setRegionChartView('province')}
|
||||||
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'}`}
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${regionChartView === 'province' ? 'bg-white text-blue-700 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
>按城市</button>
|
>按省份</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-64 w-full">
|
<div className="h-64 w-full">
|
||||||
@@ -1968,7 +1952,7 @@ export default function App() {
|
|||||||
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
|
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
|
||||||
cursor={{ fill: '#f8fafc' }}
|
cursor={{ fill: '#f8fafc' }}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="value" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={regionChartView === 'city' ? 20 : 40}>
|
<Bar dataKey="value" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={regionChartView === 'province' ? 20 : 40}>
|
||||||
<LabelList dataKey="value" position="top" style={{ fill: '#64748b', fontSize: 11, fontWeight: 600 }} />
|
<LabelList dataKey="value" position="top" style={{ fill: '#64748b', fontSize: 11, fontWeight: 600 }} />
|
||||||
</Bar>
|
</Bar>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
@@ -2227,9 +2211,9 @@ export default function App() {
|
|||||||
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'}`}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCustomerChartView('city')}
|
onClick={() => setCustomerChartView('province')}
|
||||||
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'}`}
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${customerChartView === 'province' ? 'bg-white text-emerald-600 shadow-sm' : 'text-gray-400 hover:text-gray-600'}`}
|
||||||
>按城市</button>
|
>按省份</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -1034,14 +1034,31 @@ app.get('/debug', async (c) => {
|
|||||||
app.get('/region-chart', async (c) => {
|
app.get('/region-chart', async (c) => {
|
||||||
const vehicles = await getVehicles();
|
const vehicles = await getVehicles();
|
||||||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||||||
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'city'
|
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'province'
|
||||||
const top = Number(c.req.query('top')) || 8;
|
const top = Number(c.req.query('top')) || 8;
|
||||||
|
|
||||||
const counts = new Map<string, number>();
|
let counts: Map<string, number>;
|
||||||
|
if (groupBy === 'province') {
|
||||||
|
// Get realtime province data
|
||||||
|
const [rows] = await pool.query<any[]>(`SELECT plate_number, province FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL`);
|
||||||
|
const plateProvince = new Map<string, string>();
|
||||||
|
for (const row of rows as any[]) {
|
||||||
|
const plate = (row.plate_number || '').trim();
|
||||||
|
const prov = (row.province || '').replace(/省|市$/, '').trim();
|
||||||
|
if (plate && prov) plateProvince.set(plate, prov);
|
||||||
|
}
|
||||||
|
counts = new Map<string, number>();
|
||||||
for (const v of operating) {
|
for (const v of operating) {
|
||||||
const key = groupBy === 'city' ? resolveCity(v.city, v.province) : mapMacroRegion(v.province, v.city);
|
const prov = plateProvince.get(v.plateNumber) || '未知';
|
||||||
|
counts.set(prov, (counts.get(prov) || 0) + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
counts = new Map<string, number>();
|
||||||
|
for (const v of operating) {
|
||||||
|
const key = mapMacroRegion(v.province, v.city);
|
||||||
counts.set(key, (counts.get(key) || 0) + 1);
|
counts.set(key, (counts.get(key) || 0) + 1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 分离"其他",对非"其他"排序取 Top N,剩余全部合入"其他"
|
// 分离"其他",对非"其他"排序取 Top N,剩余全部合入"其他"
|
||||||
const otherCount = counts.get('其他') || 0;
|
const otherCount = counts.get('其他') || 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user