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

393 lines
20 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>
<!-- Back Button & Header -->
<div class="mb-8">
<button
@click="goBack"
class="inline-flex items-center gap-1.5 text-sm text-slate-400 hover:text-white transition mb-4"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ t('gamification.user_detail.back') }}
</button>
<h1 class="text-2xl font-bold text-white">{{ t('gamification.user_detail.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.user_detail.user_id') }} #{{ $route.params.id }}</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.user_detail.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.user_detail.load_error') }}</p>
<button @click="fetchData" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.user_detail.retry') }}</button>
</div>
<!-- Not Found -->
<div v-else-if="!stats" class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<p class="text-slate-400 text-center py-12">{{ t('gamification.user_detail.not_found') }}</p>
</div>
<template v-else>
<!-- Stats Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_total_xp') }}</h3>
<p class="text-2xl font-bold text-white">{{ formatNumber(stats.total_xp) }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_level') }}</h3>
<p class="text-2xl font-bold text-indigo-400">{{ stats.current_level }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_total_points') }}</h3>
<p class="text-2xl font-bold text-emerald-400">{{ formatNumber(stats.total_points) }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_social_points') }}</h3>
<p class="text-2xl font-bold text-cyan-400">{{ formatNumber(stats.social_points) }}</p>
</div>
</div>
<!-- Penalty & Activity Stats -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_penalty_points') }}</h3>
<p class="text-2xl font-bold" :class="stats.penalty_points > 0 ? 'text-rose-400' : 'text-slate-400'">
{{ stats.penalty_points > 0 ? `-${stats.penalty_points}` : '0' }}
</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_restriction') }}</h3>
<p class="text-2xl font-bold" :class="stats.restriction_level > 0 ? 'text-amber-400' : 'text-slate-400'">
{{ stats.restriction_level > 0 ? restrictionLabel(stats.restriction_level) : t('gamification.user_detail.none') }}
</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_penalty_quota') }}</h3>
<p class="text-2xl font-bold text-slate-300">{{ stats.penalty_quota_remaining }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_banned') }}</h3>
<p class="text-2xl font-bold" :class="stats.banned_until ? 'text-rose-400' : 'text-slate-400'">
{{ stats.banned_until ? formatDate(stats.banned_until) : t('gamification.user_detail.no') }}
</p>
</div>
</div>
<!-- Activity Stats -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_services') }}</h3>
<p class="text-2xl font-bold text-white">{{ stats.services_submitted }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_discovered') }}</h3>
<p class="text-2xl font-bold text-white">{{ stats.places_discovered }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_validated') }}</h3>
<p class="text-2xl font-bold text-white">{{ stats.places_validated }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_providers') }}</h3>
<p class="text-2xl font-bold text-white">{{ stats.providers_added_count }}</p>
</div>
</div>
<!-- Actions: Penalty & Reward -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Penalty Form -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
{{ t('gamification.user_detail.penalty_title') }}
</h2>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.penalty_points_label') }}</label>
<input v-model.number="penaltyForm.amount" type="number" min="1" max="10000" placeholder="110000" 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-rose-500 focus:border-transparent" />
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.penalty_reason_label') }}</label>
<textarea v-model="penaltyForm.reason" rows="2" :placeholder="t('gamification.user_detail.penalty_reason_placeholder')" 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-rose-500 focus:border-transparent resize-none"></textarea>
</div>
<button @click="applyPenalty" :disabled="penaltySubmitting || !penaltyForm.amount || !penaltyForm.reason" class="w-full px-4 py-2.5 bg-rose-600 hover:bg-rose-500 disabled:bg-rose-600/50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition flex items-center justify-center gap-2">
<svg v-if="penaltySubmitting" class="w-4 h-4 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>
{{ penaltySubmitting ? t('gamification.user_detail.penalty_submitting') : t('gamification.user_detail.penalty_submit') }}
</button>
<p v-if="penaltyError" class="text-xs text-rose-400">{{ penaltyError }}</p>
<p v-if="penaltySuccess" class="text-xs text-emerald-400">{{ penaltySuccess }}</p>
</div>
</div>
<!-- Reward Form -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-emerald-400" 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.user_detail.reward_title') }}
</h2>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.reward_xp_label') }}</label>
<input v-model.number="rewardForm.xp_amount" type="number" min="0" max="100000" placeholder="0100000" 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-emerald-500 focus:border-transparent" />
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.reward_social_label') }}</label>
<input v-model.number="rewardForm.social_amount" type="number" min="0" max="100000" placeholder="0100000" 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-emerald-500 focus:border-transparent" />
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.reward_reason_label') }}</label>
<textarea v-model="rewardForm.reason" rows="2" :placeholder="t('gamification.user_detail.reward_reason_placeholder')" 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-emerald-500 focus:border-transparent resize-none"></textarea>
</div>
<button @click="applyReward" :disabled="rewardSubmitting || !rewardForm.xp_amount || !rewardForm.reason" class="w-full px-4 py-2.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-emerald-600/50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition flex items-center justify-center gap-2">
<svg v-if="rewardSubmitting" class="w-4 h-4 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>
{{ rewardSubmitting ? t('gamification.user_detail.reward_submitting') : t('gamification.user_detail.reward_submit') }}
</button>
<p v-if="rewardError" class="text-xs text-rose-400">{{ rewardError }}</p>
<p v-if="rewardSuccess" class="text-xs text-emerald-400">{{ rewardSuccess }}</p>
</div>
</div>
</div>
<!-- Points Ledger -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">{{ t('gamification.user_detail.ledger_title') }}</h2>
<span class="text-xs text-slate-500">{{ t('gamification.user_detail.ledger_count', { count: ledger.length }) }}</span>
</div>
<div v-if="ledgerLoading" class="flex items-center justify-center py-8">
<div class="text-slate-400 flex items-center gap-2">
<svg class="w-4 h-4 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.user_detail.ledger_loading') }}</span>
</div>
</div>
<div v-else-if="ledger.length === 0" class="text-sm text-slate-500 text-center py-8">
{{ t('gamification.user_detail.ledger_empty') }}
</div>
<div v-else class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-slate-700">
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_date') }}</th>
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_points') }}</th>
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_penalty') }}</th>
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_xp') }}</th>
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_reason') }}</th>
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_source') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="entry in ledger" :key="entry.id" class="hover:bg-slate-700/30 transition">
<td class="px-3 py-2.5 text-xs text-slate-400 whitespace-nowrap">{{ formatDateTime(entry.created_at) }}</td>
<td class="px-3 py-2.5 text-sm font-medium" :class="entry.points >= 0 ? 'text-emerald-400' : 'text-rose-400'">
{{ entry.points >= 0 ? '+' : '' }}{{ entry.points }}
</td>
<td class="px-3 py-2.5 text-sm" :class="entry.penalty_change ? 'text-rose-400' : 'text-slate-500'">
{{ entry.penalty_change ? `-${entry.penalty_change}` : '—' }}
</td>
<td class="px-3 py-2.5 text-sm text-slate-300">{{ entry.xp ?? '—' }}</td>
<td class="px-3 py-2.5 text-sm text-slate-300 max-w-xs truncate">{{ entry.reason }}</td>
<td class="px-3 py-2.5 text-xs text-slate-500">
<span v-if="entry.source_type" class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-slate-700 text-slate-400">
{{ entry.source_type }}{{ entry.source_id ? ` #${entry.source_id}` : '' }}
</span>
<span v-else></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</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
}
interface LedgerEntry {
id: number
user_id: number
user_name: string
points: number
penalty_change: number | null
reason: string
xp: number | null
source_type: string | null
source_id: number | null
created_at: string
}
const { formatNumber, formatDate, formatDateTime } = useFormatter()
const router = useRouter()
const route = useRoute()
const userId = computed(() => Number(route.params.id))
const loading = ref(true)
const error = ref(false)
const stats = ref<UserStats | null>(null)
const ledger = ref<LedgerEntry[]>([])
const ledgerLoading = ref(false)
// Penalty form
const penaltyForm = ref({ amount: 0, reason: '' })
const penaltySubmitting = ref(false)
const penaltyError = ref('')
const penaltySuccess = ref('')
// Reward form
const rewardForm = ref({ xp_amount: 0, social_amount: 0, reason: '' })
const rewardSubmitting = ref(false)
const rewardError = ref('')
const rewardSuccess = ref('')
function restrictionLabel(level: number): string {
switch (level) {
case 1: return t('gamification.user_detail.restriction_mild')
case 2: return t('gamification.user_detail.restriction_moderate')
case 3: return t('gamification.user_detail.restriction_severe')
default: return `${level}. ${t('gamification.user_detail.restriction_level')}`
}
}
function goBack() {
router.push('/gamification/users')
}
function getHeaders() {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function fetchData() {
loading.value = true
error.value = false
try {
const headers = getHeaders()
const data = await $fetch<UserStats>(`/api/v1/admin/gamification/user-stats/${userId.value}`, { headers })
stats.value = data
await fetchLedger()
} catch (e: any) {
console.error('Failed to load user stats:', e)
error.value = true
} finally {
loading.value = false
}
}
async function fetchLedger() {
ledgerLoading.value = true
try {
const headers = getHeaders()
const data = await $fetch<LedgerEntry[]>(`/api/v1/admin/gamification/points-ledger?user_id=${userId.value}&limit=50`, { headers })
ledger.value = data
} catch (e) {
console.error('Failed to load ledger:', e)
ledger.value = []
} finally {
ledgerLoading.value = false
}
}
async function applyPenalty() {
if (!penaltyForm.value.amount || !penaltyForm.value.reason) return
penaltySubmitting.value = true
penaltyError.value = ''
penaltySuccess.value = ''
try {
const headers = getHeaders()
await $fetch(`/api/v1/admin/gamification/user-stats/${userId.value}/penalty`, {
method: 'POST',
headers,
body: {
amount: penaltyForm.value.amount,
reason: penaltyForm.value.reason,
},
})
penaltySuccess.value = t('gamification.user_detail.penalty_success', { amount: penaltyForm.value.amount })
penaltyForm.value = { amount: 0, reason: '' }
await fetchData()
} catch (e: any) {
penaltyError.value = e?.data?.detail || e?.message || t('gamification.user_detail.penalty_error')
} finally {
penaltySubmitting.value = false
}
}
async function applyReward() {
if (!rewardForm.value.xp_amount || !rewardForm.value.reason) return
rewardSubmitting.value = true
rewardError.value = ''
rewardSuccess.value = ''
try {
const headers = getHeaders()
await $fetch(`/api/v1/admin/gamification/user-stats/${userId.value}/reward`, {
method: 'POST',
headers,
body: {
xp_amount: rewardForm.value.xp_amount,
social_amount: rewardForm.value.social_amount || 0,
reason: rewardForm.value.reason,
},
})
rewardSuccess.value = t('gamification.user_detail.reward_success', { xp: rewardForm.value.xp_amount, social: rewardForm.value.social_amount || 0 })
rewardForm.value = { xp_amount: 0, social_amount: 0, reason: '' }
await fetchData()
} catch (e: any) {
rewardError.value = e?.data?.detail || e?.message || t('gamification.user_detail.reward_error')
} finally {
rewardSubmitting.value = false
}
}
onMounted(() => {
fetchData()
})
</script>