253 lines
12 KiB
Vue
253 lines
12 KiB
Vue
<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>
|