feat: 按城市改为按省份,数据从realtime表province获取,展示前5
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:
kkfluous
2026-03-29 09:26:54 +08:00
parent db1e37b8bf
commit e910deac51
2 changed files with 42 additions and 41 deletions

View File

@@ -189,8 +189,8 @@ export default function App() {
const [draftInventoryFilters, setDraftInventoryFilters] = useState({ region: '', city: '', brand: '', type: '', model: '' });
// Chart view states
const [customerChartView, setCustomerChartView] = useState<'region' | 'city'>('region');
const [regionChartView, setRegionChartView] = useState<'region' | 'city'>('region');
const [customerChartView, setCustomerChartView] = useState<'region' | 'province'>('region');
const [regionChartView, setRegionChartView] = useState<'region' | 'province'>('region');
const [regionChartData, setRegionChartData] = useState<{ name: string; value: number }[]>([]);
// Modal filter state
@@ -250,7 +250,7 @@ export default function App() {
// Fetch region chart data when view changes
useEffect(() => {
fetchRegionChart(regionChartView, regionChartView === 'city' ? 5 : 8).then(setRegionChartData).catch(() => setRegionChartData([]));
fetchRegionChart(regionChartView, regionChartView === 'province' ? 5 : 8).then(setRegionChartData).catch(() => setRegionChartData([]));
}, [regionChartView]);
// Load modal vehicles
@@ -505,38 +505,22 @@ export default function App() {
return mp;
}), [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(() => {
let data: { name: string; value: number }[] = [];
if (customerChartView === 'region') {
const map: Record<string, number> = {};
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 {
const cityMap: Record<string, number> = {};
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 customerProvinceData;
}
return data;
}, [customerData, customerChartView]);
}, [customerData, customerChartView, customerProvinceData]);
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'}`}
></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>
onClick={() => setRegionChartView('province')}
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>
</div>
</div>
<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)' }}
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 }} />
</Bar>
</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'}`}
></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>
onClick={() => setCustomerChartView('province')}
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>
</div>
</div>
{(() => {

View File

@@ -1034,14 +1034,31 @@ app.get('/debug', async (c) => {
app.get('/region-chart', async (c) => {
const vehicles = await getVehicles();
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 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) {
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);
}
}
// 分离"其他",对非"其他"排序取 Top N剩余全部合入"其他"
const otherCount = counts.get('其他') || 0;