Initial commit: 网络验证平台
This commit is contained in:
@@ -0,0 +1,695 @@
|
||||
<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('developer.ticketStatusOpen'),
|
||||
processing: t('developer.ticketStatusProcessing'),
|
||||
resolved: t('developer.ticketStatusResolved'),
|
||||
closed: t('developer.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('developer.priorityLow'),
|
||||
normal: t('developer.priorityNormal'),
|
||||
high: t('developer.priorityHigh'),
|
||||
urgent: t('developer.priorityUrgent'),
|
||||
}
|
||||
return texts[priority] || priority
|
||||
}
|
||||
|
||||
function goToTicket(ticketId: number) {
|
||||
router.push(`/developer/tickets/${ticketId}`)
|
||||
}
|
||||
|
||||
function goToTickets() {
|
||||
router.push('/developer/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('developer.operation')
|
||||
let description = log.details || log.resource || t('developer.noDescription')
|
||||
|
||||
if (log.log_type === 'operation') {
|
||||
icon = 'lucide:plus-circle'
|
||||
title = t('developer.operationRecord')
|
||||
}
|
||||
else if (log.log_type === 'verification') {
|
||||
icon = 'lucide:check-circle'
|
||||
title = t('developer.cardVerification')
|
||||
description = `${t('developer.verification')}${log.status === 'success' ? t('developer.success') : t('developer.failed')}`
|
||||
}
|
||||
else if (log.log_type === 'exception') {
|
||||
icon = 'lucide:alert-circle'
|
||||
title = t('developer.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('developer.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('developer.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('developer.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('developer.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('developer.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('developer.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('developer.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('developer.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('developer.dashboard')"
|
||||
:description="`${t('developer.welcomeBack')}, ${currentUser?.username || t('developer.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('developer.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('developer.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('developer.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('developer.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('developer.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('developer.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('developer.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('developer.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('developer.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('developer.userDistribution') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.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('developer.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('developer.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('developer.onlineTrend') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.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('developer.recentTickets') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.recentTicketsDesc') }}</UiCardDescription>
|
||||
</div>
|
||||
<UiButton variant="ghost" size="sm" @click="goToTickets">
|
||||
{{ t('developer.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('developer.noTickets') }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.recentActivities') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.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('developer.noActivities') }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
Reference in New Issue
Block a user