revert: 恢复 ECharts 世界地图展示用户分布

This commit is contained in:
2026-05-06 20:21:32 +08:00
parent 086c5c6573
commit 9f071464bb
+370 -71
View File
@@ -48,9 +48,9 @@ const provinces = ref<Province[]>([])
const onlineTrendData = ref<number[]>([])
const recentTickets = ref<Ticket[]>([])
const distributionChart = ref<HTMLCanvasElement | null>(null)
const mapChart = ref<HTMLElement | null>(null)
const activityChart = ref<HTMLCanvasElement | null>(null)
let distributionChartInstance: any = null
let chartInstance: any = null
let activityChartInstance: any = null
const overseasUsers = computed(() => {
@@ -231,77 +231,30 @@ function fetchCurrentUser() {
}
}
async function initDistributionChart() {
if (!distributionChart.value)
async function initMapChart() {
if (!mapChart.value)
return
try {
const { default: Chart } = await import('chart.js/auto')
const [echarts, worldResponse] = await Promise.all([
import('echarts'),
fetch('/world.json'),
])
const ctx = distributionChart.value.getContext('2d')
if (!ctx)
return
if (distributionChartInstance) {
distributionChartInstance.destroy()
if (!worldResponse.ok) {
throw new Error('Failed to load world map data')
}
const data = provinces.value.filter((d: any) => d.count > 0)
const labels = data.map((d: any) => d.name)
const values = data.map((d: any) => d.count)
const dark = isDark.value
const worldData = await worldResponse.json()
const colors = [
'#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6',
'#06b6d4', '#ec4899', '#14b8a6', '#f97316', '#6366f1',
]
echarts.registerMap('world', worldData)
distributionChartInstance = new Chart(ctx, {
type: 'doughnut',
data: {
labels,
datasets: [{
data: values,
backgroundColor: colors.slice(0, values.length),
borderColor: dark ? '#1e293b' : '#ffffff',
borderWidth: 2,
hoverOffset: 8,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '55%',
plugins: {
legend: {
position: 'right',
labels: {
color: dark ? '#cbd5e1' : '#475569',
padding: 16,
usePointStyle: true,
pointStyleWidth: 12,
font: {
size: 13,
},
},
},
tooltip: {
backgroundColor: 'rgba(15, 23, 42, 0.95)',
titleColor: '#f8fafc',
bodyColor: '#cbd5e1',
borderColor: '#334155',
borderWidth: 1,
padding: 12,
callbacks: {
label: (context: any) => {
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = total > 0 ? ((context.parsed / total) * 100).toFixed(1) : '0'
return ` ${context.label}: ${context.parsed} (${percentage}%)`
},
},
},
},
},
chartInstance = echarts.init(mapChart.value)
updateMapOption()
window.addEventListener('resize', () => {
chartInstance?.resize()
})
}
catch (error) {
@@ -309,6 +262,352 @@ async function initDistributionChart() {
}
}
const worldNameMap: Record<string, string> = {
'Afghanistan': '阿富汗',
'Albania': '阿尔巴尼亚',
'Algeria': '阿尔及利亚',
'American Samoa': '美属萨摩亚',
'Andorra': '安道尔',
'Angola': '安哥拉',
'Anguilla': '安圭拉',
'Antarctica': '南极洲',
'Antigua and Barbuda': '安提瓜和巴布达',
'Argentina': '阿根廷',
'Armenia': '亚美尼亚',
'Aruba': '阿鲁巴',
'Australia': '澳大利亚',
'Austria': '奥地利',
'Azerbaijan': '阿塞拜疆',
'Bahamas': '巴哈马',
'Bahrain': '巴林',
'Bangladesh': '孟加拉国',
'Barbados': '巴巴多斯',
'Belarus': '白俄罗斯',
'Belgium': '比利时',
'Belize': '伯利兹',
'Benin': '贝宁',
'Bermuda': '百慕大',
'Bhutan': '不丹',
'Bolivia': '玻利维亚',
'Bosnia and Herzegovina': '波黑',
'Botswana': '博茨瓦纳',
'Brazil': '巴西',
'British Indian Ocean Ter.': '英属印度洋领地',
'Brunei': '文莱',
'Bulgaria': '保加利亚',
'Burkina Faso': '布基纳法索',
'Burundi': '布隆迪',
'Cambodia': '柬埔寨',
'Cameroon': '喀麦隆',
'Canada': '加拿大',
'Cape Verde': '佛得角',
'Cayman Is.': '开曼群岛',
'Central African Rep.': '中非',
'Chad': '乍得',
'Chile': '智利',
'China': '中国',
'Colombia': '哥伦比亚',
'Comoros': '科摩罗',
'Congo': '刚果',
'Dem. Rep. Congo': '刚果民主共和国',
'Cook Is.': '库克群岛',
'Costa Rica': '哥斯达黎加',
'Croatia': '克罗地亚',
'Cuba': '古巴',
'Cyprus': '塞浦路斯',
'Czech Rep.': '捷克',
'Côte d\'Ivoire': '科特迪瓦',
'Denmark': '丹麦',
'Djibouti': '吉布提',
'Dominica': '多米尼克',
'Dominican Rep.': '多米尼加',
'Ecuador': '厄瓜多尔',
'Egypt': '埃及',
'El Salvador': '萨尔瓦多',
'Equatorial Guinea': '赤道几内亚',
'Eritrea': '厄立特里亚',
'Estonia': '爱沙尼亚',
'Ethiopia': '埃塞俄比亚',
'Falkland Is.': '福克兰群岛',
'Faeroe Is.': '法罗群岛',
'Fiji': '斐济',
'Finland': '芬兰',
'France': '法国',
'French Guiana': '法属圭亚那',
'French Polynesia': '法属波利尼西亚',
'French Southern Ter.': '法属南方领地',
'Gabon': '加蓬',
'Gambia': '冈比亚',
'Gaza': '加沙',
'Georgia': '格鲁吉亚',
'Germany': '德国',
'Ghana': '加纳',
'Gibraltar': '直布罗陀',
'Greece': '希腊',
'Greenland': '格陵兰',
'Grenada': '格林纳达',
'Guadeloupe': '瓜德罗普',
'Guam': '关岛',
'Guatemala': '危地马拉',
'Guinea': '几内亚',
'Guinea-Bissau': '几内亚比绍',
'Guyana': '圭亚那',
'Haiti': '海地',
'Heard I. and McDonald Is.': '赫德岛和麦克唐纳群岛',
'Honduras': '洪都拉斯',
'Hong Kong': '香港',
'Hungary': '匈牙利',
'Iceland': '冰岛',
'India': '印度',
'Indonesia': '印度尼西亚',
'Iran': '伊朗',
'Iraq': '伊拉克',
'Ireland': '爱尔兰',
'Isle of Man': '马恩岛',
'Israel': '以色列',
'Italy': '意大利',
'Jamaica': '牙买加',
'Japan': '日本',
'Jordan': '约旦',
'Kazakhstan': '哈萨克斯坦',
'Kenya': '肯尼亚',
'Kiribati': '基里巴斯',
'Korea': '韩国',
'Dem. Rep. Korea': '朝鲜',
'Kuwait': '科威特',
'Kyrgyzstan': '吉尔吉斯斯坦',
'Lao PDR': '老挝',
'Latvia': '拉脱维亚',
'Lebanon': '黎巴嫩',
'Lesotho': '莱索托',
'Liberia': '利比里亚',
'Libya': '利比亚',
'Liechtenstein': '列支敦士登',
'Lithuania': '立陶宛',
'Luxembourg': '卢森堡',
'Macao': '澳门',
'Macedonia': '马其顿',
'Madagascar': '马达加斯加',
'Malawi': '马拉维',
'Malaysia': '马来西亚',
'Maldives': '马尔代夫',
'Mali': '马里',
'Malta': '马耳他',
'Marshall Is.': '马绍尔群岛',
'Martinique': '马提尼克',
'Mauritania': '毛里塔尼亚',
'Mauritius': '毛里求斯',
'Mexico': '墨西哥',
'Micronesia': '密克罗尼西亚',
'Moldova': '摩尔多瓦',
'Monaco': '摩纳哥',
'Mongolia': '蒙古',
'Montenegro': '黑山',
'Montserrat': '蒙特塞拉特',
'Morocco': '摩洛哥',
'Mozambique': '莫桑比克',
'Myanmar': '缅甸',
'Namibia': '纳米比亚',
'Nauru': '瑙鲁',
'Nepal': '尼泊尔',
'Netherlands': '荷兰',
'New Caledonia': '新喀里多尼亚',
'New Zealand': '新西兰',
'Nicaragua': '尼加拉瓜',
'Niger': '尼日尔',
'Nigeria': '尼日利亚',
'Niue': '纽埃',
'Norfolk Island': '诺福克岛',
'Northern Mariana Is.': '北马里亚纳群岛',
'Norway': '挪威',
'Oman': '阿曼',
'Pakistan': '巴基斯坦',
'Palau': '帕劳',
'Palestine': '巴勒斯坦',
'Panama': '巴拿马',
'Papua New Guinea': '巴布亚新几内亚',
'Paraguay': '巴拉圭',
'Peru': '秘鲁',
'Philippines': '菲律宾',
'Pitcairn Is.': '皮特凯恩群岛',
'Poland': '波兰',
'Portugal': '葡萄牙',
'Puerto Rico': '波多黎各',
'Qatar': '卡塔尔',
'Réunion': '留尼汪',
'Romania': '罗马尼亚',
'Russia': '俄罗斯',
'Rwanda': '卢旺达',
'Saint Helena': '圣赫勒拿',
'Saint Kitts and Nevis': '圣基茨和尼维斯',
'Saint Lucia': '圣卢西亚',
'Saint Pierre and Miquelon': '圣皮埃尔和密克隆',
'Saint Vincent and the Grenadines': '圣文森特和格林纳丁斯',
'Samoa': '萨摩亚',
'San Marino': '圣马力诺',
'Sao Tome and Principe': '圣多美和普林西比',
'Saudi Arabia': '沙特阿拉伯',
'Senegal': '塞内加尔',
'Serbia': '塞尔维亚',
'Seychelles': '塞舌尔',
'Sierra Leone': '塞拉利昂',
'Singapore': '新加坡',
'Slovakia': '斯洛伐克',
'Slovenia': '斯洛文尼亚',
'Solomon Is.': '所罗门群岛',
'Somalia': '索马里',
'South Africa': '南非',
'South Georgia and the South Sandwich Is.': '南乔治亚和南桑威奇群岛',
'S. Sudan': '南苏丹',
'Spain': '西班牙',
'Sri Lanka': '斯里兰卡',
'Sudan': '苏丹',
'Suriname': '苏里南',
'Swaziland': '斯威士兰',
'Sweden': '瑞典',
'Switzerland': '瑞士',
'Syria': '叙利亚',
'Taiwan': '台湾',
'Tajikistan': '塔吉克斯坦',
'Tanzania': '坦桑尼亚',
'Thailand': '泰国',
'Timor-Leste': '东帝汶',
'Togo': '多哥',
'Tokelau': '托克劳',
'Tonga': '汤加',
'Trinidad and Tobago': '特立尼达和多巴哥',
'Tunisia': '突尼斯',
'Turkey': '土耳其',
'Turkmenistan': '土库曼斯坦',
'Turks and Caicos Is.': '特克斯和凯科斯群岛',
'Tuvalu': '图瓦卢',
'Uganda': '乌干达',
'Ukraine': '乌克兰',
'United Arab Emirates': '阿联酋',
'United Kingdom': '英国',
'United States': '美国',
'United States Minor Outlying Is.': '美属小离岛',
'Uruguay': '乌拉圭',
'Uzbekistan': '乌兹别克斯坦',
'Vanuatu': '瓦努阿图',
'Vatican City': '梵蒂冈',
'Venezuela': '委内瑞拉',
'Vietnam': '越南',
'Virgin Is.': '维尔京群岛',
'W. Sahara': '西撒哈拉',
'Yemen': '也门',
'Zambia': '赞比亚',
'Zimbabwe': '津巴布韦',
}
function updateMapOption() {
if (!chartInstance)
return
const data = provinces.value.filter((d: any) => d.count > 0)
const maxCount = Math.max(...data.map((d: any) => d.count || 0), 1)
const dark = isDark.value
const seriesData = data.map((d: any) => {
const ratio = d.count / maxCount
let r: number, g: number, b: number
if (dark) {
r = Math.round(30 + (59 - 30) * ratio)
g = Math.round(58 + (130 - 58) * ratio)
b = Math.round(138 + (246 - 138) * ratio)
}
else {
r = Math.round(147 + (37 - 147) * ratio)
g = Math.round(197 + (99 - 197) * ratio)
b = Math.round(253 + (235 - 253) * ratio)
}
return {
name: d.name,
value: d.count,
count: d.count,
online: d.online || 0,
offline: d.offline || 0,
itemStyle: {
areaColor: `rgb(${r}, ${g}, ${b})`,
},
}
})
const option = {
tooltip: {
trigger: 'item',
backgroundColor: dark ? 'rgba(15, 23, 42, 0.95)' : 'rgba(255, 255, 255, 0.95)',
borderColor: dark ? '#334155' : '#e2e8f0',
borderWidth: 1,
padding: 12,
textStyle: {
color: dark ? '#f8fafc' : '#0f172a',
},
formatter: (params: any) => {
if (params.data && params.data.count > 0) {
return `<div style="padding: 4px;">
<div style="font-weight: 600; margin-bottom: 6px; font-size: 13px;">${params.name}</div>
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 3px;">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#22c55e;"></span>
<span>${t('admin.online')}: ${params.data.online}</span>
</div>
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 3px;">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#ef4444;"></span>
<span>${t('admin.offline')}: ${params.data.offline}</span>
</div>
<div style="display: flex; align-items: center; gap: 6px;">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#3b82f6;"></span>
<span>${t('admin.total')}: ${params.data.count}</span>
</div>
</div>`
}
return `<div style="padding: 4px;">
<div style="font-weight: 600; margin-bottom: 4px; font-size: 13px;">${params.name}</div>
<div style="color: ${dark ? '#94a3b8' : '#64748b'};">${t('admin.noUserData')}</div>
</div>`
},
},
geo: {
map: 'world',
roam: true,
zoom: 1.2,
nameMap: worldNameMap,
label: {
show: false,
},
emphasis: {
label: {
show: true,
color: dark ? '#f8fafc' : '#0f172a',
fontSize: 11,
fontWeight: 600,
},
itemStyle: {
areaColor: dark ? '#334155' : '#f1f5f9',
borderColor: dark ? '#475569' : '#94a3b8',
borderWidth: 1,
},
},
itemStyle: {
areaColor: dark ? '#1e293b' : '#e2e8f0',
borderColor: dark ? '#334155' : '#cbd5e1',
borderWidth: 0.5,
},
},
series: [
{
name: t('admin.userDistribution'),
type: 'map',
geoIndex: 0,
data: seriesData,
},
],
}
chartInstance.setOption(option)
}
function initActivityChart() {
if (!activityChart.value)
return
@@ -420,20 +719,20 @@ watch(loading, async (newVal) => {
if (!newVal) {
await nextTick()
setTimeout(() => {
initDistributionChart()
initMapChart()
initActivityChart()
}, 100)
}
})
watch(isDark, () => {
initDistributionChart()
updateMapOption()
})
onUnmounted(() => {
if (distributionChartInstance) {
distributionChartInstance.destroy()
distributionChartInstance = null
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
if (activityChartInstance) {
activityChartInstance.destroy()
@@ -553,7 +852,7 @@ onUnmounted(() => {
</UiCardHeader>
<UiCardContent>
<div class="relative h-[350px] rounded-lg overflow-hidden bg-card">
<canvas ref="distributionChart" />
<div ref="mapChart" class="w-full h-full" />
</div>
</UiCardContent>
</UiCard>