<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Task, type XpEvent, type XpSummary } from '../api'
import { useRealtimeRefresh } from '../realtime'
import AchievementsPanel from '../components/AchievementsPanel.vue'
defineOptions({ name: 'AchievementsView' })
// Достижения (ТЗ 3.13, 0.58): XP, уровни, звания, монеты и ачивки —
// без сада. Страница намеренно не зовёт GET /api/garden: растения рождаются
// лениво именно в этом эндпоинте, так что отказ от него и есть отключение
// выдачи растений (сад вернётся позже — см. docs/TZ.md).
const { t, tm } = useI18n()
const tasks = ref<Task[]>([])
const events = ref<XpEvent[]>([])
const xp = ref<XpSummary | null>(null)
const error = ref('')
const loading = ref(false)
// Справка «как это работает»: модалка с абзацами из локали
const aboutOpen = ref(false)
const aboutParagraphs = computed(() => {
const messages = tm('progress.about')
return Array.isArray(messages)
? (messages as unknown[]).filter((p): p is string => typeof p === 'string')
: []
})
// Званий столько же, сколько уровней сада (progress.levels)
const LEVEL_COUNT = 13
// Название уровня: 13 званий (progress.levels), дальше — последнее
function levelName(level: number): string {
const idx = Math.min(level, LEVEL_COUNT) - 1
return t(`progress.levels.${idx}`)
}
// --- Дерево званий: только звания и пороги XP (что открывается — в саду) ---
const levelsOpen = ref(false)
const levelsTree = computed(() =>
Array.from({ length: LEVEL_COUNT }, (_, i) => {
const level = i + 1
return {
level,
name: levelName(level),
threshold: (100 * level * (level - 1)) / 2, // порог XP: 100·L·(L−1)/2
}
}),
)
async function load(): Promise<void> {
loading.value = true
error.value = ''
try {
const [taskList, xpSummary, eventList] = await Promise.all([
api.listTasks(),
api.getXp(),
api.getXpEvents(),
])
tasks.value = taskList
xp.value = xpSummary
events.value = eventList
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
onMounted(load)
// --- Реактивность (ТЗ 3.14) ---
useRealtimeRefresh(load, ['xp.changed', 'task.changed'])
</script>
<template>
<section>
<!-- Скелетон: бейджи прогресса + сетка ачивок -->
<div v-if="loading && !xp" class="page-skeleton" aria-hidden="true">
<div class="page-skeleton-lines">
<span class="skeleton skeleton-line" style="width: 38%" />
<span class="skeleton skeleton-line" style="width: 24%" />
</div>
<span class="skeleton skeleton-block" style="height: 120px" />
</div>
<template v-else>
<GnPageHeader kicker="GNexus Tasks" :title="t('progress.title')">
<template #meta>
<GnBadge variant="primary"><i class="ph ph-trophy" aria-hidden="true" />
{{ t('progress.level', { n: xp?.level ?? 1 }) }} · {{ levelName(xp?.level ?? 1) }}
</GnBadge>
<GnBadge variant="neutral"><i class="ph ph-lightning" aria-hidden="true" />
{{ t('progress.xpTotal', { n: xp?.total_xp ?? 0 }) }}
</GnBadge>
<GnBadge variant="warning" :title="t('progress.coinsHint')">
<i class="ph ph-coins" aria-hidden="true" />
{{ t('progress.coins', { n: xp?.coins ?? 0 }) }}
</GnBadge>
</template>
<template #actions>
<!-- Спиннер обновления: данные уже есть, сводка дотягивается -->
<GnLoader
v-if="loading"
circle
label=""
:aria-label="t('common.loading')"
class="refresh-loader"
/>
<GnButton variant="secondary" icon="ph-list-numbers" @click="levelsOpen = true">
{{ t('progress.levelsTitle') }}
</GnButton>
<GnIconButton
icon="ph-question"
:label="t('progress.aboutOpen')"
:title="t('progress.aboutOpen')"
@click="aboutOpen = true"
/>
</template>
</GnPageHeader>
<!-- Справка: как считается опыт, монеты и что отмечают достижения -->
<GnModal :open="aboutOpen" :title="t('progress.aboutTitle')" @update:open="aboutOpen = $event">
<div class="about-body">
<p v-for="(p, i) in aboutParagraphs" :key="i">{{ p }}</p>
</div>
</GnModal>
<!-- Дерево званий: звание и порог XP каждого уровня -->
<GnModal
:open="levelsOpen"
:title="t('progress.levelsTitle')"
@update:open="levelsOpen = $event"
>
<p class="levels-hint">{{ t('progress.levelsHint') }}</p>
<div class="levels-tree">
<div
v-for="node in levelsTree"
:key="node.level"
class="level-node"
:class="{ current: node.level === xp?.level, past: node.level < (xp?.level ?? 1) }"
>
<div class="level-node-head">
<i
:class="node.level < (xp?.level ?? 1) ? 'ph ph-check-circle' : 'ph ph-lock-simple'"
aria-hidden="true"
/>
<span class="level-node-title">
{{ t('progress.level', { n: node.level }) }} · {{ node.name }}
</span>
<GnBadge variant="neutral" class="level-threshold">
<i class="ph ph-lightning" aria-hidden="true" /> {{ node.threshold }}
</GnBadge>
<GnBadge v-if="node.level === xp?.level" variant="success">
{{ t('progress.levelCurrent') }}
</GnBadge>
</div>
</div>
</div>
</GnModal>
<GnAlert v-if="error" variant="error">{{ error }}</GnAlert>
<!-- Прогресс уровня: XP только растёт, уровень не падает -->
<GnProgress
v-if="xp"
class="level-progress"
:value="xp.level_xp"
:max="xp.level_span"
:label="t('progress.levelProgress', { xp: xp.level_xp, span: xp.level_span })"
/>
<!-- Ачивки с тирами: считаются по задачам и событиям XP -->
<AchievementsPanel :tasks="tasks" :events="events" :level="xp?.level ?? 1" />
</template>
</section>
</template>
<style scoped>
.page-skeleton {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 12px;
}
.page-skeleton-lines {
display: flex;
flex-direction: column;
gap: 10px;
}
.level-progress {
margin-bottom: 1rem;
/* на всю ширину контейнера (в ките .progress упёрт в max-width: 640px) */
max-width: 100%;
}
/* справка */
.about-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
font-size: 0.95em;
line-height: 1.5;
}
/* дерево званий */
.levels-hint {
margin: 0 0 0.75rem;
font-size: 0.88em;
color: var(--text-muted, #888);
line-height: 1.45;
}
.levels-tree {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.level-node {
padding: 0.6rem 0.75rem;
border: 2px solid var(--gn-border-color-muted, rgba(192, 202, 245, 0.24));
border-radius: 6px;
opacity: 0.6;
}
.level-node.past {
opacity: 0.85;
/* фон как у пройденных достижений: зелёная подложка */
background: color-mix(in srgb, var(--success, #9ece6a) 8%, var(--gn-surface-panel, #1f2335));
}
.level-node.current {
opacity: 1;
background: color-mix(in srgb, var(--accent, #7aa2f7) 8%, var(--gn-surface-panel, #1f2335));
border-color: color-mix(
in srgb,
var(--accent, #7aa2f7) 45%,
var(--gn-border-color-muted, rgba(192, 202, 245, 0.24))
);
}
.level-node-head {
display: flex;
gap: 0.5rem;
align-items: center;
font-weight: 600;
font-size: 0.95em;
}
.level-node-head > i {
font-size: 1.1rem;
color: var(--text-muted, #888);
}
.level-node.past > .level-node-head > i,
.level-node.current > .level-node-head > i {
color: var(--success, #9ece6a);
}
.level-node-title {
flex: 1 1 auto;
}
.level-threshold {
white-space: nowrap;
}
</style>