393 lines
20 KiB
Vue
393 lines
20 KiB
Vue
<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="1–10000" 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="0–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-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="0–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-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>
|