223 lines
12 KiB
Vue
223 lines
12 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Page Header -->
|
|
<div class="mb-8">
|
|
<h1 class="text-2xl font-bold text-white">{{ t('gamification.dashboard.title') }}</h1>
|
|
<p class="text-slate-400 mt-1">{{ t('gamification.dashboard.subtitle') }}</p>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="flex items-center justify-center py-20">
|
|
<div class="text-slate-400 flex items-center gap-3">
|
|
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
<span>{{ t('gamification.dashboard.loading') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="error" class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8">
|
|
<p class="text-rose-400">{{ t('gamification.dashboard.load_error') }}</p>
|
|
</div>
|
|
|
|
<!-- Dashboard Content -->
|
|
<template v-else>
|
|
<!-- Stat Tiles -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
|
|
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-amber-500/50 transition group">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="p-2 rounded-lg bg-amber-500/10 text-amber-400 group-hover:bg-amber-500/20 transition">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.dashboard.total_users') }}</h3>
|
|
<p class="text-3xl font-bold text-white mt-2">{{ stats.total_users }}</p>
|
|
</div>
|
|
|
|
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-emerald-500/50 transition group">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="p-2 rounded-lg bg-emerald-500/10 text-emerald-400 group-hover:bg-emerald-500/20 transition">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.dashboard.active_season') }}</h3>
|
|
<p class="text-3xl font-bold text-white mt-2">{{ stats.active_season || t('gamification.dashboard.none') }}</p>
|
|
</div>
|
|
|
|
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-violet-500/50 transition group">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="p-2 rounded-lg bg-violet-500/10 text-violet-400 group-hover:bg-violet-500/20 transition">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.dashboard.pending_contributions') }}</h3>
|
|
<p class="text-3xl font-bold text-white mt-2">{{ stats.pending_contributions }}</p>
|
|
</div>
|
|
|
|
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-cyan-500/50 transition group">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="p-2 rounded-lg bg-cyan-500/10 text-cyan-400 group-hover:bg-cyan-500/20 transition">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.dashboard.today_xp') }}</h3>
|
|
<p class="text-3xl font-bold text-white mt-2">{{ stats.today_xp }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Links -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
|
<h2 class="text-lg font-semibold text-white mb-4">{{ t('gamification.dashboard.quick_actions') }}</h2>
|
|
<div class="space-y-3">
|
|
<NuxtLink to="/gamification/point-rules" class="flex items-center gap-3 px-4 py-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-sm text-slate-300 hover:text-white transition">
|
|
<svg class="w-5 h-5 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
{{ t('gamification.dashboard.manage_point_rules') }}
|
|
</NuxtLink>
|
|
<NuxtLink to="/gamification/badges" class="flex items-center gap-3 px-4 py-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-sm text-slate-300 hover:text-white transition">
|
|
<svg class="w-5 h-5 text-violet-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
|
</svg>
|
|
{{ t('gamification.dashboard.manage_badges') }}
|
|
</NuxtLink>
|
|
<NuxtLink to="/gamification/seasons" class="flex items-center gap-3 px-4 py-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-sm text-slate-300 hover:text-white transition">
|
|
<svg class="w-5 h-5 text-emerald-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
{{ t('gamification.dashboard.manage_seasons') }}
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
|
<h2 class="text-lg font-semibold text-white mb-4">{{ t('gamification.dashboard.recent_ledger') }}</h2>
|
|
<div v-if="recentLedger.length === 0" class="text-sm text-slate-500 text-center py-8">
|
|
{{ t('gamification.dashboard.no_ledger_entries') }}
|
|
</div>
|
|
<div v-else class="space-y-3">
|
|
<div v-for="entry in recentLedger" :key="entry.id" class="flex items-center justify-between py-2 border-b border-slate-700/50 last:border-0">
|
|
<div>
|
|
<p class="text-sm text-slate-300">{{ entry.reason }}</p>
|
|
<p class="text-xs text-slate-500">{{ entry.created_at }}</p>
|
|
</div>
|
|
<span class="text-sm font-semibold" :class="entry.points >= 0 ? 'text-emerald-400' : 'text-rose-400'">
|
|
{{ entry.points >= 0 ? '+' : '' }}{{ entry.points }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Top 5 Leaderboard -->
|
|
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-lg font-semibold text-white">{{ t('gamification.dashboard.top_5_leaderboard') }}</h2>
|
|
<NuxtLink to="/gamification/leaderboard" class="text-sm text-indigo-400 hover:text-indigo-300 transition">
|
|
{{ t('gamification.dashboard.full_leaderboard') }} →
|
|
</NuxtLink>
|
|
</div>
|
|
<div v-if="topLeaderboard.length === 0" class="text-sm text-slate-500 text-center py-8">
|
|
{{ t('gamification.dashboard.no_leaderboard_data') }}
|
|
</div>
|
|
<div v-else class="space-y-2">
|
|
<div v-for="(entry, idx) in topLeaderboard" :key="idx" class="flex items-center gap-4 px-4 py-3 rounded-lg" :class="idx < 3 ? 'bg-slate-700/50' : ''">
|
|
<span class="w-8 text-center text-lg font-bold" :class="idx === 0 ? 'text-amber-400' : idx === 1 ? 'text-slate-300' : idx === 2 ? 'text-amber-700' : 'text-slate-500'">
|
|
#{{ idx + 1 }}
|
|
</span>
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium text-white">{{ entry.user_name }}</p>
|
|
<p class="text-xs text-slate-500">{{ t('gamification.dashboard.level') }} {{ entry.level }} · {{ entry.xp }} {{ t('gamification.dashboard.xp_abbr') }}</p>
|
|
</div>
|
|
<span class="text-sm font-semibold text-emerald-400">{{ entry.total_points }} {{ t('gamification.dashboard.points') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({
|
|
middleware: 'auth',
|
|
})
|
|
|
|
const { t } = useI18n()
|
|
|
|
interface DashboardStats {
|
|
total_users: number
|
|
active_season: string | null
|
|
pending_contributions: number
|
|
today_xp: number
|
|
}
|
|
|
|
interface LedgerEntry {
|
|
id: number
|
|
user_name: string
|
|
points: number
|
|
reason: string
|
|
created_at: string
|
|
}
|
|
|
|
interface LeaderboardEntry {
|
|
user_name: string
|
|
level: number
|
|
xp: number
|
|
total_points: number
|
|
}
|
|
|
|
const loading = ref(true)
|
|
const error = ref(false)
|
|
const stats = ref<DashboardStats>({
|
|
total_users: 0,
|
|
active_season: null,
|
|
pending_contributions: 0,
|
|
today_xp: 0,
|
|
})
|
|
const recentLedger = ref<LedgerEntry[]>([])
|
|
const topLeaderboard = ref<LeaderboardEntry[]>([])
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const tokenCookie = useCookie('access_token')
|
|
const token = tokenCookie.value
|
|
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
|
|
const [statsData, ledgerData, leaderboardData] = await Promise.all([
|
|
$fetch('/api/v1/admin/gamification/dashboard-summary', { headers }).catch(() => ({ total: 0 })),
|
|
$fetch('/api/v1/admin/gamification/points-ledger?limit=5', { headers }).catch(() => []),
|
|
$fetch('/api/v1/gamification/leaderboard?limit=5', { headers }).catch(() => []),
|
|
])
|
|
|
|
stats.value = {
|
|
total_users: statsData.total ?? 0,
|
|
active_season: statsData.active_season ?? null,
|
|
pending_contributions: statsData.pending_contributions ?? 0,
|
|
today_xp: statsData.today_xp ?? 0,
|
|
}
|
|
recentLedger.value = Array.isArray(ledgerData) ? ledgerData.slice(0, 5) : []
|
|
topLeaderboard.value = Array.isArray(leaderboardData) ? leaderboardData.slice(0, 5).map((entry: any) => ({
|
|
user_name: entry.username || entry.user_name || `User #${entry.user_id}`,
|
|
level: entry.current_level || entry.level || 0,
|
|
xp: entry.total_xp || entry.xp || 0,
|
|
total_points: entry.total_points || entry.total_xp || 0,
|
|
})) : []
|
|
} catch (e) {
|
|
console.error('Failed to load gamification dashboard:', e)
|
|
error.value = true
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
</script>
|