feat: 全球地图、暗黑模式适配、用户总数修复
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -22,7 +22,6 @@ const teams = reactive([
|
|||||||
{
|
{
|
||||||
name: '管理后台',
|
name: '管理后台',
|
||||||
logo: Code,
|
logo: Code,
|
||||||
plan: 'Admin',
|
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -37,6 +36,7 @@ function loadSystemSettings() {
|
|||||||
}
|
}
|
||||||
if (parsed.site_logo) {
|
if (parsed.site_logo) {
|
||||||
siteSettings.logo = parsed.site_logo
|
siteSettings.logo = parsed.site_logo
|
||||||
|
teams[0].logo = parsed.site_logo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
@@ -53,6 +53,7 @@ function handleSettingsChange(event: CustomEvent) {
|
|||||||
}
|
}
|
||||||
if (settings.site_logo) {
|
if (settings.site_logo) {
|
||||||
siteSettings.logo = settings.site_logo
|
siteSettings.logo = settings.site_logo
|
||||||
|
teams[0].logo = settings.site_logo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ const { teams } = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const activeTeam = ref<Team>(teams[0])
|
const activeTeam = ref<Team>(teams[0])
|
||||||
|
|
||||||
|
function getAssetUrl(path: string) {
|
||||||
|
if (!path)
|
||||||
|
return ''
|
||||||
|
if (path.startsWith('http'))
|
||||||
|
return path
|
||||||
|
return `${import.meta.env.VITE_API_BASE || 'http://localhost:8080'}${path}`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -15,13 +23,14 @@ const activeTeam = ref<Team>(teams[0])
|
|||||||
<UiSidebarMenuItem>
|
<UiSidebarMenuItem>
|
||||||
<UiSidebarMenuButton size="lg" class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
<UiSidebarMenuButton size="lg" class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center rounded-lg aspect-square size-8 bg-sidebar-primary text-sidebar-primary-foreground"
|
class="flex items-center justify-center rounded-lg aspect-square size-8 overflow-hidden"
|
||||||
|
:class="typeof activeTeam.logo === 'string' && activeTeam.logo ? '' : 'bg-sidebar-primary text-sidebar-primary-foreground'"
|
||||||
>
|
>
|
||||||
<component :is="activeTeam.logo" v-if="activeTeam.logo" class="size-4" />
|
<img v-if="typeof activeTeam.logo === 'string' && activeTeam.logo" :src="getAssetUrl(activeTeam.logo)" class="size-full object-contain" alt="" />
|
||||||
|
<component v-else-if="typeof activeTeam.logo === 'function'" :is="activeTeam.logo" class="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid flex-1 text-sm leading-tight">
|
<div class="grid flex-1 text-sm leading-tight">
|
||||||
<span class="font-semibold truncate">{{ activeTeam.name }}</span>
|
<span class="font-semibold truncate">{{ activeTeam.name }}</span>
|
||||||
<span class="text-xs truncate text-muted-foreground">{{ activeTeam.plan }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</UiSidebarMenuButton>
|
</UiSidebarMenuButton>
|
||||||
</UiSidebarMenuItem>
|
</UiSidebarMenuItem>
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ export interface User {
|
|||||||
|
|
||||||
export interface Team {
|
export interface Team {
|
||||||
name: string
|
name: string
|
||||||
logo: NavIcon
|
logo: NavIcon | string
|
||||||
plan: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarData {
|
export interface SidebarData {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import AdminSidebar from '@/components/admin-sidebar/index.vue'
|
import AdminSidebar from '@/components/admin-sidebar/index.vue'
|
||||||
@@ -71,9 +71,49 @@ const breadcrumbs = computed(() => {
|
|||||||
return crumbs
|
return crumbs
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function getAssetUrl(path: string) {
|
||||||
|
if (!path)
|
||||||
|
return ''
|
||||||
|
if (path.startsWith('http'))
|
||||||
|
return path
|
||||||
|
return `${import.meta.env.VITE_API_BASE || 'http://localhost:8080'}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFavicon(favicon: string) {
|
||||||
|
if (!favicon)
|
||||||
|
return
|
||||||
|
const link: HTMLLinkElement = document.querySelector('link[rel*=\'icon\']') || document.createElement('link')
|
||||||
|
link.type = 'image/x-icon'
|
||||||
|
link.rel = 'shortcut icon'
|
||||||
|
link.href = getAssetUrl(favicon)
|
||||||
|
document.getElementsByTagName('head')[0].appendChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSiteName(name: string) {
|
||||||
|
if (name)
|
||||||
|
document.title = name
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(settings: { site_name?: string, site_logo?: string, site_favicon?: string }) {
|
||||||
|
if (settings.site_favicon)
|
||||||
|
updateFavicon(settings.site_favicon)
|
||||||
|
if (settings.site_name)
|
||||||
|
updateSiteName(settings.site_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSettingsChange(event: CustomEvent) {
|
||||||
|
applySettings(event.detail)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSystemSettings() {
|
async function loadSystemSettings() {
|
||||||
const storedSettings = localStorage.getItem('systemSettings')
|
const storedSettings = localStorage.getItem('systemSettings')
|
||||||
if (storedSettings) {
|
if (storedSettings) {
|
||||||
|
try {
|
||||||
|
applySettings(JSON.parse(storedSettings))
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error('Failed to parse systemSettings', e)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +136,11 @@ async function loadSystemSettings() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadSystemSettings()
|
loadSystemSettings()
|
||||||
|
window.addEventListener('system-settings-changed', handleSettingsChange as EventListener)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('system-settings-changed', handleSettingsChange as EventListener)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+351
-139
@@ -1,9 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Activity, AlertCircle, ArrowRight, CheckCircle, DollarSign, Key, LayoutGrid, Loader2, PlusCircle, Ticket, Users } from 'lucide-vue-next'
|
import { ArrowRight, DollarSign, Key, LayoutGrid, Loader2, Ticket, Users } from 'lucide-vue-next'
|
||||||
import type { LucideProps } from 'lucide-vue-next'
|
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
import { useColorMode } from '@vueuse/core'
|
||||||
import type { FunctionalComponent } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -12,6 +11,8 @@ import { BASE_URL } from '@/services/api'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
const isDark = computed(() => colorMode.value === 'dark')
|
||||||
|
|
||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
totalApplications: number
|
totalApplications: number
|
||||||
@@ -20,14 +21,6 @@ interface DashboardStats {
|
|||||||
monthlyRevenue: number
|
monthlyRevenue: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Activity {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
icon: FunctionalComponent<LucideProps>
|
|
||||||
createdAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Ticket {
|
interface Ticket {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
@@ -39,6 +32,8 @@ interface Ticket {
|
|||||||
interface Province {
|
interface Province {
|
||||||
name: string
|
name: string
|
||||||
count: number
|
count: number
|
||||||
|
online?: number
|
||||||
|
offline?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -51,38 +46,37 @@ const stats = ref<DashboardStats>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const provinces = ref<Province[]>([])
|
const provinces = ref<Province[]>([])
|
||||||
const chinaProvinces = ref<Province[]>([])
|
|
||||||
const onlineTrendData = ref<number[]>([])
|
const onlineTrendData = ref<number[]>([])
|
||||||
const recentActivities = ref<Activity[]>([])
|
|
||||||
const recentTickets = ref<Ticket[]>([])
|
const recentTickets = ref<Ticket[]>([])
|
||||||
|
|
||||||
const mapChart = ref<HTMLElement | null>(null)
|
const mapChart = ref<HTMLElement | null>(null)
|
||||||
const activityChart = ref<HTMLCanvasElement | null>(null)
|
const activityChart = ref<HTMLCanvasElement | null>(null)
|
||||||
let chartInstance: any = null
|
let chartInstance: any = null
|
||||||
let activityChartInstance: any = null
|
let activityChartInstance: any = null
|
||||||
const currentMapType = ref<'china'>('china')
|
|
||||||
|
|
||||||
const domesticUsers = computed(() => {
|
|
||||||
if (!chinaProvinces.value || chinaProvinces.value.length === 0)
|
|
||||||
return 0
|
|
||||||
return chinaProvinces.value.reduce((sum, province) => sum + (province.count || 0), 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
const overseasUsers = computed(() => {
|
const overseasUsers = computed(() => {
|
||||||
if (!provinces.value || provinces.value.length === 0)
|
if (!provinces.value || provinces.value.length === 0)
|
||||||
return 0
|
return 0
|
||||||
return provinces.value.reduce((sum, country) => {
|
return provinces.value.reduce((sum, country) => sum + (country.count || 0), 0)
|
||||||
if (country.name !== 'China')
|
})
|
||||||
return sum + (country.count || 0)
|
|
||||||
return sum
|
const onlineUsers = computed(() => {
|
||||||
}, 0)
|
if (!provinces.value || provinces.value.length === 0)
|
||||||
|
return 0
|
||||||
|
return provinces.value.reduce((sum, country) => sum + (country.online || 0), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const offlineUsers = computed(() => {
|
||||||
|
if (!provinces.value || provinces.value.length === 0)
|
||||||
|
return 0
|
||||||
|
return provinces.value.reduce((sum, country) => sum + (country.offline || 0), 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalUsers = computed(() => {
|
const totalUsers = computed(() => {
|
||||||
if (stats.value?.totalUsers && stats.value.totalUsers > 0) {
|
if (stats.value?.totalUsers && stats.value.totalUsers > 0) {
|
||||||
return stats.value.totalUsers
|
return stats.value.totalUsers
|
||||||
}
|
}
|
||||||
return (domesticUsers.value || 0) + (overseasUsers.value || 0)
|
return overseasUsers.value || 0
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatDate(date: Date) {
|
function formatDate(date: Date) {
|
||||||
@@ -177,54 +171,30 @@ async function fetchDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.data.userDistribution) {
|
if (data.data.userDistribution) {
|
||||||
|
const allProvinces: Province[] = []
|
||||||
if (data.data.userDistribution.provinces && Array.isArray(data.data.userDistribution.provinces)) {
|
if (data.data.userDistribution.provinces && Array.isArray(data.data.userDistribution.provinces)) {
|
||||||
chinaProvinces.value = data.data.userDistribution.provinces.map((p: any) => ({
|
allProvinces.push(...data.data.userDistribution.provinces.map((p: any) => ({
|
||||||
name: p.name,
|
name: p.name,
|
||||||
count: p.count || 0,
|
count: p.count || 0,
|
||||||
}))
|
online: p.online || 0,
|
||||||
|
offline: p.offline || 0,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
if (data.data.userDistribution.overseas && Array.isArray(data.data.userDistribution.overseas)) {
|
if (data.data.userDistribution.overseas && Array.isArray(data.data.userDistribution.overseas)) {
|
||||||
provinces.value = data.data.userDistribution.overseas.map((p: any) => ({
|
allProvinces.push(...data.data.userDistribution.overseas.map((p: any) => ({
|
||||||
name: p.name,
|
name: p.name,
|
||||||
count: p.count || 0,
|
count: p.count || 0,
|
||||||
}))
|
online: p.online || 0,
|
||||||
|
offline: p.offline || 0,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
provinces.value = allProvinces
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.data.onlineTrend && Array.isArray(data.data.onlineTrend)) {
|
if (data.data.onlineTrend && Array.isArray(data.data.onlineTrend)) {
|
||||||
onlineTrendData.value = data.data.onlineTrend.map((t: any) => t.value || t.count || 0)
|
onlineTrendData.value = data.data.onlineTrend.map((t: any) => t.value || t.count || 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.data.recentActivities && Array.isArray(data.data.recentActivities)) {
|
|
||||||
recentActivities.value = data.data.recentActivities.map((log: any) => {
|
|
||||||
let icon = Activity
|
|
||||||
let title = log.action || t('admin.operation')
|
|
||||||
let description = log.details || log.resource || t('admin.noDescription')
|
|
||||||
|
|
||||||
if (log.log_type === 'operation') {
|
|
||||||
icon = PlusCircle
|
|
||||||
title = t('admin.operationRecord')
|
|
||||||
}
|
|
||||||
else if (log.log_type === 'verification') {
|
|
||||||
icon = CheckCircle
|
|
||||||
title = t('admin.cardVerification')
|
|
||||||
description = `${t('admin.verification')}${log.status === 'success' ? t('admin.success') : t('admin.failed')}`
|
|
||||||
}
|
|
||||||
else if (log.log_type === 'exception') {
|
|
||||||
icon = AlertCircle
|
|
||||||
title = t('admin.exceptionRecord')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: log.id,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
createdAt: new Date(log.created_at),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.data.recentTickets && Array.isArray(data.data.recentTickets)) {
|
if (data.data.recentTickets && Array.isArray(data.data.recentTickets)) {
|
||||||
recentTickets.value = data.data.recentTickets.map((t: any) => ({
|
recentTickets.value = data.data.recentTickets.map((t: any) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
@@ -259,15 +229,15 @@ async function initMapChart() {
|
|||||||
return
|
return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chinaResponse = await fetch('https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json')
|
const worldResponse = await fetch('/world.json')
|
||||||
|
|
||||||
if (!chinaResponse.ok) {
|
if (!worldResponse.ok) {
|
||||||
throw new Error('Failed to load China map data')
|
throw new Error('Failed to load world map data')
|
||||||
}
|
}
|
||||||
|
|
||||||
const chinaData = await chinaResponse.json()
|
const worldData = await worldResponse.json()
|
||||||
|
|
||||||
echarts.registerMap('china', chinaData)
|
echarts.registerMap('world', worldData)
|
||||||
|
|
||||||
chartInstance = echarts.init(mapChart.value)
|
chartInstance = echarts.init(mapChart.value)
|
||||||
|
|
||||||
@@ -282,64 +252,337 @@ async function initMapChart() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function updateMapOption() {
|
||||||
if (!chartInstance)
|
if (!chartInstance)
|
||||||
return
|
return
|
||||||
|
|
||||||
const isChinaMap = currentMapType.value === 'china'
|
const data = provinces.value.filter((d: any) => d.count > 0)
|
||||||
const data = isChinaMap ? chinaProvinces.value : provinces.value
|
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 = {
|
const option = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
backgroundColor: 'rgba(15, 23, 42, 0.95)',
|
backgroundColor: dark ? 'rgba(15, 23, 42, 0.95)' : 'rgba(255, 255, 255, 0.95)',
|
||||||
borderColor: '#334155',
|
borderColor: dark ? '#334155' : '#e2e8f0',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
|
textStyle: {
|
||||||
|
color: dark ? '#f8fafc' : '#0f172a',
|
||||||
|
},
|
||||||
formatter: (params: any) => {
|
formatter: (params: any) => {
|
||||||
if (params.data && params.data.count > 0) {
|
if (params.data && params.data.count > 0) {
|
||||||
return `<div style="padding: 8px;">
|
return `<div style="padding: 4px;">
|
||||||
<div style="font-weight: bold; margin-bottom: 4px; color: #f8fafc;">${params.name}</div>
|
<div style="font-weight: 600; margin-bottom: 6px; font-size: 13px;">${params.name}</div>
|
||||||
<div style="color: #cbd5e1;">${t('admin.userCount')}: ${params.data.count}</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>`
|
</div>`
|
||||||
}
|
}
|
||||||
return `<div style="padding: 8px;">
|
return `<div style="padding: 4px;">
|
||||||
<div style="font-weight: bold; margin-bottom: 4px; color: #f8fafc;">${params.name}</div>
|
<div style="font-weight: 600; margin-bottom: 4px; font-size: 13px;">${params.name}</div>
|
||||||
<div style="color: #cbd5e1;">${t('admin.noUserData')}</div>
|
<div style="color: ${dark ? '#94a3b8' : '#64748b'};">${t('admin.noUserData')}</div>
|
||||||
</div>`
|
</div>`
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
visualMap: {
|
|
||||||
show: false,
|
|
||||||
min: 0,
|
|
||||||
max: Math.max(...data.map((d: any) => d.count || 0), 1),
|
|
||||||
inRange: {
|
|
||||||
color: ['#e0f2fe', '#3b82f6'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
geo: {
|
geo: {
|
||||||
map: isChinaMap ? 'china' : 'world',
|
map: 'world',
|
||||||
roam: false,
|
roam: true,
|
||||||
zoom: isChinaMap ? 1.2 : 1.1,
|
zoom: 1.2,
|
||||||
center: isChinaMap ? [104.195, 35.861] : undefined,
|
nameMap: worldNameMap,
|
||||||
label: {
|
label: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
emphasis: {
|
emphasis: {
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
color: '#f8fafc',
|
color: dark ? '#f8fafc' : '#0f172a',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
},
|
},
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
areaColor: '#3b82f6',
|
areaColor: dark ? '#334155' : '#f1f5f9',
|
||||||
borderColor: '#1d4ed8',
|
borderColor: dark ? '#475569' : '#94a3b8',
|
||||||
borderWidth: 2,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
areaColor: 'rgba(226, 232, 240, 0.3)',
|
areaColor: dark ? '#1e293b' : '#e2e8f0',
|
||||||
borderColor: '#e2e8f0',
|
borderColor: dark ? '#334155' : '#cbd5e1',
|
||||||
borderWidth: 1,
|
borderWidth: 0.5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
@@ -347,7 +590,7 @@ function updateMapOption() {
|
|||||||
name: t('admin.userDistribution'),
|
name: t('admin.userDistribution'),
|
||||||
type: 'map',
|
type: 'map',
|
||||||
geoIndex: 0,
|
geoIndex: 0,
|
||||||
data,
|
data: seriesData,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -466,6 +709,10 @@ onMounted(async () => {
|
|||||||
initActivityChart()
|
initActivityChart()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(isDark, () => {
|
||||||
|
updateMapOption()
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (chartInstance) {
|
if (chartInstance) {
|
||||||
chartInstance.dispose()
|
chartInstance.dispose()
|
||||||
@@ -573,12 +820,16 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 text-sm">
|
<div class="flex items-center gap-4 text-sm">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="size-3 rounded-full bg-blue-500" />
|
<div class="size-3 rounded-full bg-green-500" />
|
||||||
<span class="text-muted-foreground">{{ t('admin.domesticUsers') }}: {{ formatNumber(domesticUsers) }}</span>
|
<span class="text-muted-foreground">{{ t('admin.online') }}: {{ formatNumber(onlineUsers) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="size-3 rounded-full bg-green-500" />
|
<div class="size-3 rounded-full bg-red-500" />
|
||||||
<span class="text-muted-foreground">{{ t('admin.overseasUsers') }}: {{ formatNumber(overseasUsers) }}</span>
|
<span class="text-muted-foreground">{{ t('admin.offline') }}: {{ formatNumber(offlineUsers) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="size-3 rounded-full bg-blue-500" />
|
||||||
|
<span class="text-muted-foreground">{{ t('admin.total') }}: {{ formatNumber(totalUsers) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -653,45 +904,6 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</UiCardContent>
|
</UiCardContent>
|
||||||
</UiCard>
|
</UiCard>
|
||||||
|
|
||||||
<UiCard>
|
|
||||||
<UiCardHeader>
|
|
||||||
<UiCardTitle>{{ t('admin.recentActivities') }}</UiCardTitle>
|
|
||||||
<UiCardDescription>{{ t('admin.recentActivitiesDesc') }}</UiCardDescription>
|
|
||||||
</UiCardHeader>
|
|
||||||
<UiCardContent>
|
|
||||||
<div v-if="recentActivities.length > 0" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<div
|
|
||||||
v-for="activity in recentActivities.slice(0, 8)"
|
|
||||||
:key="activity.id"
|
|
||||||
class="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div class="size-7 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<component :is="activity.icon" class="size-3.5 text-primary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm font-medium">
|
|
||||||
{{ activity.title }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground truncate">
|
|
||||||
{{ activity.description }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{{ formatDate(activity.createdAt) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-center py-8 text-muted-foreground">
|
|
||||||
<Activity class="mx-auto size-10 text-muted-foreground mb-2" />
|
|
||||||
<p class="text-sm">
|
|
||||||
{{ t('admin.noActivities') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</UiCardContent>
|
|
||||||
</UiCard>
|
|
||||||
</div>
|
</div>
|
||||||
</BasicPage>
|
</BasicPage>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -951,7 +951,7 @@
|
|||||||
"recentTickets": "Recent Tickets",
|
"recentTickets": "Recent Tickets",
|
||||||
"recentTicketsDesc": "Recent ticket records",
|
"recentTicketsDesc": "Recent ticket records",
|
||||||
"userDistribution": "User Distribution",
|
"userDistribution": "User Distribution",
|
||||||
"userDistributionDesc": "User distribution by province and overseas",
|
"userDistributionDesc": "Global user geographic distribution",
|
||||||
"recentActivities": "Recent Activities",
|
"recentActivities": "Recent Activities",
|
||||||
"recentActivitiesDesc": "Your recent operation records",
|
"recentActivitiesDesc": "Your recent operation records",
|
||||||
"allRecentActivities": "All Recent Activities",
|
"allRecentActivities": "All Recent Activities",
|
||||||
@@ -984,6 +984,8 @@
|
|||||||
"initActivityChartError": "Failed to initialize activity chart",
|
"initActivityChartError": "Failed to initialize activity chart",
|
||||||
"verificationCount": "Verification Count",
|
"verificationCount": "Verification Count",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
|
"offline": "Offline",
|
||||||
|
"total": "Total",
|
||||||
"userCount": "User Count",
|
"userCount": "User Count",
|
||||||
"noUserData": "No user data",
|
"noUserData": "No user data",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
|
|||||||
@@ -952,7 +952,7 @@
|
|||||||
"recentTickets": "最新工单",
|
"recentTickets": "最新工单",
|
||||||
"recentTicketsDesc": "最近的工单记录",
|
"recentTicketsDesc": "最近的工单记录",
|
||||||
"userDistribution": "用户分布",
|
"userDistribution": "用户分布",
|
||||||
"userDistributionDesc": "各省份及海外用户分布",
|
"userDistributionDesc": "全球用户地理分布",
|
||||||
"recentActivities": "最近活动",
|
"recentActivities": "最近活动",
|
||||||
"recentActivitiesDesc": "您最近的操作记录",
|
"recentActivitiesDesc": "您最近的操作记录",
|
||||||
"allRecentActivities": "所有最近活动",
|
"allRecentActivities": "所有最近活动",
|
||||||
@@ -985,6 +985,8 @@
|
|||||||
"initActivityChartError": "初始化活动图表失败",
|
"initActivityChartError": "初始化活动图表失败",
|
||||||
"verificationCount": "验证次数",
|
"verificationCount": "验证次数",
|
||||||
"online": "在线",
|
"online": "在线",
|
||||||
|
"offline": "离线",
|
||||||
|
"total": "总数",
|
||||||
"userCount": "用户数",
|
"userCount": "用户数",
|
||||||
"noUserData": "暂无用户数据",
|
"noUserData": "暂无用户数据",
|
||||||
"profile": "个人资料",
|
"profile": "个人资料",
|
||||||
|
|||||||
Reference in New Issue
Block a user