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

546 lines
23 KiB
Vue

<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('gamification.config.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.config.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.config.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.config.load_error') }}</p>
<button @click="fetchConfig" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.config.retry') }}</button>
</div>
<template v-else>
<!-- Save Button Bar -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500">{{ t('gamification.config.last_saved') }} {{ lastSaved ? lastSaved : '—' }}</span>
</div>
<button @click="saveConfig" :disabled="saving" class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 disabled:bg-emerald-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg v-if="saving" 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>
{{ saving ? t('gamification.config.saving') : t('gamification.config.save_config') }}
</button>
</div>
<!-- Config Sections Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- XP Logic -->
<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-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{{ t('gamification.config.xp_logic') }}
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.config.base_xp') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="xpLogic.baseXp"
type="range"
min="0"
max="10000"
step="100"
class="flex-1 accent-amber-500"
/>
<input
v-model.number="xpLogic.baseXp"
type="number"
min="0"
max="10000"
class="w-20 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.config.exponent') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="xpLogic.exponent"
type="range"
min="0.1"
max="5.0"
step="0.1"
class="flex-1 accent-amber-500"
/>
<input
v-model.number="xpLogic.exponent"
type="number"
min="0.1"
max="5.0"
step="0.1"
class="w-20 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
</div>
</div>
</div>
</div>
<!-- Conversion Logic -->
<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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
{{ t('gamification.config.conversion_logic') }}
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.config.social_credit_rate') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="conversionLogic.socialToCreditRate"
type="range"
min="0"
max="10000"
step="10"
class="flex-1 accent-emerald-500"
/>
<input
v-model.number="conversionLogic.socialToCreditRate"
type="number"
min="0"
max="10000"
class="w-20 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-emerald-500/50"
/>
</div>
</div>
</div>
</div>
<!-- Penalty Logic -->
<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.config.penalty_logic') }}
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.config.recovery_rate') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="penaltyLogic.recoveryRate"
type="range"
min="0"
max="1"
step="0.05"
class="flex-1 accent-rose-500"
/>
<input
v-model.number="penaltyLogic.recoveryRate"
type="number"
min="0"
max="1"
step="0.05"
class="w-20 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-rose-500/50"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">{{ t('gamification.config.thresholds') }}</label>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="block text-xs text-slate-500 mb-1">{{ t('gamification.config.level_1') }}</label>
<input v-model.number="penaltyLogic.thresholds.level1" type="number" min="0" class="w-full px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-rose-500/50" />
</div>
<div>
<label class="block text-xs text-slate-500 mb-1">{{ t('gamification.config.level_2') }}</label>
<input v-model.number="penaltyLogic.thresholds.level2" type="number" min="0" class="w-full px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-rose-500/50" />
</div>
<div>
<label class="block text-xs text-slate-500 mb-1">{{ t('gamification.config.level_3') }}</label>
<input v-model.number="penaltyLogic.thresholds.level3" type="number" min="0" class="w-full px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-rose-500/50" />
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">{{ t('gamification.config.multipliers') }}</label>
<div class="grid grid-cols-4 gap-2">
<div v-for="key in multiplierKeys" :key="key">
<label class="block text-xs text-slate-500 mb-1">{{ key }}</label>
<input v-model.number="penaltyLogic.multipliers[key]" type="number" min="0" max="1" step="0.1" class="w-full px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-rose-500/50" />
</div>
</div>
</div>
</div>
</div>
<!-- Level Rewards -->
<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-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
{{ t('gamification.config.level_rewards') }}
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.config.credits_per_10_levels') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="levelRewards.creditsPer10Levels"
type="range"
min="0"
max="10000"
step="10"
class="flex-1 accent-violet-500"
/>
<input
v-model.number="levelRewards.creditsPer10Levels"
type="number"
min="0"
max="10000"
class="w-20 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-violet-500/50"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Impact Calculator -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
{{ t('gamification.config.impact_calculator') }}
</h2>
<p class="text-sm text-slate-500 mb-6">{{ t('gamification.config.calculator_hint') }}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Calculator Inputs -->
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
{{ t('gamification.config.user_level') }}: <span class="text-cyan-400 font-bold">{{ calculator.level }}</span>
</label>
<input v-model.number="calculator.level" type="range" min="1" max="100" class="w-full accent-cyan-500" />
<div class="flex justify-between text-xs text-slate-600 mt-1">
<span>1</span><span>100</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
{{ t('gamification.config.daily_activities') }}: <span class="text-cyan-400 font-bold">{{ calculator.activities }}</span>
</label>
<input v-model.number="calculator.activities" type="range" min="1" max="20" class="w-full accent-cyan-500" />
<div class="flex justify-between text-xs text-slate-600 mt-1">
<span>1</span><span>20</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
{{ t('gamification.config.social_points') }}: <span class="text-cyan-400 font-bold">{{ calculator.socialPoints }}</span>
</label>
<input v-model.number="calculator.socialPoints" type="range" min="0" max="500" step="10" class="w-full accent-cyan-500" />
<div class="flex justify-between text-xs text-slate-600 mt-1">
<span>0</span><span>500</span>
</div>
</div>
</div>
<!-- Calculator Results -->
<div class="bg-slate-700/50 rounded-xl p-6 space-y-4">
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.config.estimated_results') }}</h3>
<div class="space-y-3">
<div class="flex items-center justify-between py-2 border-b border-slate-600/50">
<span class="text-sm text-slate-300">{{ t('gamification.config.daily_xp') }}</span>
<span class="text-lg font-bold text-amber-400">{{ formatNumber(calculatedDailyXp) }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-slate-600/50">
<span class="text-sm text-slate-300">{{ t('gamification.config.level_up_xp') }}</span>
<span class="text-lg font-bold text-emerald-400">{{ formatNumber(calculatedLevelUpXp) }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-slate-600/50">
<span class="text-sm text-slate-300">{{ t('gamification.config.social_credit') }}</span>
<span class="text-lg font-bold text-violet-400">{{ formatNumber(calculatedSocialCredit) }}</span>
</div>
<div class="flex items-center justify-between py-2">
<span class="text-sm text-slate-300">{{ t('gamification.config.days_to_level_up') }}</span>
<span class="text-lg font-bold text-cyan-400">{{ calculatedDaysToLevelUp }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Raw JSON Editor (Collapsible) -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<button @click="showRawJson = !showRawJson" class="flex items-center justify-between w-full">
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
{{ t('gamification.config.raw_json_editor') }}
</h2>
<svg class="w-5 h-5 text-slate-400 transition-transform" :class="showRawJson ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div v-if="showRawJson" class="mt-4">
<textarea
v-model="rawJson"
rows="20"
class="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-green-400 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50 resize-none"
spellcheck="false"
></textarea>
<div v-if="rawJsonError" class="mt-2 bg-rose-500/10 border border-rose-500/30 rounded-lg p-3">
<p class="text-sm text-rose-400 font-mono">{{ rawJsonError }}</p>
</div>
<button @click="applyRawJson" class="mt-3 px-4 py-2 bg-amber-600 hover:bg-amber-500 text-white rounded-lg text-sm font-medium transition">
{{ t('gamification.config.apply_json') }}
</button>
</div>
</div>
</template>
<!-- Form Error -->
<div v-if="formError" class="fixed bottom-6 right-6 z-50 bg-rose-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ formError }}
</div>
<!-- Success Toast -->
<div v-if="toast" class="fixed bottom-6 right-6 z-50 bg-emerald-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 animate-slide-up">
<svg class="w-4 h-4 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>
{{ toast }}
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface XpLogic {
baseXp: number
exponent: number
}
interface PenaltyThresholds {
level1: number
level2: number
level3: number
}
interface PenaltyMultipliers {
[key: string]: number
}
interface PenaltyLogic {
recoveryRate: number
thresholds: PenaltyThresholds
multipliers: PenaltyMultipliers
}
interface ConversionLogic {
socialToCreditRate: number
}
interface LevelRewards {
creditsPer10Levels: number
}
const loading = ref(true)
const error = ref(false)
const saving = ref(false)
const formError = ref('')
const toast = ref('')
const lastSaved = ref('')
const showRawJson = ref(false)
const rawJson = ref('')
const rawJsonError = ref('')
const multiplierKeys = ['L0', 'L1', 'L2', 'L3']
const xpLogic = reactive<XpLogic>({ baseXp: 500, exponent: 1.5 })
const penaltyLogic = reactive<PenaltyLogic>({
recoveryRate: 0.5,
thresholds: { level1: 100, level2: 500, level3: 1000 },
multipliers: { L0: 1.0, L1: 0.5, L2: 0.1, L3: 0.0 },
})
const conversionLogic = reactive<ConversionLogic>({ socialToCreditRate: 100 })
const levelRewards = reactive<LevelRewards>({ creditsPer10Levels: 50 })
const calculator = reactive({ level: 5, activities: 3, socialPoints: 50 })
const calculatedDailyXp = computed(() => {
return Math.round(xpLogic.baseXp * Math.pow(calculator.activities, xpLogic.exponent))
})
const calculatedLevelUpXp = computed(() => {
return calculator.level * 1000
})
const calculatedSocialCredit = computed(() => {
return conversionLogic.socialToCreditRate * calculator.socialPoints
})
const calculatedDaysToLevelUp = computed(() => {
const daily = calculatedDailyXp.value
if (daily <= 0) return '∞'
return Math.ceil(calculatedLevelUpXp.value / daily) + ' ' + t('gamification.config.days')
})
function getHeaders(): Record<string, string> {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }
}
function showToast(msg: string) {
toast.value = msg
setTimeout(() => { toast.value = '' }, 3000)
}
function buildConfigObject(): Record<string, any> {
return {
xp_logic: {
base_xp: xpLogic.baseXp,
exponent: xpLogic.exponent,
},
penalty_logic: {
recovery_rate: penaltyLogic.recoveryRate,
thresholds: {
level_1: penaltyLogic.thresholds.level1,
level_2: penaltyLogic.thresholds.level2,
level_3: penaltyLogic.thresholds.level3,
},
multipliers: { ...penaltyLogic.multipliers },
},
conversion_logic: {
social_to_credit_rate: conversionLogic.socialToCreditRate,
},
level_rewards: {
credits_per_10_levels: levelRewards.creditsPer10Levels,
},
}
}
function applyConfigToForm(cfg: Record<string, any>) {
if (cfg.xp_logic) {
xpLogic.baseXp = cfg.xp_logic.base_xp ?? 500
xpLogic.exponent = cfg.xp_logic.exponent ?? 1.5
}
if (cfg.penalty_logic) {
penaltyLogic.recoveryRate = cfg.penalty_logic.recovery_rate ?? 0.5
if (cfg.penalty_logic.thresholds) {
penaltyLogic.thresholds.level1 = cfg.penalty_logic.thresholds.level_1 ?? 100
penaltyLogic.thresholds.level2 = cfg.penalty_logic.thresholds.level_2 ?? 500
penaltyLogic.thresholds.level3 = cfg.penalty_logic.thresholds.level_3 ?? 1000
}
if (cfg.penalty_logic.multipliers) {
for (const key of multiplierKeys) {
penaltyLogic.multipliers[key] = cfg.penalty_logic.multipliers[key] ?? 1.0
}
}
}
if (cfg.conversion_logic) {
conversionLogic.socialToCreditRate = cfg.conversion_logic.social_to_credit_rate ?? 100
}
if (cfg.level_rewards) {
levelRewards.creditsPer10Levels = cfg.level_rewards.credits_per_10_levels ?? 50
}
}
async function fetchConfig() {
loading.value = true
error.value = false
try {
const data = await $fetch<{ key: string; value: Record<string, any> }>('/api/v1/admin/gamification/master-config', { headers: getHeaders() })
if (data.value && Object.keys(data.value).length > 0) {
applyConfigToForm(data.value)
}
rawJson.value = JSON.stringify(buildConfigObject(), null, 2)
} catch (e) {
console.error('Failed to fetch master config:', e)
error.value = true
} finally {
loading.value = false
}
}
async function saveConfig() {
saving.value = true
formError.value = ''
try {
const config = buildConfigObject()
await $fetch('/api/v1/admin/gamification/master-config', {
method: 'PUT',
headers: getHeaders(),
body: { config },
})
lastSaved.value = new Date().toLocaleString('hu-HU')
rawJson.value = JSON.stringify(config, null, 2)
showToast(t('gamification.config.config_updated'))
} catch (e: any) {
formError.value = t('gamification.config.save_error')
console.error('Failed to save master config:', e)
setTimeout(() => { formError.value = '' }, 4000)
} finally {
saving.value = false
}
}
function applyRawJson() {
rawJsonError.value = ''
try {
const parsed = JSON.parse(rawJson.value)
applyConfigToForm(parsed)
showRawJson.value = false
showToast(t('gamification.config.json_applied'))
} catch (e: any) {
rawJsonError.value = t('gamification.config.invalid_json') + ' ' + (e.message || '')
}
}
function formatNumber(n: number): string {
return new Intl.NumberFormat('hu-HU').format(n)
}
onMounted(() => {
fetchConfig()
})
</script>
<style scoped>
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>