feat: fix leaderboard, redesign student profile, olympiad admin, ru i18n

LeaderBoard:
- Fix bug: allUsers now loaded before logsResource on mount
- Fallback: show users with 0 points when Energy Point Log is empty
- Replace FontAwesome with lucide-vue-next (Crown, Medal, Trophy, User)
- Replace all hardcoded hex colors with Frappe UI CSS variables

StudentProfile:
- Remove ~40 debug console.log statements
- Increase avatar to 96px, use amber CSS token gradient
- All scoped CSS colors converted to rgb(var(--color-*)) tokens

Olympiads admin:
- Add OlympiadForm.vue for create/edit with all fields
- Add routes /olympiads/new and /olympiads/:name/edit
- Add "Create Olympiad" button for moderators/instructors
- Add sidebar entry for admin users

Russian translations:
- Add Olympiad module strings to lms/locale/ru.po

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 06:52:15 +03:00
parent c9dbc12a44
commit 4d80c84775
7 changed files with 707 additions and 249 deletions

View File

@@ -1,10 +1,10 @@
<template>
<div class="lms-page-container">
<!-- HEADER & TABS -->
<div class="lms-page-header">
<h1 class="page-title">{{ __('Таблица лидеров') }}</h1>
<!-- Сегментированные вкладки (Apple / Frappe style) -->
<div class="lms-tabs-container">
<button
@@ -21,23 +21,23 @@
<!-- MAIN GRID LAYOUT -->
<div class="lms-layout-grid">
<!-- LEFT COLUMN: SUMMARY CARDS -->
<div class="lms-sidebar">
<!-- Карточка: Ваш результат -->
<div class="lms-card my-result-card">
<div class="lms-card-body">
<div class="card-header">
<span class="card-subtitle">{{ __('Ваш результат') }}</span>
<div v-if="currentUserPosition === 1" class="icon-gold"><i class="fas fa-crown"></i></div>
<div v-else-if="currentUserPosition === 2" class="icon-silver"><i class="fas fa-medal"></i></div>
<div v-else-if="currentUserPosition === 3" class="icon-bronze"><i class="fas fa-medal"></i></div>
<div v-else class="icon-gray"><i class="fas fa-user"></i></div>
<div v-if="currentUserPosition === 1" class="icon-gold"><Crown class="icon-svg" /></div>
<div v-else-if="currentUserPosition === 2" class="icon-silver"><Medal class="icon-svg" /></div>
<div v-else-if="currentUserPosition === 3" class="icon-bronze"><Medal class="icon-svg" /></div>
<div v-else class="icon-gray"><User class="icon-svg" /></div>
</div>
<h2 class="card-title truncate-text" :title="currentUser.full_name">{{ currentUser.full_name || __('Вы') }}</h2>
<div class="card-stats">
<div class="stat-block">
<span class="stat-value">{{ currentUserPosition !== '-' ? currentUserPosition : '-' }}</span>
@@ -51,10 +51,10 @@
<div class="card-footer">
<p v-if="pointsToNext > 0 && currentUserPosition !== '-'" class="text-muted">
{{ __('До') }} {{ nextPosition }}-{{ __('го места не хватает') }} <strong style="color: #111827;">{{ pointsToNext }}</strong> {{ __('очков') }}
{{ __('До') }} {{ nextPosition }}-{{ __('го места не хватает') }} <strong class="text-strong">{{ pointsToNext }}</strong> {{ __('очков') }}
</p>
<p v-else-if="currentUserPosition === 1" class="text-success">
<i class="fas fa-check-circle"></i> {{ __('Вы на первом месте!') }}
{{ __('Вы на первом месте!') }}
</p>
<p v-else class="text-muted">{{ __('Выполняйте задания, чтобы попасть в топ') }}</p>
</div>
@@ -66,16 +66,16 @@
<div class="lms-card-body">
<div class="card-header">
<span class="card-subtitle">{{ __('Лидер группы') }}</span>
<div class="icon-gold"><i class="fas fa-trophy"></i></div>
<div class="icon-gold"><Trophy class="icon-svg" /></div>
</div>
<template v-if="topUserInGroup">
<h2 class="card-title truncate-text" :title="topUserInGroup.full_name">
<a :href="`/lms/user/${topUserInGroup.username}`" class="card-link">
{{ topUserInGroup.full_name }}
</a>
</h2>
<div class="card-stats">
<div class="stat-block">
<span class="stat-value">1</span>
@@ -105,10 +105,9 @@
<div class="lms-table-header">
<h3>{{ activeGroupLabel }} <span class="count-badge">{{ currentLeaderboard.length }}</span></h3>
</div>
<div class="table-responsive">
<table v-if="currentLeaderboard.length > 0" class="lms-table">
<!-- FIX: Fixed table layout to prevent jumping when tabs change -->
<colgroup>
<col style="width: 70px;">
<col style="width: auto;">
@@ -126,19 +125,19 @@
</tr>
</thead>
<tbody>
<tr
v-for="(user, index) in currentLeaderboard"
<tr
v-for="(user, index) in currentLeaderboard"
:key="user.user"
:class="{'is-current-user': user.user === currentUser.name}"
>
<!-- Ранг -->
<td style="text-align: center; font-weight: 600; color: #4b5563;">
<td class="td-rank">
<span v-if="index === 0" title="1 Место" class="medal-emoji">🥇</span>
<span v-else-if="index === 1" title="2 Место" class="medal-emoji">🥈</span>
<span v-else-if="index === 2" title="3 Место" class="medal-emoji">🥉</span>
<span v-else>{{ index + 1 }}</span>
</td>
<!-- Пользователь -->
<td>
<div class="user-cell">
@@ -148,15 +147,11 @@
</a>
</div>
</td>
<!-- Очки -->
<td style="text-align: center; font-weight: 600; color: #111827;">
{{ user.points }}
</td>
<td style="text-align: center; font-weight: 600; color: #111827;">
{{ user.points }}
</td>
<td class="td-points">{{ user.points }}</td>
<td class="td-points">{{ user.points }}</td>
<!-- Бонусы МПГУ -->
<td v-if="activeGroup === 'LMS Schoolchild'" style="text-align: center;">
<span v-if="user.bonus > 0" class="bonus-badge">
@@ -167,9 +162,45 @@
</tr>
</tbody>
</table>
<!-- Fallback: нет данных в таблице, показываем всех пользователей с 0 очков -->
<div v-else-if="currentGroupUsers.length > 0" class="table-responsive">
<table class="lms-table">
<colgroup>
<col style="width: 70px;">
<col style="width: auto;">
<col style="width: 130px;">
</colgroup>
<thead>
<tr>
<th style="text-align: center;">#</th>
<th>{{ __('Пользователь') }}</th>
<th style="text-align: center;">{{ __('Всего') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(user, index) in currentGroupUsers"
:key="user.name"
:class="{'is-current-user': user.name === currentUser.name}"
>
<td class="td-rank">{{ index + 1 }}</td>
<td>
<div class="user-cell">
<div class="user-avatar">{{ user.full_name?.charAt(0).toUpperCase() || 'U' }}</div>
<a :href="`/lms/user/${user.username}`" class="user-link truncate-text">
{{ user.full_name }}
</a>
</div>
</td>
<td class="td-points">0</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="lms-empty-state">
<i class="fas fa-ghost empty-icon"></i>
<User class="empty-icon" />
<p>{{ __('В этой категории пока нет участников.') }}</p>
</div>
</div>
@@ -184,6 +215,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { usersStore } from '@/stores/user'
import { createResource } from 'frappe-ui'
import { Crown, Medal, Trophy, User } from 'lucide-vue-next'
const store = usersStore()
const { userResource, allUsers } = store
@@ -223,24 +255,17 @@ const currentUser = computed(() => {
return userResource.data || {}
})
const currentUserInitial = computed(() => {
if (currentUser.value.full_name) {
return currentUser.value.full_name.charAt(0).toUpperCase()
}
return currentUser.value.name?.charAt(0).toUpperCase() || 'U'
})
function getUserInitial(user) {
return user.full_name?.charAt(0).toUpperCase() || user.username?.charAt(0).toUpperCase() || 'U'
}
watch(
[() => logsResource.data, () => allUsers.data],
[() => logsResource.data, () => allUsers.data],
() => {
if (logsResource.data && allUsers.data) {
if (allUsers.data) {
calculateLeaderboard()
}
},
},
{ deep: true }
)
@@ -262,6 +287,14 @@ const currentLeaderboard = computed(() => {
.slice(0, 50)
})
// Fallback: все пользователи активной группы (для случая когда Energy Point Log пуст)
const currentGroupUsers = computed(() => {
if (currentLeaderboard.value.length > 0) return []
return usersList.value
.filter(u => u.roles && u.roles.some(r => r === activeGroup.value))
.slice(0, 50)
})
const topUserInGroup = computed(() => {
return currentLeaderboard.value[0]
})
@@ -296,14 +329,16 @@ const nextPosition = computed(() => {
})
function calculateLeaderboard() {
if (!logsResource.data || !usersList.value) return
if (!usersList.value) return
const pointsMap = {}
logsResource.data.forEach(log => {
if (!pointsMap[log.user]) pointsMap[log.user] = 0
pointsMap[log.user] += log.points
})
if (logsResource.data) {
logsResource.data.forEach(log => {
if (!pointsMap[log.user]) pointsMap[log.user] = 0
pointsMap[log.user] += log.points
})
}
leaderboard.value = Object.keys(pointsMap)
.map(user => {
@@ -327,7 +362,7 @@ function calculateLeaderboard() {
username: u.username,
roles: u.roles || [],
bonus: bonus,
isSchoolchild: isSchoolchild
isSchoolchild: isSchoolchild
}
})
.filter(user => user !== null)
@@ -336,14 +371,15 @@ function calculateLeaderboard() {
onMounted(async () => {
await allUsers.reload()
await logsResource.reload()
// Загружаем Energy Point Log параллельно, ошибка не блокирует отображение
logsResource.reload().catch(() => {})
calculateLeaderboard()
})
</script>
<style scoped>
/* ============================================
ДИЗАЙН В СТИЛЕ FRAPPE LMS
LEADERBOARD — Frappe UI tokens only
============================================ */
.lms-page-container {
@@ -351,7 +387,7 @@ onMounted(async () => {
max-width: 1400px;
margin: 0 auto;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #111827;
color: rgb(var(--color-ink-gray-9));
}
/* === HEADER & TABS === */
@@ -368,22 +404,22 @@ onMounted(async () => {
font-size: 28px;
font-weight: 700;
margin: 0;
color: #111827;
color: rgb(var(--color-ink-gray-9));
letter-spacing: -0.02em;
}
.lms-tabs-container {
display: inline-flex;
background-color: #f3f4f6;
background-color: rgb(var(--color-surface-gray-2));
padding: 4px;
border-radius: 8px;
border: 1px solid #e5e7eb;
border: 1px solid rgb(var(--color-outline-gray-2));
}
.lms-tab-btn {
background: transparent;
border: none;
color: #6b7280;
color: rgb(var(--color-ink-gray-5));
font-size: 14px;
font-weight: 500;
padding: 8px 20px;
@@ -393,12 +429,12 @@ onMounted(async () => {
}
.lms-tab-btn:hover {
color: #374151;
color: rgb(var(--color-ink-gray-7));
}
.lms-tab-btn.active {
background: #ffffff;
color: #111827;
background: rgb(var(--color-surface-white));
color: rgb(var(--color-ink-gray-9));
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
@@ -419,8 +455,8 @@ onMounted(async () => {
/* === CARDS === */
.lms-card {
background: #ffffff;
border: 1px solid #e5e7eb;
background: rgb(var(--color-surface-white));
border: 1px solid rgb(var(--color-outline-gray-2));
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: hidden;
@@ -440,36 +476,41 @@ onMounted(async () => {
.card-subtitle {
font-size: 13px;
font-weight: 600;
color: #6b7280;
color: rgb(var(--color-ink-gray-5));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.icon-gold { color: #f59e0b; font-size: 1.4rem; }
.icon-silver { color: #9ca3af; font-size: 1.4rem; }
.icon-bronze { color: #b45309; font-size: 1.4rem; }
.icon-gray { color: #d1d5db; font-size: 1.4rem; }
.icon-svg {
width: 22px;
height: 22px;
}
.icon-gold { color: rgb(var(--color-ink-amber-3)); font-size: 1.4rem; }
.icon-silver { color: rgb(var(--color-ink-gray-4)); font-size: 1.4rem; }
.icon-bronze { color: rgb(var(--color-ink-amber-4)); font-size: 1.4rem; }
.icon-gray { color: rgb(var(--color-ink-gray-3)); font-size: 1.4rem; }
.card-title {
font-size: 20px;
font-weight: 700;
margin: 0 0 24px 0;
color: #111827;
color: rgb(var(--color-ink-gray-9));
line-height: 1.3;
}
.card-link {
text-decoration: none;
color: #111827;
color: rgb(var(--color-ink-gray-9));
transition: color 0.2s;
}
.card-link:hover { color: #00a9a6; }
.card-link:hover { color: rgb(var(--color-ink-blue-3)); }
.card-stats {
display: flex;
gap: 32px;
margin-bottom: 20px;
border-bottom: 1px solid #f3f4f6;
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
padding-bottom: 20px;
}
@@ -482,20 +523,21 @@ onMounted(async () => {
font-size: 28px;
font-weight: 800;
line-height: 1;
color: #111827;
color: rgb(var(--color-ink-gray-9));
}
.stat-label {
font-size: 12px;
color: #6b7280;
color: rgb(var(--color-ink-gray-5));
margin-top: 8px;
font-weight: 500;
}
.text-primary { color: #00a9a6; }
.text-gold { color: #f59e0b; }
.text-muted { color: #6b7280; font-size: 13px; margin: 0; line-height: 1.5; }
.text-success { color: #059669; font-size: 13px; margin: 0; font-weight: 600; display: flex; align-items: center; gap: 6px; }
.text-primary { color: rgb(var(--color-ink-blue-3)); }
.text-gold { color: rgb(var(--color-ink-amber-3)); }
.text-muted { color: rgb(var(--color-ink-gray-5)); font-size: 13px; margin: 0; line-height: 1.5; }
.text-success { color: rgb(var(--color-ink-green-3)); font-size: 13px; margin: 0; font-weight: 600; display: flex; align-items: center; gap: 6px; }
.text-strong { color: rgb(var(--color-ink-gray-9)); }
.card-footer {
min-height: 20px;
@@ -503,42 +545,40 @@ onMounted(async () => {
/* === TABLE === */
.table-card {
min-height: 500px;
min-height: 500px;
}
.lms-table-header {
padding: 20px 24px;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
background: rgb(var(--color-surface-white));
border-bottom: 1px solid rgb(var(--color-outline-gray-2));
}
.lms-table-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #111827;
color: rgb(var(--color-ink-gray-9));
display: flex;
align-items: center;
gap: 12px;
}
.count-badge {
background: #f3f4f6;
color: #4b5563;
background: rgb(var(--color-surface-gray-2));
color: rgb(var(--color-ink-gray-6));
padding: 2px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
/* --- FIX: Настройки скролла для длинной таблицы --- */
.table-responsive {
overflow-x: auto;
overflow-y: auto;
max-height: 650px; /* Ограничиваем высоту, чтобы таблица не уезжала вниз */
max-height: 650px;
}
/* Кастомный красивый скроллбар */
.table-responsive::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -547,11 +587,11 @@ onMounted(async () => {
background: transparent;
}
.table-responsive::-webkit-scrollbar-thumb {
background-color: #d1d5db;
background-color: rgb(var(--color-outline-gray-3));
border-radius: 10px;
}
.table-responsive::-webkit-scrollbar-thumb:hover {
background-color: #9ca3af;
background-color: rgb(var(--color-outline-gray-4));
}
.lms-table {
@@ -559,19 +599,17 @@ onMounted(async () => {
border-collapse: collapse;
text-align: left;
font-size: 14px;
table-layout: fixed;
table-layout: fixed;
}
.lms-table th {
background: #f9fafb;
color: #4b5563;
background: rgb(var(--color-surface-gray-1));
color: rgb(var(--color-ink-gray-6));
font-weight: 500;
font-size: 13px;
padding: 14px 24px;
/* Заменяем border-bottom на box-shadow для sticky, чтобы не было просветов */
box-shadow: inset 0 -1px 0 #e5e7eb;
box-shadow: inset 0 -1px 0 rgb(var(--color-outline-gray-2));
white-space: nowrap;
/* Делаем шапку прилипающей */
position: sticky;
top: 0;
z-index: 10;
@@ -579,24 +617,36 @@ onMounted(async () => {
.lms-table td {
padding: 16px 24px;
border-bottom: 1px solid #e5e7eb;
color: #374151;
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
color: rgb(var(--color-ink-gray-7));
vertical-align: middle;
}
.td-rank {
text-align: center;
font-weight: 600;
color: rgb(var(--color-ink-gray-6));
}
.td-points {
text-align: center;
font-weight: 600;
color: rgb(var(--color-ink-gray-9));
}
.lms-table tr:last-child td {
border-bottom: none;
}
.lms-table tr:hover {
background-color: #f9fafb;
background-color: rgb(var(--color-surface-gray-1));
}
.lms-table tr.is-current-user {
background-color: #f0fdfa;
background-color: rgb(var(--color-surface-blue-1));
}
.lms-table tr.is-current-user td {
border-bottom-color: #ccfbf1;
border-bottom-color: rgb(var(--color-outline-blue-1));
}
.medal-emoji {
@@ -612,8 +662,8 @@ onMounted(async () => {
.user-avatar {
width: 36px;
height: 36px;
background: #e5e7eb;
color: #4b5563;
background: rgb(var(--color-surface-gray-3));
color: rgb(var(--color-ink-gray-6));
border-radius: 50%;
display: flex;
align-items: center;
@@ -623,17 +673,17 @@ onMounted(async () => {
flex-shrink: 0;
}
.is-current-user .user-avatar {
background: #00a9a6;
color: #ffffff;
background: rgb(var(--color-surface-blue-3));
color: rgb(var(--color-ink-on-blue));
}
.user-link {
color: #111827;
color: rgb(var(--color-ink-gray-9));
text-decoration: none;
font-weight: 600;
}
.user-link:hover {
color: #00a9a6;
color: rgb(var(--color-ink-blue-3));
text-decoration: underline;
}
@@ -647,8 +697,8 @@ onMounted(async () => {
.bonus-badge {
display: inline-flex;
align-items: center;
background: #ecfdf5;
color: #059669;
background: rgb(var(--color-surface-green-1));
color: rgb(var(--color-ink-green-3));
padding: 4px 10px;
border-radius: 6px;
font-weight: 600;
@@ -658,7 +708,7 @@ onMounted(async () => {
.lms-empty-state {
padding: 60px 20px;
text-align: center;
color: #6b7280;
color: rgb(var(--color-ink-gray-5));
font-size: 15px;
display: flex;
flex-direction: column;
@@ -667,8 +717,9 @@ onMounted(async () => {
}
.empty-icon {
font-size: 32px;
color: #d1d5db;
width: 32px;
height: 32px;
color: rgb(var(--color-ink-gray-3));
}
/* === RESPONSIVE === */
@@ -676,11 +727,11 @@ onMounted(async () => {
.lms-layout-grid {
grid-template-columns: 1fr;
}
.lms-sidebar {
flex-direction: row;
}
.lms-card {
flex: 1;
}
@@ -690,7 +741,7 @@ onMounted(async () => {
.lms-page-container {
padding: 16px;
}
.lms-sidebar {
flex-direction: column;
}
@@ -699,20 +750,19 @@ onMounted(async () => {
width: 100%;
display: flex;
}
.lms-tab-btn {
flex: 1;
text-align: center;
padding: 8px 12px;
}
.lms-table th, .lms-table td {
padding: 12px 16px;
}
/* Немного уменьшим высоту на мобилках */
.table-responsive {
max-height: 500px;
}
}
</style>
</style>

View File

@@ -0,0 +1,345 @@
<template>
<div>
<header class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5">
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="olympiad-form-page">
<div class="form-card">
<div class="form-card-header">
<h2 class="form-title">{{ isEdit ? __('Edit Olympiad') : __('New Olympiad') }}</h2>
<p class="form-subtitle">{{ __('Fill in the olympiad details below') }}</p>
</div>
<div class="form-body">
<!-- Title -->
<div class="field-group field-full">
<FormControl
v-model="form.title"
:label="__('Title')"
type="text"
:placeholder="__('Olympiad title')"
required
/>
</div>
<!-- Subject -->
<div class="field-group field-full">
<FormControl
v-model="form.subject"
:label="__('Subject')"
type="text"
:placeholder="__('e.g. Mathematics, Physics')"
/>
</div>
<!-- Description -->
<div class="field-group field-full">
<label class="field-label">{{ __('Description') }}</label>
<textarea
v-model="form.description"
class="form-textarea"
rows="4"
:placeholder="__('Describe the olympiad...')"
></textarea>
</div>
<!-- Registration dates -->
<div class="field-group">
<label class="field-label">{{ __('Registration Start') }}</label>
<DatePicker v-model="form.registration_start" class="w-full" />
</div>
<div class="field-group">
<label class="field-label">{{ __('Registration End') }}</label>
<DatePicker v-model="form.registration_end" class="w-full" />
</div>
<!-- Olympiad dates -->
<div class="field-group">
<label class="field-label">{{ __('Olympiad Start') }}</label>
<DatePicker v-model="form.olympiad_start" class="w-full" />
</div>
<div class="field-group">
<label class="field-label">{{ __('Olympiad End') }}</label>
<DatePicker v-model="form.olympiad_end" class="w-full" />
</div>
<!-- Max participants -->
<div class="field-group">
<FormControl
v-model="form.max_participants"
:label="__('Max Participants')"
type="number"
:placeholder="__('Leave empty for unlimited')"
min="1"
/>
</div>
<!-- Passing score -->
<div class="field-group">
<FormControl
v-model="form.passing_score"
:label="__('Passing Score')"
type="number"
placeholder="0"
min="0"
/>
</div>
<!-- Status -->
<div class="field-group field-full">
<label class="field-label">{{ __('Status') }}</label>
<Select
v-model="form.status"
:options="statusOptions"
class="w-full"
/>
</div>
<!-- Actions -->
<div class="form-actions">
<Button
variant="solid"
@click="saveOlympiad"
:loading="saving"
:disabled="!form.title"
>
{{ saving ? __('Saving...') : (isEdit ? __('Save Changes') : __('Create Olympiad')) }}
</Button>
<Button variant="subtle" @click="router.back()">
{{ __('Cancel') }}
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Breadcrumbs, FormControl, Button, DatePicker, Select, createResource, toast } from 'frappe-ui'
const router = useRouter()
const route = useRoute()
const props = defineProps<{
olympiadName?: string
}>()
const isEdit = computed(() => !!props.olympiadName)
const saving = ref(false)
const form = ref({
title: '',
subject: '',
description: '',
registration_start: '',
registration_end: '',
olympiad_start: '',
olympiad_end: '',
max_participants: '',
passing_score: '',
status: 'Draft',
})
const statusOptions = computed(() => [
{ label: __('Draft'), value: 'Draft' },
{ label: __('Registration Open'), value: 'Registration Open' },
{ label: __('In Progress'), value: 'In Progress' },
{ label: __('Completed'), value: 'Completed' },
])
const breadcrumbs = computed(() => [
{ label: __('Olympiads'), route: { name: 'Olympiads' } },
{
label: isEdit.value ? __('Edit Olympiad') : __('New Olympiad'),
route: isEdit.value
? { name: 'OlympiadEdit', params: { olympiadName: props.olympiadName } }
: { name: 'OlympiadNew' },
},
])
const existingDoc = createResource({
url: 'frappe.client.get',
auto: false,
onSuccess(data: any) {
form.value.title = data.title || ''
form.value.subject = data.subject || ''
form.value.description = data.description || ''
form.value.registration_start = data.registration_start || ''
form.value.registration_end = data.registration_end || ''
form.value.olympiad_start = data.olympiad_start || ''
form.value.olympiad_end = data.olympiad_end || ''
form.value.max_participants = data.max_participants || ''
form.value.passing_score = data.passing_score || ''
form.value.status = data.status || 'Draft'
},
onError(error: any) {
toast.error(__('Failed to load olympiad: ') + (error.message || ''))
},
})
async function saveOlympiad() {
if (!form.value.title.trim()) {
toast.error(__('Title is required'))
return
}
saving.value = true
try {
const doc: Record<string, any> = {
doctype: 'Olympiad',
title: form.value.title,
subject: form.value.subject || '',
description: form.value.description || '',
status: form.value.status,
}
if (form.value.registration_start) doc.registration_start = form.value.registration_start
if (form.value.registration_end) doc.registration_end = form.value.registration_end
if (form.value.olympiad_start) doc.olympiad_start = form.value.olympiad_start
if (form.value.olympiad_end) doc.olympiad_end = form.value.olympiad_end
if (form.value.max_participants) doc.max_participants = parseInt(String(form.value.max_participants))
if (form.value.passing_score) doc.passing_score = parseInt(String(form.value.passing_score))
if (isEdit.value) {
await createResource({
url: 'frappe.client.save',
params: { doc: { ...doc, name: props.olympiadName } },
}).submit()
toast.success(__('Olympiad updated'))
} else {
const result: any = await createResource({
url: 'frappe.client.insert',
params: { doc },
}).submit()
toast.success(__('Olympiad created'))
const newName = result?.name
if (newName) {
router.push({ name: 'OlympiadDetail', params: { olympiadName: newName } })
return
}
}
router.push({ name: 'Olympiads' })
} catch (e: any) {
toast.error(__('Error saving olympiad: ') + (e?.message || ''))
} finally {
saving.value = false
}
}
onMounted(() => {
if (isEdit.value && props.olympiadName) {
existingDoc.update({
params: {
doctype: 'Olympiad',
name: props.olympiadName,
},
})
existingDoc.reload()
}
})
</script>
<style scoped>
.olympiad-form-page {
max-width: 720px;
margin: 0 auto;
padding: 24px 16px 48px;
}
.form-card {
background: rgb(var(--color-surface-white));
border: 1px solid rgb(var(--color-outline-gray-2));
border-radius: 10px;
overflow: hidden;
}
.form-card-header {
padding: 20px 24px 16px;
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
background: rgb(var(--color-surface-gray-1));
}
.form-title {
font-size: 18px;
font-weight: 700;
color: rgb(var(--color-ink-gray-9));
margin: 0 0 4px;
}
.form-subtitle {
font-size: 13px;
color: rgb(var(--color-ink-gray-5));
margin: 0;
}
.form-body {
padding: 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-full {
grid-column: 1 / -1;
}
.field-label {
font-size: 13px;
font-weight: 500;
color: rgb(var(--color-ink-gray-7));
}
.form-textarea {
width: 100%;
border: 1px solid rgb(var(--color-outline-gray-3));
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
color: rgb(var(--color-ink-gray-9));
background: rgb(var(--color-surface-white));
resize: vertical;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
font-family: inherit;
}
.form-textarea:focus {
border-color: rgb(var(--color-ink-blue-3));
box-shadow: 0 0 0 3px rgba(var(--color-surface-blue-2), 0.4);
}
.form-actions {
grid-column: 1 / -1;
display: flex;
gap: 12px;
padding-top: 8px;
border-top: 1px solid rgb(var(--color-outline-gray-1));
}
@media (max-width: 640px) {
.form-body {
grid-template-columns: 1fr;
}
.field-full {
grid-column: 1;
}
.form-actions {
grid-column: 1;
flex-direction: column;
}
}
</style>

View File

@@ -21,8 +21,18 @@
/>
</div>
</div>
<div class="text-sm text-ink-gray-5">
{{ filteredOlympiads.length }} {{ __('olympiads') }}
<div class="flex items-center gap-3">
<div class="text-sm text-ink-gray-5">
{{ filteredOlympiads.length }} {{ __('olympiads') }}
</div>
<button
v-if="canCreateOlympiad"
@click="router.push({ name: 'OlympiadNew' })"
class="create-olympiad-btn"
>
<Plus class="w-4 h-4" />
{{ __('Create Olympiad') }}
</button>
</div>
</div>
@@ -56,12 +66,20 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Breadcrumbs, TabButtons, createResource, usePageMeta } from 'frappe-ui'
import { Trophy, Search } from 'lucide-vue-next'
import { Trophy, Search, Plus } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user'
import EmptyState from '@/components/EmptyState.vue'
import OlympiadCard from '@/pages/Olympiads/OlympiadCard.vue'
const { brand } = sessionStore()
const { userResource } = usersStore()
const router = useRouter()
const canCreateOlympiad = computed(() => {
return userResource?.data?.is_moderator || userResource?.data?.is_instructor
})
const searchQuery = ref('')
const currentStatus = ref('all')
@@ -114,3 +132,24 @@ usePageMeta(() => ({
icon: brand.favicon,
}))
</script>
<style scoped>
.create-olympiad-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgb(var(--color-surface-blue-3));
color: rgb(var(--color-ink-on-blue));
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
white-space: nowrap;
}
.create-olympiad-btn:hover {
opacity: 0.88;
}
</style>

View File

@@ -47,7 +47,7 @@
</div>
</div>
<!-- VIEW MODE -->
<!-- VIEW MODE -->
<div v-if="!editMode" class="section-grid">
<!-- Empty profile -->
<div v-if="schoolProfileNotFound" class="empty-card">
@@ -406,25 +406,25 @@
}
}
.hero-avatar {
width: 64px;
height: 64px;
width: 96px;
height: 96px;
border-radius: 50%;
background: linear-gradient(135deg, #f97316, #f59e0b);
color: #fff;
font-size: 26px;
background: linear-gradient(135deg, rgb(var(--color-surface-amber-2)), rgb(var(--color-surface-amber-3)));
color: rgb(var(--color-ink-on-amber));
font-size: 38px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.25);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.hero-info {
flex: 1;
min-width: 0;
}
.hero-name {
font-size: 20px;
font-size: 22px;
font-weight: 700;
color: rgb(var(--color-ink-gray-9));
line-height: 1.3;
@@ -432,7 +432,7 @@
.hero-subtitle {
font-size: 13px;
color: rgb(var(--color-ink-gray-5));
margin-top: 2px;
margin-top: 4px;
}
.hero-bio {
margin-top: 4px;
@@ -481,7 +481,7 @@
.card-header-icon {
width: 20px;
height: 20px;
color: #f97316;
color: rgb(var(--color-ink-amber-3));
flex-shrink: 0;
}
.card-header-title {
@@ -581,8 +581,8 @@
width: 72px;
height: 72px;
border-radius: 50%;
background: linear-gradient(135deg, #fff7ed, #ffedd5);
color: #f97316;
background: rgb(var(--color-surface-amber-1));
color: rgb(var(--color-ink-amber-3));
display: flex;
align-items: center;
justify-content: center;
@@ -620,7 +620,7 @@
width: 6px;
height: 6px;
border-radius: 50%;
background: #f97316;
background: rgb(var(--color-ink-amber-3));
flex-shrink: 0;
}
@@ -654,8 +654,8 @@
transition: border-color 0.15s, box-shadow 0.15s;
}
.edit-input:focus {
border-color: #f97316;
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
border-color: rgb(var(--color-ink-amber-3));
box-shadow: 0 0 0 3px rgba(var(--color-surface-amber-2), 0.4);
}
.dropdown-list {
margin-top: 4px;
@@ -698,13 +698,6 @@ const { user } = sessionStore();
const $user = inject('$user');
const schoolProfileNotFound = ref(false);
// Логирование инициализации
console.log('[DEBUG] Инициализация компонента:', {
user: user,
$user: $user.data,
username: $user.data?.username,
});
const props = defineProps({
username: {
type: String,
@@ -714,9 +707,7 @@ const props = defineProps({
});
const effectiveUsername = computed(() => {
const username = props.username || $user.data?.username || '';
console.log('[DEBUG] Вычисление effectiveUsername:', { propsUsername: props.username, sessionUsername: $user.data?.username, result: username });
return username;
return props.username || $user.data?.username || '';
});
const editMode = ref(false);
@@ -726,17 +717,12 @@ const profile = createResource({
url: 'frappe.client.get',
makeParams(values) {
const username = effectiveUsername.value;
console.log('[DEBUG] Запрос profile:', { doctype: 'User', filters: { username } });
return {
doctype: 'User',
filters: { username },
};
},
onSuccess(data) {
console.log('[DEBUG] Профиль загружен:', data);
},
onError(error) {
console.error('[DEBUG] Ошибка загрузки профиля:', error);
window.frappe?.msgprint({
title: 'Ошибка',
message: 'Не удалось загрузить профиль пользователя: ' + (error.message || 'Неизвестная ошибка'),
@@ -752,23 +738,17 @@ const schoolProfile = createResource({
filters: { user:user },
},
auto: false,
onSuccess(data) {
console.log('[DEBUG] Профиль школьника загружен:', data);
onError(error) {
if (error.exc_type === 'DoesNotExistError' || error.message?.includes('DoesNotExist')) {
schoolProfileNotFound.value = true;
} else {
window.frappe?.msgprint({
title: 'Ошибка',
message: 'Не удалось загрузить профиль школьника: ' + (error.message || 'Неизвестная ошибка'),
indicator: 'red',
});
}
},
onError(error) {
// Проверяем, является ли ошибка "не найдено"
if (error.exc_type === 'DoesNotExistError' || error.message?.includes('DoesNotExist')) {
console.log('[DEBUG] Профиль школьника не найден, создаем новый');
schoolProfileNotFound.value = true;
} else {
console.error('[DEBUG] Ошибка загрузки профиля школьника:', error);
window.frappe?.msgprint({
title: 'Ошибка',
message: 'Не удалось загрузить профиль школьника: ' + (error.message || 'Неизвестная ошибка'),
indicator: 'red',
});
}
},
});
const form = ref({
@@ -792,7 +772,7 @@ const form = ref({
const breadcrumbs = computed(() => {
const username = effectiveUsername.value;
const crumbs = [
return [
{
label: 'People',
route: { name: 'People' },
@@ -805,35 +785,20 @@ const breadcrumbs = computed(() => {
} : undefined,
},
];
console.log('[DEBUG] Хлебные крошки:', crumbs);
return crumbs;
});
const pageMeta = computed(() => {
const meta = {
title: profile.data?.full_name || 'Профиль',
description: profile.data?.headline || '',
};
console.log('[DEBUG] Мета-данные страницы:', meta);
return meta;
});
const pageMeta = computed(() => ({
title: profile.data?.full_name || 'Профиль',
description: profile.data?.headline || '',
}));
const displayName = computed(() => {
if (!profile.data) {
console.log('[DEBUG] displayName: profile.data не загружен');
return 'Загрузка...';
}
const name = profile.data?.full_name || `${form.value.first_name || ''} ${form.value.last_name || ''}`.trim();
console.log('[DEBUG] Отображаемое имя:', name);
return name;
if (!profile.data) return 'Загрузка...';
return profile.data?.full_name || `${form.value.first_name || ''} ${form.value.last_name || ''}`.trim();
});
const isSessionUser = () => {
const sessionUser = $user.data?.username;
const profileUser = effectiveUsername.value;
const isSession = sessionUser === profileUser;
console.log('[DEBUG] Проверка isSessionUser:', { sessionUser, profileUser, isSession });
return isSession;
return $user.data?.username === effectiveUsername.value;
};
function formattedDate(d) {
@@ -841,7 +806,6 @@ function formattedDate(d) {
try {
return new Date(d).toLocaleDateString('ru-RU');
} catch (e) {
console.error('[DEBUG] Ошибка форматирования даты:', e, { date: d });
return d;
}
}
@@ -862,11 +826,6 @@ function formatTelegram(t) {
}
function fillFormFromProfile() {
console.log('[DEBUG] Заполнение формы:', {
schoolProfile: schoolProfile.data,
profile: profile.data,
currentForm: JSON.stringify(form.value, null, 2),
});
form.value.first_name = schoolProfile.data?.first_name || profile.data?.first_name || '';
form.value.last_name = schoolProfile.data?.last_name || profile.data?.last_name || '';
form.value.middle_name = schoolProfile.data?.middle_name || '';
@@ -883,38 +842,20 @@ function fillFormFromProfile() {
form.value.interests = schoolProfile.data?.interests || '';
form.value.about_me = schoolProfile.data?.about_me || '';
form.value.dreams = schoolProfile.data?.dreams || '';
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
}
function toggleEdit() {
editMode.value = !editMode.value;
if (editMode.value) fillFormFromProfile();
console.log('[DEBUG] Переключение режима редактирования:', { editMode: editMode.value });
}
function validateExams(exams) {
console.log('[DEBUG] Валидация exams:', { exams, validOptions: examOptions });
return exams.every(exam => examOptions.includes(exam));
}
function validateLearnSubjects(subjects) {
console.log('[DEBUG] Валидация learn_subjects:', { subjects, validOptions: learnOptions });
return subjects.every(subject => learnOptions.includes(subject));
}
async function saveProfile() {
console.log('[DEBUG] Сохранение профиля:', { form: form.value });
saving.value = true;
try {
// Создаём копию данных формы
const formData = { ...form.value };
console.log('[DEBUG] Копия formData:', JSON.stringify(formData, null, 2));
// Обновление full_name в User, если нужно
if (formData.first_name || formData.last_name) {
const fullName = `${formData.first_name || ''} ${formData.last_name || ''}`.trim();
console.log('[DEBUG] Обновление User.full_name:', { name: profile.data?.name, fullName });
await createResource({
url: 'frappe.client.set_value',
params: {
@@ -926,18 +867,14 @@ async function saveProfile() {
}).submit();
}
// Получаем docname
let docname = '';
try {
await schoolProfile.reload();
console.log('[DEBUG] Schoolprofile:', { schoolProfile });
docname = schoolProfile?.data?.name;
console.log('[DEBUG] Выбранное имя документа:', docname);
} catch (error) {
console.log('[DEBUG] Ошибка загрузки schoolProfile, продолжаем с profile:', error.message);
// профиль не найден, создаём новый
}
// Формируем payload из копии данных формы
let payload = {
doctype: 'Schoolchildren Profile',
user: profile.data?.name,
@@ -959,9 +896,7 @@ async function saveProfile() {
dreams: formData.dreams,
last_updated: new Date().toISOString(),
};
console.log('[DEBUG] Сохранение Schoolchildren Profile (payload):', { docname, payload });
// Сохранение или создание документа
if (docname) {
await createResource({
url: 'frappe.client.save',
@@ -975,11 +910,9 @@ async function saveProfile() {
}
editMode.value = false;
schoolProfileNotFound.value = false;
schoolProfileNotFound.value = false;
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint('Профиль сохранён');
console.log('[DEBUG] Профиль успешно сохранён');
} catch (e) {
console.error('[DEBUG] Ошибка при сохранении профиля:', e);
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint({
title: 'Ошибка',
message: (e && e.message) || 'Ошибка при сохранении',
@@ -1000,7 +933,6 @@ async function searchSchool(q) {
return;
}
try {
console.log('[DEBUG] Поиск школы:', { query: q });
const res = await createResource({
url: 'frappe.client.get_list',
params: {
@@ -1011,10 +943,8 @@ async function searchSchool(q) {
},
}).submit();
schoolResults.value = res || [];
console.log('[DEBUG] Результаты поиска школы:', schoolResults.value);
} catch (e) {
schoolResults.value = [];
console.error('[DEBUG] Ошибка поиска школы:', e);
}
}
@@ -1022,11 +952,8 @@ const debouncedSearchSchool = debounce(() => searchSchool(schoolQuery.value), 30
function selectSchool(s) {
form.value.school = s.school;
//form.value.school_name = s.school_name;
schoolResults.value = [];
schoolQuery.value = s.school;
console.log('[DEBUG] Выбрана школа:', { school: s });
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
}
const majorQuery = ref('');
@@ -1038,7 +965,6 @@ async function searchMajor(q) {
return;
}
try {
console.log('[DEBUG] Поиск направления:', { query: q });
const res = await createResource({
url: 'frappe.client.get_list',
params: {
@@ -1049,10 +975,8 @@ async function searchMajor(q) {
},
}).submit();
majorResults.value = res || [];
console.log('[DEBUG] Результаты поиска направления:', majorResults.value);
} catch (e) {
majorResults.value = [];
console.error('[DEBUG] Ошибка поиска направления:', e);
}
}
@@ -1060,40 +984,27 @@ const debouncedSearchMajor = debounce(() => searchMajor(majorQuery.value), 300);
function selectMajor(m) {
form.value.major = m.major_name;
//form.value.school_name = s.school_name;
majorResults.value = [];
majorQuery.value = m.major_name;
console.log('[DEBUG] Выбрана школа:', { major: m });
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
}
onMounted(() => {
console.log('[DEBUG] Компонент смонтирован:', {
propsUsername: props.username,
sessionUsername: $user.data?.username,
user: user,
$user: $user.data,
});
if ($user.data) {
console.log('[DEBUG] Запуск profile.reload()');
profile.reload();
}
});
watch(
() => props.username,
(newUsername, oldUsername) => {
console.log('[DEBUG] Изменение props.username:', { old: oldUsername, new: newUsername });
() => {
profile.reload();
}
);
watch(
() => profile.data,
(newData, oldData) => {
console.log('[DEBUG] Изменение profile.data:', { old: oldData, new: newData });
(newData) => {
if (newData) {
console.log('[DEBUG] Запуск schoolProfile.reload()');
schoolProfile.reload();
}
}
@@ -1101,10 +1012,8 @@ watch(
watch(
() => schoolProfile.data,
(newData, oldData) => {
console.log('[DEBUG] Изменение schoolProfile.data:', { old: oldData, new: newData });
(newData) => {
if (newData && !editMode.value && !schoolProfileNotFound.value) {
console.log('[DEBUG] Заполнение формы из schoolProfile');
fillFormFromProfile();
}
}
@@ -1113,7 +1022,6 @@ watch(
watch(
() => effectiveUsername.value,
(newUsername) => {
console.log('[DEBUG] Изменение effectiveUsername для schoolProfile:', newUsername);
schoolProfile.update({
params: {
doctype: 'Schoolchildren Profile',

View File

@@ -271,6 +271,17 @@ const routes = [
name: 'Olympiads',
component: () => import('@/pages/Olympiads/Olympiads.vue'),
},
{
path: '/olympiads/new',
name: 'OlympiadNew',
component: () => import('@/pages/Olympiads/OlympiadForm.vue'),
},
{
path: '/olympiads/:olympiadName/edit',
name: 'OlympiadEdit',
component: () => import('@/pages/Olympiads/OlympiadForm.vue'),
props: true,
},
{
path: '/olympiads/:olympiadName',
name: 'OlympiadDetail',

View File

@@ -541,6 +541,15 @@ const getSidebarItems = () => {
label: 'Assessments',
hideLabel: true,
items: [
{
label: 'Create Olympiad',
icon: 'PlusCircle',
to: 'OlympiadNew',
condition: () => {
return isAdmin()
},
activeFor: ['OlympiadNew', 'OlympiadEdit'],
},
{
label: 'Quizzes',
icon: 'CircleHelp',