Files
verify/frontend/src/pages/developer/index.vue
T

696 lines
21 KiB
Vue

<script setup lang="ts">
import { Icon } from '@iconify/vue'
import * as echarts from 'echarts'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { BasicPage } from '@/components/global-layout'
import { BASE_URL } from '@/services/api'
const { t } = useI18n()
const router = useRouter()
interface DashboardStats {
totalApplications: number
totalCards: number
totalUsers: number
monthlyRevenue: number
}
interface Activity {
id: number
title: string
description: string
icon: string
createdAt: Date
}
interface Ticket {
id: number
title: string
status: string
priority: string
createdAt: Date
}
interface Province {
name: string
count: number
}
const loading = ref(true)
const currentUser = ref<any>(null)
const stats = ref<DashboardStats>({
totalApplications: 0,
totalCards: 0,
totalUsers: 0,
monthlyRevenue: 0,
})
const provinces = ref<Province[]>([])
const chinaProvinces = ref<Province[]>([])
const onlineTrendData = ref<number[]>([])
const recentActivities = ref<Activity[]>([])
const recentTickets = ref<Ticket[]>([])
const mapChart = ref<HTMLElement | null>(null)
const activityChart = ref<HTMLCanvasElement | null>(null)
let chartInstance: 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(() => {
if (!provinces.value || provinces.value.length === 0)
return 0
return provinces.value.reduce((sum, country) => {
if (country.name !== 'China')
return sum + (country.count || 0)
return sum
}, 0)
})
const totalUsers = computed(() => {
if (stats.value?.totalUsers && stats.value.totalUsers > 0) {
return stats.value.totalUsers
}
return (domesticUsers.value || 0) + (overseasUsers.value || 0)
})
function formatDate(date: Date) {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date)
}
function formatNumber(num: number) {
return (num || 0).toLocaleString()
}
function getTicketStatusColor(status: string) {
const colors: Record<string, string> = {
open: 'bg-yellow-500/10 text-yellow-500',
processing: 'bg-blue-500/10 text-blue-500',
resolved: 'bg-green-500/10 text-green-500',
closed: 'bg-gray-500/10 text-gray-500',
}
return colors[status] || 'bg-gray-500/10 text-gray-500'
}
function getTicketStatusText(status: string) {
const texts: Record<string, string> = {
open: t('admin.ticketStatusOpen'),
processing: t('admin.ticketStatusProcessing'),
resolved: t('admin.ticketStatusResolved'),
closed: t('admin.ticketStatusClosed'),
}
return texts[status] || status
}
function getTicketPriorityColor(priority: string) {
const colors: Record<string, string> = {
low: 'text-gray-500',
normal: 'text-blue-500',
high: 'text-orange-500',
urgent: 'text-red-500',
}
return colors[priority] || 'text-gray-500'
}
function getTicketPriorityText(priority: string) {
const texts: Record<string, string> = {
low: t('admin.priorityLow'),
normal: t('admin.priorityNormal'),
high: t('admin.priorityHigh'),
urgent: t('admin.priorityUrgent'),
}
return texts[priority] || priority
}
function goToTicket(ticketId: number) {
router.push(`/admin/tickets/${ticketId}`)
}
function goToTickets() {
router.push('/admin/tickets')
}
async function fetchDashboard() {
loading.value = true
try {
const token = localStorage.getItem('token')
if (!token) {
console.error('No token found')
return
}
const response = await fetch(`${BASE_URL}/dev/dashboard`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
if (data.code === 200 && data.data) {
if (data.data.stats) {
stats.value = {
totalApplications: data.data.stats.totalApplications || 0,
totalCards: data.data.stats.totalCards || 0,
totalUsers: data.data.stats.totalUsers || 0,
monthlyRevenue: data.data.stats.monthlyRevenue || 0,
}
}
if (data.data.userDistribution) {
if (data.data.userDistribution.provinces && Array.isArray(data.data.userDistribution.provinces)) {
chinaProvinces.value = data.data.userDistribution.provinces.map((p: any) => ({
name: p.name,
count: p.count || 0,
}))
}
if (data.data.userDistribution.overseas && Array.isArray(data.data.userDistribution.overseas)) {
provinces.value = data.data.userDistribution.overseas.map((p: any) => ({
name: p.name,
count: p.count || 0,
}))
}
}
if (data.data.onlineTrend && Array.isArray(data.data.onlineTrend)) {
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 = 'lucide:activity'
let title = log.action || t('admin.operation')
let description = log.details || log.resource || t('admin.noDescription')
if (log.log_type === 'operation') {
icon = 'lucide:plus-circle'
title = t('admin.operationRecord')
}
else if (log.log_type === 'verification') {
icon = 'lucide:check-circle'
title = t('admin.cardVerification')
description = `${t('admin.verification')}${log.status === 'success' ? t('admin.success') : t('admin.failed')}`
}
else if (log.log_type === 'exception') {
icon = 'lucide:alert-circle'
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)) {
recentTickets.value = data.data.recentTickets.map((t: any) => ({
id: t.id,
title: t.title,
status: t.status,
priority: t.priority,
createdAt: new Date(t.created_at),
}))
}
}
else {
console.error('API returned error:', data)
}
}
catch (error) {
console.error(t('admin.fetchDashboardError'), error)
}
finally {
loading.value = false
}
}
function fetchCurrentUser() {
const userStr = localStorage.getItem('user')
if (userStr) {
currentUser.value = JSON.parse(userStr)
}
}
async function initMapChart() {
if (!mapChart.value)
return
try {
const chinaResponse = await fetch('https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json')
if (!chinaResponse.ok) {
throw new Error('Failed to load China map data')
}
const chinaData = await chinaResponse.json()
echarts.registerMap('china', chinaData)
chartInstance = echarts.init(mapChart.value)
updateMapOption()
window.addEventListener('resize', () => {
chartInstance?.resize()
})
}
catch (error) {
console.error(t('admin.initMapError'), error)
}
}
function updateMapOption() {
if (!chartInstance)
return
const isChinaMap = currentMapType.value === 'china'
const data = isChinaMap ? chinaProvinces.value : provinces.value
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 23, 42, 0.95)',
borderColor: '#334155',
borderWidth: 1,
padding: 12,
formatter: (params: any) => {
if (params.data && params.data.count > 0) {
return `<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 4px; color: #f8fafc;">${params.name}</div>
<div style="color: #cbd5e1;">${t('admin.userCount')}: ${params.data.count}</div>
</div>`
}
return `<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 4px; color: #f8fafc;">${params.name}</div>
<div style="color: #cbd5e1;">${t('admin.noUserData')}</div>
</div>`
},
},
visualMap: {
show: false,
min: 0,
max: Math.max(...data.map((d: any) => d.count || 0), 1),
inRange: {
color: ['#e0f2fe', '#3b82f6'],
},
},
geo: {
map: isChinaMap ? 'china' : 'world',
roam: false,
zoom: isChinaMap ? 1.2 : 1.1,
center: isChinaMap ? [104.195, 35.861] : undefined,
label: {
show: false,
},
emphasis: {
label: {
show: true,
color: '#f8fafc',
},
itemStyle: {
areaColor: '#3b82f6',
borderColor: '#1d4ed8',
borderWidth: 2,
},
},
itemStyle: {
areaColor: 'rgba(226, 232, 240, 0.3)',
borderColor: '#e2e8f0',
borderWidth: 1,
},
},
series: [
{
name: t('admin.userDistribution'),
type: 'map',
geoIndex: 0,
data,
},
],
}
chartInstance.setOption(option)
}
function initActivityChart() {
if (!activityChart.value)
return
try {
import('chart.js/auto').then(({ default: Chart }) => {
const ctx = activityChart.value?.getContext('2d')
if (!ctx)
return
const labels = Array.from({ length: 30 }, (_, i) => {
const date = new Date()
date.setDate(date.getDate() - (29 - i))
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
})
const data = onlineTrendData.value.length > 0
? onlineTrendData.value
: Array.from({ length: 30 }).fill(0)
if (activityChartInstance) {
activityChartInstance.destroy()
}
activityChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: t('admin.verificationCount'),
data,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 6,
pointHoverBackgroundColor: '#3b82f6',
pointHoverBorderColor: '#fff',
pointHoverBorderWidth: 2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: 'rgba(15, 23, 42, 0.95)',
titleColor: '#f8fafc',
bodyColor: '#cbd5e1',
borderColor: '#334155',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: (context: any) => `${context.parsed.y} ${t('admin.online')}`,
},
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
color: '#94a3b8',
font: {
size: 11,
},
},
},
y: {
grid: {
color: '#334155',
},
ticks: {
color: '#94a3b8',
font: {
size: 11,
},
},
beginAtZero: true,
},
},
interaction: {
intersect: false,
mode: 'index',
},
},
})
})
}
catch (error) {
console.error(t('admin.initActivityChartError'), error)
}
}
onMounted(async () => {
fetchCurrentUser()
await fetchDashboard()
await nextTick()
initMapChart()
initActivityChart()
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
if (activityChartInstance) {
activityChartInstance.destroy()
activityChartInstance = null
}
})
</script>
<template>
<BasicPage
:title="t('admin.dashboard.title')"
:description="`${t('admin.welcomeBack')}, ${currentUser?.username || t('admin.developer')}`"
>
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="text-center">
<Icon icon="lucide:loader-2" class="size-8 animate-spin mx-auto text-muted-foreground" />
<p class="mt-4 text-muted-foreground">
{{ t('admin.loading') }}...
</p>
</div>
</div>
<div v-else class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.totalApplications') }}
</UiCardTitle>
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ formatNumber(stats.totalApplications) }}
</div>
<p class="text-xs text-muted-foreground">
+12.5% {{ t('admin.lastMonth') }}
</p>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.totalCards') }}
</UiCardTitle>
<Icon icon="lucide:key" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ formatNumber(stats.totalCards) }}
</div>
<p class="text-xs text-muted-foreground">
+8.2% {{ t('admin.lastMonth') }}
</p>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.totalUsers') }}
</UiCardTitle>
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ formatNumber(totalUsers) }}
</div>
<p class="text-xs text-muted-foreground">
+15.3% {{ t('admin.lastMonth') }}
</p>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.monthlyRevenue') }}
</UiCardTitle>
<Icon icon="lucide:dollar-sign" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
¥{{ formatNumber(stats.monthlyRevenue) }}
</div>
<p class="text-xs text-muted-foreground">
+23.1% {{ t('admin.lastMonth') }}
</p>
</UiCardContent>
</UiCard>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<UiCard>
<UiCardHeader>
<div class="flex items-center justify-between">
<div>
<UiCardTitle>{{ t('admin.userDistribution') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.userDistributionDesc') }}</UiCardDescription>
</div>
<div class="flex items-center gap-4 text-sm">
<div class="flex items-center gap-2">
<div class="size-3 rounded-full bg-blue-500" />
<span class="text-muted-foreground">{{ t('admin.domesticUsers') }}: {{ formatNumber(domesticUsers) }}</span>
</div>
<div class="flex items-center gap-2">
<div class="size-3 rounded-full bg-green-500" />
<span class="text-muted-foreground">{{ t('admin.overseasUsers') }}: {{ formatNumber(overseasUsers) }}</span>
</div>
</div>
</div>
</UiCardHeader>
<UiCardContent>
<div class="relative h-[350px] rounded-lg overflow-hidden bg-card">
<div ref="mapChart" class="w-full h-full" />
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.onlineTrend') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.onlineTrendDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="pl-2">
<div class="relative h-[350px]">
<canvas ref="activityChart" />
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardHeader>
<div class="flex items-center justify-between">
<div>
<UiCardTitle>{{ t('admin.recentTickets') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.recentTicketsDesc') }}</UiCardDescription>
</div>
<UiButton variant="ghost" size="sm" @click="goToTickets">
{{ t('admin.viewAll') }}
<Icon icon="lucide:arrow-right" class="ml-1 size-4" />
</UiButton>
</div>
</UiCardHeader>
<UiCardContent>
<div v-if="recentTickets.length > 0" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div
v-for="ticket in recentTickets.slice(0, 5)"
:key="ticket.id"
class="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/50 cursor-pointer transition-colors"
@click="goToTicket(ticket.id)"
>
<div class="flex-shrink-0">
<div class="size-8 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="lucide:ticket" class="size-4 text-primary" />
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ ticket.title }}
</p>
<div class="flex items-center gap-2 mt-1">
<UiBadge :class="getTicketStatusColor(ticket.status)" class="text-[10px] flex-shrink-0">
{{ getTicketStatusText(ticket.status) }}
</UiBadge>
<span :class="`text-xs ${getTicketPriorityColor(ticket.priority)}`">
{{ getTicketPriorityText(ticket.priority) }}
</span>
</div>
<span class="text-xs text-muted-foreground">{{ formatDate(ticket.createdAt) }}</span>
</div>
</div>
</div>
<div v-else class="text-center py-8 text-muted-foreground">
<Icon icon="lucide:ticket" class="mx-auto size-10 text-muted-foreground mb-2" />
<p class="text-sm">
{{ t('admin.noTickets') }}
</p>
</div>
</UiCardContent>
</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">
<Icon :icon="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">
<Icon icon="lucide:activity" class="mx-auto size-10 text-muted-foreground mb-2" />
<p class="text-sm">
{{ t('admin.noActivities') }}
</p>
</div>
</UiCardContent>
</UiCard>
</div>
</BasicPage>
</template>