696 lines
21 KiB
Vue
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>
|