Files
2026-06-30 08:56:55 +00:00

253 lines
12 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('gamification.users.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.users.subtitle') }}</p>
</div>
<!-- Filters -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Level Filter -->
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.users.filter_level') }}</label>
<select v-model="filters.level" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent">
<option value="">{{ t('gamification.users.all_levels') }}</option>
<option v-for="lvl in 20" :key="lvl" :value="lvl">{{ lvl }}. {{ t('gamification.users.level_suffix') }}</option>
</select>
</div>
<!-- Min XP -->
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.users.filter_min_xp') }}</label>
<input v-model.number="filters.min_xp" type="number" min="0" placeholder="0" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent" />
</div>
<!-- Max XP -->
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.users.filter_max_xp') }}</label>
<input v-model.number="filters.max_xp" type="number" min="0" placeholder="100000" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent" />
</div>
<!-- Has Penalty -->
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.users.filter_penalty') }}</label>
<select v-model="filters.has_penalty" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent">
<option value="">{{ t('gamification.users.penalty_any') }}</option>
<option :value="true">{{ t('gamification.users.penalty_only') }}</option>
<option :value="false">{{ t('gamification.users.penalty_none') }}</option>
</select>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-3 mt-4 pt-4 border-t border-slate-700/50">
<button @click="applyFilters" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition">
{{ t('gamification.users.filter_apply') }}
</button>
<button @click="resetFilters" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm font-medium rounded-lg transition">
{{ t('gamification.users.filter_reset') }}
</button>
<span class="text-xs text-slate-500 ml-auto">{{ t('gamification.users.total_count', { count: totalCount }) }}</span>
</div>
</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.users.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.users.load_error') }}</p>
<button @click="fetchUsers" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.users.retry') }}</button>
</div>
<!-- Empty State -->
<div v-else-if="users.length === 0" class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<p class="text-slate-400 text-center py-12">{{ t('gamification.users.empty') }}</p>
</div>
<!-- Users Table -->
<div v-else class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-slate-700">
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_user_id') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_xp') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_level') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_points') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_penalty') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_restriction') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_services') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_discoveries') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_updated') }}</th>
<th class="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="user in users" :key="user.user_id" class="hover:bg-slate-700/30 transition">
<td class="px-4 py-3 text-sm text-white font-mono">#{{ user.user_id }}</td>
<td class="px-4 py-3 text-sm text-white">{{ formatNumber(user.total_xp) }}</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">
{{ user.current_level }}
</span>
</td>
<td class="px-4 py-3 text-sm text-slate-300">{{ formatNumber(user.total_points) }}</td>
<td class="px-4 py-3">
<span v-if="user.penalty_points > 0" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-rose-500/10 text-rose-400 border border-rose-500/20">
-{{ user.penalty_points }}
</span>
<span v-else class="text-sm text-slate-500"></span>
</td>
<td class="px-4 py-3">
<span v-if="user.restriction_level > 0" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
{{ restrictionLabel(user.restriction_level) }}
</span>
<span v-else class="text-sm text-slate-500">{{ t('gamification.users.none') }}</span>
</td>
<td class="px-4 py-3 text-sm text-slate-300">{{ user.services_submitted }}</td>
<td class="px-4 py-3 text-sm text-slate-300">{{ user.places_discovered }}</td>
<td class="px-4 py-3 text-sm text-slate-500">{{ user.updated_at ? formatDate(user.updated_at) : '—' }}</td>
<td class="px-4 py-3 text-right">
<NuxtLink :to="`/gamification/users/${user.user_id}`" class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600/10 hover:bg-indigo-600/20 text-indigo-400 text-xs font-medium rounded-lg transition">
{{ t('gamification.users.details') }}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</NuxtLink>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-between px-4 py-3 border-t border-slate-700">
<button @click="prevPage" :disabled="offset === 0" class="px-3 py-1.5 text-sm rounded-lg transition" :class="offset === 0 ? 'text-slate-600 cursor-not-allowed' : 'text-slate-300 hover:bg-slate-700'">
{{ t('gamification.users.prev') }}
</button>
<span class="text-sm text-slate-500">{{ offset + 1 }}{{ Math.min(offset + limit, totalCount) }} / {{ totalCount }}</span>
<button @click="nextPage" :disabled="offset + limit >= totalCount" class="px-3 py-1.5 text-sm rounded-lg transition" :class="offset + limit >= totalCount ? 'text-slate-600 cursor-not-allowed' : 'text-slate-300 hover:bg-slate-700'">
{{ t('gamification.users.next') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface UserStats {
user_id: number
total_xp: number
total_points: number
social_points: number
current_level: number
penalty_points: number
restriction_level: number
penalty_quota_remaining: number
places_discovered: number
places_validated: number
providers_added_count: number
services_submitted: number
banned_until: string | null
updated_at: string | null
}
const { formatNumber, formatDate } = useFormatter()
const loading = ref(false)
const error = ref(false)
const users = ref<UserStats[]>([])
const totalCount = ref(0)
const offset = ref(0)
const limit = ref(50)
const filters = ref({
level: '' as number | string,
min_xp: null as number | null,
max_xp: null as number | null,
has_penalty: '' as boolean | string,
})
function restrictionLabel(level: number): string {
switch (level) {
case 1: return t('gamification.users.restriction_mild')
case 2: return t('gamification.users.restriction_moderate')
case 3: return t('gamification.users.restriction_severe')
default: return `${level}. ${t('gamification.users.restriction_level')}`
}
}
function buildQueryParams(): string {
const params = new URLSearchParams()
params.set('limit', String(limit.value))
params.set('offset', String(offset.value))
if (filters.value.level !== '') params.set('level', String(filters.value.level))
if (filters.value.min_xp !== null) params.set('min_xp', String(filters.value.min_xp))
if (filters.value.max_xp !== null) params.set('max_xp', String(filters.value.max_xp))
if (filters.value.has_penalty !== '') params.set('has_penalty', String(filters.value.has_penalty))
return params.toString()
}
async function fetchUsers() {
loading.value = true
error.value = false
try {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
const headers = token ? { Authorization: `Bearer ${token}` } : {}
const data = await $fetch<UserStats[]>(`/api/v1/admin/gamification/user-stats?${buildQueryParams()}`, { headers })
users.value = data
totalCount.value = data.length < limit.value ? offset.value + data.length : offset.value + limit.value + 1
} catch (e) {
console.error('Failed to load user stats:', e)
error.value = true
} finally {
loading.value = false
}
}
function applyFilters() {
offset.value = 0
fetchUsers()
}
function resetFilters() {
filters.value = { level: '', min_xp: null, max_xp: null, has_penalty: '' }
offset.value = 0
fetchUsers()
}
function prevPage() {
if (offset.value > 0) {
offset.value = Math.max(0, offset.value - limit.value)
fetchUsers()
}
}
function nextPage() {
offset.value += limit.value
fetchUsers()
}
onMounted(() => {
fetchUsers()
})
</script>