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
+165 -115
View File
@@ -1,10 +1,10 @@
<template> <template>
<div class="lms-page-container"> <div class="lms-page-container">
<!-- HEADER & TABS --> <!-- HEADER & TABS -->
<div class="lms-page-header"> <div class="lms-page-header">
<h1 class="page-title">{{ __('Таблица лидеров') }}</h1> <h1 class="page-title">{{ __('Таблица лидеров') }}</h1>
<!-- Сегментированные вкладки (Apple / Frappe style) --> <!-- Сегментированные вкладки (Apple / Frappe style) -->
<div class="lms-tabs-container"> <div class="lms-tabs-container">
<button <button
@@ -21,23 +21,23 @@
<!-- MAIN GRID LAYOUT --> <!-- MAIN GRID LAYOUT -->
<div class="lms-layout-grid"> <div class="lms-layout-grid">
<!-- LEFT COLUMN: SUMMARY CARDS --> <!-- LEFT COLUMN: SUMMARY CARDS -->
<div class="lms-sidebar"> <div class="lms-sidebar">
<!-- Карточка: Ваш результат --> <!-- Карточка: Ваш результат -->
<div class="lms-card my-result-card"> <div class="lms-card my-result-card">
<div class="lms-card-body"> <div class="lms-card-body">
<div class="card-header"> <div class="card-header">
<span class="card-subtitle">{{ __('Ваш результат') }}</span> <span class="card-subtitle">{{ __('Ваш результат') }}</span>
<div v-if="currentUserPosition === 1" class="icon-gold"><i class="fas fa-crown"></i></div> <div v-if="currentUserPosition === 1" class="icon-gold"><Crown class="icon-svg" /></div>
<div v-else-if="currentUserPosition === 2" class="icon-silver"><i class="fas fa-medal"></i></div> <div v-else-if="currentUserPosition === 2" class="icon-silver"><Medal class="icon-svg" /></div>
<div v-else-if="currentUserPosition === 3" class="icon-bronze"><i class="fas fa-medal"></i></div> <div v-else-if="currentUserPosition === 3" class="icon-bronze"><Medal class="icon-svg" /></div>
<div v-else class="icon-gray"><i class="fas fa-user"></i></div> <div v-else class="icon-gray"><User class="icon-svg" /></div>
</div> </div>
<h2 class="card-title truncate-text" :title="currentUser.full_name">{{ currentUser.full_name || __('Вы') }}</h2> <h2 class="card-title truncate-text" :title="currentUser.full_name">{{ currentUser.full_name || __('Вы') }}</h2>
<div class="card-stats"> <div class="card-stats">
<div class="stat-block"> <div class="stat-block">
<span class="stat-value">{{ currentUserPosition !== '-' ? currentUserPosition : '-' }}</span> <span class="stat-value">{{ currentUserPosition !== '-' ? currentUserPosition : '-' }}</span>
@@ -51,10 +51,10 @@
<div class="card-footer"> <div class="card-footer">
<p v-if="pointsToNext > 0 && currentUserPosition !== '-'" class="text-muted"> <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>
<p v-else-if="currentUserPosition === 1" class="text-success"> <p v-else-if="currentUserPosition === 1" class="text-success">
<i class="fas fa-check-circle"></i> {{ __('Вы на первом месте!') }} {{ __('Вы на первом месте!') }}
</p> </p>
<p v-else class="text-muted">{{ __('Выполняйте задания, чтобы попасть в топ') }}</p> <p v-else class="text-muted">{{ __('Выполняйте задания, чтобы попасть в топ') }}</p>
</div> </div>
@@ -66,16 +66,16 @@
<div class="lms-card-body"> <div class="lms-card-body">
<div class="card-header"> <div class="card-header">
<span class="card-subtitle">{{ __('Лидер группы') }}</span> <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> </div>
<template v-if="topUserInGroup"> <template v-if="topUserInGroup">
<h2 class="card-title truncate-text" :title="topUserInGroup.full_name"> <h2 class="card-title truncate-text" :title="topUserInGroup.full_name">
<a :href="`/lms/user/${topUserInGroup.username}`" class="card-link"> <a :href="`/lms/user/${topUserInGroup.username}`" class="card-link">
{{ topUserInGroup.full_name }} {{ topUserInGroup.full_name }}
</a> </a>
</h2> </h2>
<div class="card-stats"> <div class="card-stats">
<div class="stat-block"> <div class="stat-block">
<span class="stat-value">1</span> <span class="stat-value">1</span>
@@ -105,10 +105,9 @@
<div class="lms-table-header"> <div class="lms-table-header">
<h3>{{ activeGroupLabel }} <span class="count-badge">{{ currentLeaderboard.length }}</span></h3> <h3>{{ activeGroupLabel }} <span class="count-badge">{{ currentLeaderboard.length }}</span></h3>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table v-if="currentLeaderboard.length > 0" class="lms-table"> <table v-if="currentLeaderboard.length > 0" class="lms-table">
<!-- FIX: Fixed table layout to prevent jumping when tabs change -->
<colgroup> <colgroup>
<col style="width: 70px;"> <col style="width: 70px;">
<col style="width: auto;"> <col style="width: auto;">
@@ -126,19 +125,19 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="(user, index) in currentLeaderboard" v-for="(user, index) in currentLeaderboard"
:key="user.user" :key="user.user"
:class="{'is-current-user': user.user === currentUser.name}" :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-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 === 1" title="2 Место" class="medal-emoji">🥈</span>
<span v-else-if="index === 2" title="3 Место" class="medal-emoji">🥉</span> <span v-else-if="index === 2" title="3 Место" class="medal-emoji">🥉</span>
<span v-else>{{ index + 1 }}</span> <span v-else>{{ index + 1 }}</span>
</td> </td>
<!-- Пользователь --> <!-- Пользователь -->
<td> <td>
<div class="user-cell"> <div class="user-cell">
@@ -148,15 +147,11 @@
</a> </a>
</div> </div>
</td> </td>
<!-- Очки --> <!-- Очки -->
<td style="text-align: center; font-weight: 600; color: #111827;"> <td class="td-points">{{ user.points }}</td>
{{ user.points }} <td class="td-points">{{ user.points }}</td>
</td>
<td style="text-align: center; font-weight: 600; color: #111827;">
{{ user.points }}
</td>
<!-- Бонусы МПГУ --> <!-- Бонусы МПГУ -->
<td v-if="activeGroup === 'LMS Schoolchild'" style="text-align: center;"> <td v-if="activeGroup === 'LMS Schoolchild'" style="text-align: center;">
<span v-if="user.bonus > 0" class="bonus-badge"> <span v-if="user.bonus > 0" class="bonus-badge">
@@ -167,9 +162,45 @@
</tr> </tr>
</tbody> </tbody>
</table> </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"> <div v-else class="lms-empty-state">
<i class="fas fa-ghost empty-icon"></i> <User class="empty-icon" />
<p>{{ __('В этой категории пока нет участников.') }}</p> <p>{{ __('В этой категории пока нет участников.') }}</p>
</div> </div>
</div> </div>
@@ -184,6 +215,7 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { createResource } from 'frappe-ui' import { createResource } from 'frappe-ui'
import { Crown, Medal, Trophy, User } from 'lucide-vue-next'
const store = usersStore() const store = usersStore()
const { userResource, allUsers } = store const { userResource, allUsers } = store
@@ -223,24 +255,17 @@ const currentUser = computed(() => {
return userResource.data || {} 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) { function getUserInitial(user) {
return user.full_name?.charAt(0).toUpperCase() || user.username?.charAt(0).toUpperCase() || 'U' return user.full_name?.charAt(0).toUpperCase() || user.username?.charAt(0).toUpperCase() || 'U'
} }
watch( watch(
[() => logsResource.data, () => allUsers.data], [() => logsResource.data, () => allUsers.data],
() => { () => {
if (logsResource.data && allUsers.data) { if (allUsers.data) {
calculateLeaderboard() calculateLeaderboard()
} }
}, },
{ deep: true } { deep: true }
) )
@@ -262,6 +287,14 @@ const currentLeaderboard = computed(() => {
.slice(0, 50) .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(() => { const topUserInGroup = computed(() => {
return currentLeaderboard.value[0] return currentLeaderboard.value[0]
}) })
@@ -296,14 +329,16 @@ const nextPosition = computed(() => {
}) })
function calculateLeaderboard() { function calculateLeaderboard() {
if (!logsResource.data || !usersList.value) return if (!usersList.value) return
const pointsMap = {} const pointsMap = {}
logsResource.data.forEach(log => { if (logsResource.data) {
if (!pointsMap[log.user]) pointsMap[log.user] = 0 logsResource.data.forEach(log => {
pointsMap[log.user] += log.points if (!pointsMap[log.user]) pointsMap[log.user] = 0
}) pointsMap[log.user] += log.points
})
}
leaderboard.value = Object.keys(pointsMap) leaderboard.value = Object.keys(pointsMap)
.map(user => { .map(user => {
@@ -327,7 +362,7 @@ function calculateLeaderboard() {
username: u.username, username: u.username,
roles: u.roles || [], roles: u.roles || [],
bonus: bonus, bonus: bonus,
isSchoolchild: isSchoolchild isSchoolchild: isSchoolchild
} }
}) })
.filter(user => user !== null) .filter(user => user !== null)
@@ -336,14 +371,15 @@ function calculateLeaderboard() {
onMounted(async () => { onMounted(async () => {
await allUsers.reload() await allUsers.reload()
await logsResource.reload() // Загружаем Energy Point Log параллельно, ошибка не блокирует отображение
logsResource.reload().catch(() => {})
calculateLeaderboard() calculateLeaderboard()
}) })
</script> </script>
<style scoped> <style scoped>
/* ============================================ /* ============================================
ДИЗАЙН В СТИЛЕ FRAPPE LMS LEADERBOARD — Frappe UI tokens only
============================================ */ ============================================ */
.lms-page-container { .lms-page-container {
@@ -351,7 +387,7 @@ onMounted(async () => {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #111827; color: rgb(var(--color-ink-gray-9));
} }
/* === HEADER & TABS === */ /* === HEADER & TABS === */
@@ -368,22 +404,22 @@ onMounted(async () => {
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;
color: #111827; color: rgb(var(--color-ink-gray-9));
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.lms-tabs-container { .lms-tabs-container {
display: inline-flex; display: inline-flex;
background-color: #f3f4f6; background-color: rgb(var(--color-surface-gray-2));
padding: 4px; padding: 4px;
border-radius: 8px; border-radius: 8px;
border: 1px solid #e5e7eb; border: 1px solid rgb(var(--color-outline-gray-2));
} }
.lms-tab-btn { .lms-tab-btn {
background: transparent; background: transparent;
border: none; border: none;
color: #6b7280; color: rgb(var(--color-ink-gray-5));
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
padding: 8px 20px; padding: 8px 20px;
@@ -393,12 +429,12 @@ onMounted(async () => {
} }
.lms-tab-btn:hover { .lms-tab-btn:hover {
color: #374151; color: rgb(var(--color-ink-gray-7));
} }
.lms-tab-btn.active { .lms-tab-btn.active {
background: #ffffff; background: rgb(var(--color-surface-white));
color: #111827; color: rgb(var(--color-ink-gray-9));
font-weight: 600; font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
@@ -419,8 +455,8 @@ onMounted(async () => {
/* === CARDS === */ /* === CARDS === */
.lms-card { .lms-card {
background: #ffffff; background: rgb(var(--color-surface-white));
border: 1px solid #e5e7eb; border: 1px solid rgb(var(--color-outline-gray-2));
border-radius: 12px; border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: hidden; overflow: hidden;
@@ -440,36 +476,41 @@ onMounted(async () => {
.card-subtitle { .card-subtitle {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #6b7280; color: rgb(var(--color-ink-gray-5));
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.icon-gold { color: #f59e0b; font-size: 1.4rem; } .icon-svg {
.icon-silver { color: #9ca3af; font-size: 1.4rem; } width: 22px;
.icon-bronze { color: #b45309; font-size: 1.4rem; } height: 22px;
.icon-gray { color: #d1d5db; font-size: 1.4rem; } }
.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 { .card-title {
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
margin: 0 0 24px 0; margin: 0 0 24px 0;
color: #111827; color: rgb(var(--color-ink-gray-9));
line-height: 1.3; line-height: 1.3;
} }
.card-link { .card-link {
text-decoration: none; text-decoration: none;
color: #111827; color: rgb(var(--color-ink-gray-9));
transition: color 0.2s; transition: color 0.2s;
} }
.card-link:hover { color: #00a9a6; } .card-link:hover { color: rgb(var(--color-ink-blue-3)); }
.card-stats { .card-stats {
display: flex; display: flex;
gap: 32px; gap: 32px;
margin-bottom: 20px; margin-bottom: 20px;
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid rgb(var(--color-outline-gray-1));
padding-bottom: 20px; padding-bottom: 20px;
} }
@@ -482,20 +523,21 @@ onMounted(async () => {
font-size: 28px; font-size: 28px;
font-weight: 800; font-weight: 800;
line-height: 1; line-height: 1;
color: #111827; color: rgb(var(--color-ink-gray-9));
} }
.stat-label { .stat-label {
font-size: 12px; font-size: 12px;
color: #6b7280; color: rgb(var(--color-ink-gray-5));
margin-top: 8px; margin-top: 8px;
font-weight: 500; font-weight: 500;
} }
.text-primary { color: #00a9a6; } .text-primary { color: rgb(var(--color-ink-blue-3)); }
.text-gold { color: #f59e0b; } .text-gold { color: rgb(var(--color-ink-amber-3)); }
.text-muted { color: #6b7280; font-size: 13px; margin: 0; line-height: 1.5; } .text-muted { color: rgb(var(--color-ink-gray-5)); 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-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 { .card-footer {
min-height: 20px; min-height: 20px;
@@ -503,42 +545,40 @@ onMounted(async () => {
/* === TABLE === */ /* === TABLE === */
.table-card { .table-card {
min-height: 500px; min-height: 500px;
} }
.lms-table-header { .lms-table-header {
padding: 20px 24px; padding: 20px 24px;
background: #ffffff; background: rgb(var(--color-surface-white));
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid rgb(var(--color-outline-gray-2));
} }
.lms-table-header h3 { .lms-table-header h3 {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #111827; color: rgb(var(--color-ink-gray-9));
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.count-badge { .count-badge {
background: #f3f4f6; background: rgb(var(--color-surface-gray-2));
color: #4b5563; color: rgb(var(--color-ink-gray-6));
padding: 2px 10px; padding: 2px 10px;
border-radius: 20px; border-radius: 20px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
} }
/* --- FIX: Настройки скролла для длинной таблицы --- */
.table-responsive { .table-responsive {
overflow-x: auto; overflow-x: auto;
overflow-y: auto; overflow-y: auto;
max-height: 650px; /* Ограничиваем высоту, чтобы таблица не уезжала вниз */ max-height: 650px;
} }
/* Кастомный красивый скроллбар */
.table-responsive::-webkit-scrollbar { .table-responsive::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -547,11 +587,11 @@ onMounted(async () => {
background: transparent; background: transparent;
} }
.table-responsive::-webkit-scrollbar-thumb { .table-responsive::-webkit-scrollbar-thumb {
background-color: #d1d5db; background-color: rgb(var(--color-outline-gray-3));
border-radius: 10px; border-radius: 10px;
} }
.table-responsive::-webkit-scrollbar-thumb:hover { .table-responsive::-webkit-scrollbar-thumb:hover {
background-color: #9ca3af; background-color: rgb(var(--color-outline-gray-4));
} }
.lms-table { .lms-table {
@@ -559,19 +599,17 @@ onMounted(async () => {
border-collapse: collapse; border-collapse: collapse;
text-align: left; text-align: left;
font-size: 14px; font-size: 14px;
table-layout: fixed; table-layout: fixed;
} }
.lms-table th { .lms-table th {
background: #f9fafb; background: rgb(var(--color-surface-gray-1));
color: #4b5563; color: rgb(var(--color-ink-gray-6));
font-weight: 500; font-weight: 500;
font-size: 13px; font-size: 13px;
padding: 14px 24px; padding: 14px 24px;
/* Заменяем border-bottom на box-shadow для sticky, чтобы не было просветов */ box-shadow: inset 0 -1px 0 rgb(var(--color-outline-gray-2));
box-shadow: inset 0 -1px 0 #e5e7eb;
white-space: nowrap; white-space: nowrap;
/* Делаем шапку прилипающей */
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
@@ -579,24 +617,36 @@ onMounted(async () => {
.lms-table td { .lms-table td {
padding: 16px 24px; padding: 16px 24px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid rgb(var(--color-outline-gray-1));
color: #374151; color: rgb(var(--color-ink-gray-7));
vertical-align: middle; 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 { .lms-table tr:last-child td {
border-bottom: none; border-bottom: none;
} }
.lms-table tr:hover { .lms-table tr:hover {
background-color: #f9fafb; background-color: rgb(var(--color-surface-gray-1));
} }
.lms-table tr.is-current-user { .lms-table tr.is-current-user {
background-color: #f0fdfa; background-color: rgb(var(--color-surface-blue-1));
} }
.lms-table tr.is-current-user td { .lms-table tr.is-current-user td {
border-bottom-color: #ccfbf1; border-bottom-color: rgb(var(--color-outline-blue-1));
} }
.medal-emoji { .medal-emoji {
@@ -612,8 +662,8 @@ onMounted(async () => {
.user-avatar { .user-avatar {
width: 36px; width: 36px;
height: 36px; height: 36px;
background: #e5e7eb; background: rgb(var(--color-surface-gray-3));
color: #4b5563; color: rgb(var(--color-ink-gray-6));
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -623,17 +673,17 @@ onMounted(async () => {
flex-shrink: 0; flex-shrink: 0;
} }
.is-current-user .user-avatar { .is-current-user .user-avatar {
background: #00a9a6; background: rgb(var(--color-surface-blue-3));
color: #ffffff; color: rgb(var(--color-ink-on-blue));
} }
.user-link { .user-link {
color: #111827; color: rgb(var(--color-ink-gray-9));
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
} }
.user-link:hover { .user-link:hover {
color: #00a9a6; color: rgb(var(--color-ink-blue-3));
text-decoration: underline; text-decoration: underline;
} }
@@ -647,8 +697,8 @@ onMounted(async () => {
.bonus-badge { .bonus-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
background: #ecfdf5; background: rgb(var(--color-surface-green-1));
color: #059669; color: rgb(var(--color-ink-green-3));
padding: 4px 10px; padding: 4px 10px;
border-radius: 6px; border-radius: 6px;
font-weight: 600; font-weight: 600;
@@ -658,7 +708,7 @@ onMounted(async () => {
.lms-empty-state { .lms-empty-state {
padding: 60px 20px; padding: 60px 20px;
text-align: center; text-align: center;
color: #6b7280; color: rgb(var(--color-ink-gray-5));
font-size: 15px; font-size: 15px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -667,8 +717,9 @@ onMounted(async () => {
} }
.empty-icon { .empty-icon {
font-size: 32px; width: 32px;
color: #d1d5db; height: 32px;
color: rgb(var(--color-ink-gray-3));
} }
/* === RESPONSIVE === */ /* === RESPONSIVE === */
@@ -676,11 +727,11 @@ onMounted(async () => {
.lms-layout-grid { .lms-layout-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.lms-sidebar { .lms-sidebar {
flex-direction: row; flex-direction: row;
} }
.lms-card { .lms-card {
flex: 1; flex: 1;
} }
@@ -690,7 +741,7 @@ onMounted(async () => {
.lms-page-container { .lms-page-container {
padding: 16px; padding: 16px;
} }
.lms-sidebar { .lms-sidebar {
flex-direction: column; flex-direction: column;
} }
@@ -699,20 +750,19 @@ onMounted(async () => {
width: 100%; width: 100%;
display: flex; display: flex;
} }
.lms-tab-btn { .lms-tab-btn {
flex: 1; flex: 1;
text-align: center; text-align: center;
padding: 8px 12px; padding: 8px 12px;
} }
.lms-table th, .lms-table td { .lms-table th, .lms-table td {
padding: 12px 16px; padding: 12px 16px;
} }
/* Немного уменьшим высоту на мобилках */
.table-responsive { .table-responsive {
max-height: 500px; max-height: 500px;
} }
} }
</style> </style>
@@ -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>
+42 -3
View File
@@ -21,8 +21,18 @@
/> />
</div> </div>
</div> </div>
<div class="text-sm text-ink-gray-5"> <div class="flex items-center gap-3">
{{ filteredOlympiads.length }} {{ __('olympiads') }} <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>
</div> </div>
@@ -56,12 +66,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { Breadcrumbs, TabButtons, createResource, usePageMeta } from 'frappe-ui' 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 { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user'
import EmptyState from '@/components/EmptyState.vue' import EmptyState from '@/components/EmptyState.vue'
import OlympiadCard from '@/pages/Olympiads/OlympiadCard.vue' import OlympiadCard from '@/pages/Olympiads/OlympiadCard.vue'
const { brand } = sessionStore() 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 searchQuery = ref('')
const currentStatus = ref('all') const currentStatus = ref('all')
@@ -114,3 +132,24 @@ usePageMeta(() => ({
icon: brand.favicon, icon: brand.favicon,
})) }))
</script> </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>
+39 -131
View File
@@ -47,7 +47,7 @@
</div> </div>
</div> </div>
<!-- VIEW MODE --> <!-- VIEW MODE -->
<div v-if="!editMode" class="section-grid"> <div v-if="!editMode" class="section-grid">
<!-- Empty profile --> <!-- Empty profile -->
<div v-if="schoolProfileNotFound" class="empty-card"> <div v-if="schoolProfileNotFound" class="empty-card">
@@ -406,25 +406,25 @@
} }
} }
.hero-avatar { .hero-avatar {
width: 64px; width: 96px;
height: 64px; height: 96px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #f97316, #f59e0b); background: linear-gradient(135deg, rgb(var(--color-surface-amber-2)), rgb(var(--color-surface-amber-3)));
color: #fff; color: rgb(var(--color-ink-on-amber));
font-size: 26px; font-size: 38px;
font-weight: 700; font-weight: 700;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; 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 { .hero-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.hero-name { .hero-name {
font-size: 20px; font-size: 22px;
font-weight: 700; font-weight: 700;
color: rgb(var(--color-ink-gray-9)); color: rgb(var(--color-ink-gray-9));
line-height: 1.3; line-height: 1.3;
@@ -432,7 +432,7 @@
.hero-subtitle { .hero-subtitle {
font-size: 13px; font-size: 13px;
color: rgb(var(--color-ink-gray-5)); color: rgb(var(--color-ink-gray-5));
margin-top: 2px; margin-top: 4px;
} }
.hero-bio { .hero-bio {
margin-top: 4px; margin-top: 4px;
@@ -481,7 +481,7 @@
.card-header-icon { .card-header-icon {
width: 20px; width: 20px;
height: 20px; height: 20px;
color: #f97316; color: rgb(var(--color-ink-amber-3));
flex-shrink: 0; flex-shrink: 0;
} }
.card-header-title { .card-header-title {
@@ -581,8 +581,8 @@
width: 72px; width: 72px;
height: 72px; height: 72px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #fff7ed, #ffedd5); background: rgb(var(--color-surface-amber-1));
color: #f97316; color: rgb(var(--color-ink-amber-3));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -620,7 +620,7 @@
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: #f97316; background: rgb(var(--color-ink-amber-3));
flex-shrink: 0; flex-shrink: 0;
} }
@@ -654,8 +654,8 @@
transition: border-color 0.15s, box-shadow 0.15s; transition: border-color 0.15s, box-shadow 0.15s;
} }
.edit-input:focus { .edit-input:focus {
border-color: #f97316; border-color: rgb(var(--color-ink-amber-3));
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1); box-shadow: 0 0 0 3px rgba(var(--color-surface-amber-2), 0.4);
} }
.dropdown-list { .dropdown-list {
margin-top: 4px; margin-top: 4px;
@@ -698,13 +698,6 @@ const { user } = sessionStore();
const $user = inject('$user'); const $user = inject('$user');
const schoolProfileNotFound = ref(false); const schoolProfileNotFound = ref(false);
// Логирование инициализации
console.log('[DEBUG] Инициализация компонента:', {
user: user,
$user: $user.data,
username: $user.data?.username,
});
const props = defineProps({ const props = defineProps({
username: { username: {
type: String, type: String,
@@ -714,9 +707,7 @@ const props = defineProps({
}); });
const effectiveUsername = computed(() => { const effectiveUsername = computed(() => {
const username = props.username || $user.data?.username || ''; return props.username || $user.data?.username || '';
console.log('[DEBUG] Вычисление effectiveUsername:', { propsUsername: props.username, sessionUsername: $user.data?.username, result: username });
return username;
}); });
const editMode = ref(false); const editMode = ref(false);
@@ -726,17 +717,12 @@ const profile = createResource({
url: 'frappe.client.get', url: 'frappe.client.get',
makeParams(values) { makeParams(values) {
const username = effectiveUsername.value; const username = effectiveUsername.value;
console.log('[DEBUG] Запрос profile:', { doctype: 'User', filters: { username } });
return { return {
doctype: 'User', doctype: 'User',
filters: { username }, filters: { username },
}; };
}, },
onSuccess(data) {
console.log('[DEBUG] Профиль загружен:', data);
},
onError(error) { onError(error) {
console.error('[DEBUG] Ошибка загрузки профиля:', error);
window.frappe?.msgprint({ window.frappe?.msgprint({
title: 'Ошибка', title: 'Ошибка',
message: 'Не удалось загрузить профиль пользователя: ' + (error.message || 'Неизвестная ошибка'), message: 'Не удалось загрузить профиль пользователя: ' + (error.message || 'Неизвестная ошибка'),
@@ -752,23 +738,17 @@ const schoolProfile = createResource({
filters: { user:user }, filters: { user:user },
}, },
auto: false, auto: false,
onSuccess(data) { onError(error) {
console.log('[DEBUG] Профиль школьника загружен:', data); 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({ const form = ref({
@@ -792,7 +772,7 @@ const form = ref({
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
const username = effectiveUsername.value; const username = effectiveUsername.value;
const crumbs = [ return [
{ {
label: 'People', label: 'People',
route: { name: 'People' }, route: { name: 'People' },
@@ -805,35 +785,20 @@ const breadcrumbs = computed(() => {
} : undefined, } : undefined,
}, },
]; ];
console.log('[DEBUG] Хлебные крошки:', crumbs);
return crumbs;
}); });
const pageMeta = computed(() => { const pageMeta = computed(() => ({
const meta = { title: profile.data?.full_name || 'Профиль',
title: profile.data?.full_name || 'Профиль', description: profile.data?.headline || '',
description: profile.data?.headline || '', }));
};
console.log('[DEBUG] Мета-данные страницы:', meta);
return meta;
});
const displayName = computed(() => { const displayName = computed(() => {
if (!profile.data) { if (!profile.data) return 'Загрузка...';
console.log('[DEBUG] displayName: profile.data не загружен'); return profile.data?.full_name || `${form.value.first_name || ''} ${form.value.last_name || ''}`.trim();
return 'Загрузка...';
}
const name = profile.data?.full_name || `${form.value.first_name || ''} ${form.value.last_name || ''}`.trim();
console.log('[DEBUG] Отображаемое имя:', name);
return name;
}); });
const isSessionUser = () => { const isSessionUser = () => {
const sessionUser = $user.data?.username; return $user.data?.username === effectiveUsername.value;
const profileUser = effectiveUsername.value;
const isSession = sessionUser === profileUser;
console.log('[DEBUG] Проверка isSessionUser:', { sessionUser, profileUser, isSession });
return isSession;
}; };
function formattedDate(d) { function formattedDate(d) {
@@ -841,7 +806,6 @@ function formattedDate(d) {
try { try {
return new Date(d).toLocaleDateString('ru-RU'); return new Date(d).toLocaleDateString('ru-RU');
} catch (e) { } catch (e) {
console.error('[DEBUG] Ошибка форматирования даты:', e, { date: d });
return d; return d;
} }
} }
@@ -862,11 +826,6 @@ function formatTelegram(t) {
} }
function fillFormFromProfile() { 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.first_name = schoolProfile.data?.first_name || profile.data?.first_name || '';
form.value.last_name = schoolProfile.data?.last_name || profile.data?.last_name || ''; form.value.last_name = schoolProfile.data?.last_name || profile.data?.last_name || '';
form.value.middle_name = schoolProfile.data?.middle_name || ''; form.value.middle_name = schoolProfile.data?.middle_name || '';
@@ -883,38 +842,20 @@ function fillFormFromProfile() {
form.value.interests = schoolProfile.data?.interests || ''; form.value.interests = schoolProfile.data?.interests || '';
form.value.about_me = schoolProfile.data?.about_me || ''; form.value.about_me = schoolProfile.data?.about_me || '';
form.value.dreams = schoolProfile.data?.dreams || ''; form.value.dreams = schoolProfile.data?.dreams || '';
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
} }
function toggleEdit() { function toggleEdit() {
editMode.value = !editMode.value; editMode.value = !editMode.value;
if (editMode.value) fillFormFromProfile(); 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() { async function saveProfile() {
console.log('[DEBUG] Сохранение профиля:', { form: form.value });
saving.value = true; saving.value = true;
try { try {
// Создаём копию данных формы
const formData = { ...form.value }; const formData = { ...form.value };
console.log('[DEBUG] Копия formData:', JSON.stringify(formData, null, 2));
// Обновление full_name в User, если нужно
if (formData.first_name || formData.last_name) { if (formData.first_name || formData.last_name) {
const fullName = `${formData.first_name || ''} ${formData.last_name || ''}`.trim(); const fullName = `${formData.first_name || ''} ${formData.last_name || ''}`.trim();
console.log('[DEBUG] Обновление User.full_name:', { name: profile.data?.name, fullName });
await createResource({ await createResource({
url: 'frappe.client.set_value', url: 'frappe.client.set_value',
params: { params: {
@@ -926,18 +867,14 @@ async function saveProfile() {
}).submit(); }).submit();
} }
// Получаем docname
let docname = ''; let docname = '';
try { try {
await schoolProfile.reload(); await schoolProfile.reload();
console.log('[DEBUG] Schoolprofile:', { schoolProfile });
docname = schoolProfile?.data?.name; docname = schoolProfile?.data?.name;
console.log('[DEBUG] Выбранное имя документа:', docname);
} catch (error) { } catch (error) {
console.log('[DEBUG] Ошибка загрузки schoolProfile, продолжаем с profile:', error.message); // профиль не найден, создаём новый
} }
// Формируем payload из копии данных формы
let payload = { let payload = {
doctype: 'Schoolchildren Profile', doctype: 'Schoolchildren Profile',
user: profile.data?.name, user: profile.data?.name,
@@ -959,9 +896,7 @@ async function saveProfile() {
dreams: formData.dreams, dreams: formData.dreams,
last_updated: new Date().toISOString(), last_updated: new Date().toISOString(),
}; };
console.log('[DEBUG] Сохранение Schoolchildren Profile (payload):', { docname, payload });
// Сохранение или создание документа
if (docname) { if (docname) {
await createResource({ await createResource({
url: 'frappe.client.save', url: 'frappe.client.save',
@@ -975,11 +910,9 @@ async function saveProfile() {
} }
editMode.value = false; editMode.value = false;
schoolProfileNotFound.value = false; schoolProfileNotFound.value = false;
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint('Профиль сохранён'); if (window.frappe && window.frappe.msgprint) window.frappe.msgprint('Профиль сохранён');
console.log('[DEBUG] Профиль успешно сохранён');
} catch (e) { } catch (e) {
console.error('[DEBUG] Ошибка при сохранении профиля:', e);
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint({ if (window.frappe && window.frappe.msgprint) window.frappe.msgprint({
title: 'Ошибка', title: 'Ошибка',
message: (e && e.message) || 'Ошибка при сохранении', message: (e && e.message) || 'Ошибка при сохранении',
@@ -1000,7 +933,6 @@ async function searchSchool(q) {
return; return;
} }
try { try {
console.log('[DEBUG] Поиск школы:', { query: q });
const res = await createResource({ const res = await createResource({
url: 'frappe.client.get_list', url: 'frappe.client.get_list',
params: { params: {
@@ -1011,10 +943,8 @@ async function searchSchool(q) {
}, },
}).submit(); }).submit();
schoolResults.value = res || []; schoolResults.value = res || [];
console.log('[DEBUG] Результаты поиска школы:', schoolResults.value);
} catch (e) { } catch (e) {
schoolResults.value = []; schoolResults.value = [];
console.error('[DEBUG] Ошибка поиска школы:', e);
} }
} }
@@ -1022,11 +952,8 @@ const debouncedSearchSchool = debounce(() => searchSchool(schoolQuery.value), 30
function selectSchool(s) { function selectSchool(s) {
form.value.school = s.school; form.value.school = s.school;
//form.value.school_name = s.school_name;
schoolResults.value = []; schoolResults.value = [];
schoolQuery.value = s.school; schoolQuery.value = s.school;
console.log('[DEBUG] Выбрана школа:', { school: s });
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
} }
const majorQuery = ref(''); const majorQuery = ref('');
@@ -1038,7 +965,6 @@ async function searchMajor(q) {
return; return;
} }
try { try {
console.log('[DEBUG] Поиск направления:', { query: q });
const res = await createResource({ const res = await createResource({
url: 'frappe.client.get_list', url: 'frappe.client.get_list',
params: { params: {
@@ -1049,10 +975,8 @@ async function searchMajor(q) {
}, },
}).submit(); }).submit();
majorResults.value = res || []; majorResults.value = res || [];
console.log('[DEBUG] Результаты поиска направления:', majorResults.value);
} catch (e) { } catch (e) {
majorResults.value = []; majorResults.value = [];
console.error('[DEBUG] Ошибка поиска направления:', e);
} }
} }
@@ -1060,40 +984,27 @@ const debouncedSearchMajor = debounce(() => searchMajor(majorQuery.value), 300);
function selectMajor(m) { function selectMajor(m) {
form.value.major = m.major_name; form.value.major = m.major_name;
//form.value.school_name = s.school_name;
majorResults.value = []; majorResults.value = [];
majorQuery.value = m.major_name; majorQuery.value = m.major_name;
console.log('[DEBUG] Выбрана школа:', { major: m });
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
} }
onMounted(() => { onMounted(() => {
console.log('[DEBUG] Компонент смонтирован:', {
propsUsername: props.username,
sessionUsername: $user.data?.username,
user: user,
$user: $user.data,
});
if ($user.data) { if ($user.data) {
console.log('[DEBUG] Запуск profile.reload()');
profile.reload(); profile.reload();
} }
}); });
watch( watch(
() => props.username, () => props.username,
(newUsername, oldUsername) => { () => {
console.log('[DEBUG] Изменение props.username:', { old: oldUsername, new: newUsername });
profile.reload(); profile.reload();
} }
); );
watch( watch(
() => profile.data, () => profile.data,
(newData, oldData) => { (newData) => {
console.log('[DEBUG] Изменение profile.data:', { old: oldData, new: newData });
if (newData) { if (newData) {
console.log('[DEBUG] Запуск schoolProfile.reload()');
schoolProfile.reload(); schoolProfile.reload();
} }
} }
@@ -1101,10 +1012,8 @@ watch(
watch( watch(
() => schoolProfile.data, () => schoolProfile.data,
(newData, oldData) => { (newData) => {
console.log('[DEBUG] Изменение schoolProfile.data:', { old: oldData, new: newData });
if (newData && !editMode.value && !schoolProfileNotFound.value) { if (newData && !editMode.value && !schoolProfileNotFound.value) {
console.log('[DEBUG] Заполнение формы из schoolProfile');
fillFormFromProfile(); fillFormFromProfile();
} }
} }
@@ -1113,7 +1022,6 @@ watch(
watch( watch(
() => effectiveUsername.value, () => effectiveUsername.value,
(newUsername) => { (newUsername) => {
console.log('[DEBUG] Изменение effectiveUsername для schoolProfile:', newUsername);
schoolProfile.update({ schoolProfile.update({
params: { params: {
doctype: 'Schoolchildren Profile', doctype: 'Schoolchildren Profile',
+11
View File
@@ -271,6 +271,17 @@ const routes = [
name: 'Olympiads', name: 'Olympiads',
component: () => import('@/pages/Olympiads/Olympiads.vue'), 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', path: '/olympiads/:olympiadName',
name: 'OlympiadDetail', name: 'OlympiadDetail',
+9
View File
@@ -541,6 +541,15 @@ const getSidebarItems = () => {
label: 'Assessments', label: 'Assessments',
hideLabel: true, hideLabel: true,
items: [ items: [
{
label: 'Create Olympiad',
icon: 'PlusCircle',
to: 'OlympiadNew',
condition: () => {
return isAdmin()
},
activeFor: ['OlympiadNew', 'OlympiadEdit'],
},
{ {
label: 'Quizzes', label: 'Quizzes',
icon: 'CircleHelp', icon: 'CircleHelp',
+96
View File
@@ -8320,3 +8320,99 @@ msgstr "Чем вы занимаетесь на платформе?"
msgid "Установить приложение IIE" msgid "Установить приложение IIE"
msgstr "Установить приложение IIE" msgstr "Установить приложение IIE"
msgid "Olympiads"
msgstr "Олимпиады"
msgid "Academic Olympiads"
msgstr "Академические олимпиады"
msgid "Olympiad"
msgstr "Олимпиада"
msgid "Registration Open"
msgstr "Регистрация открыта"
msgid "In Progress"
msgstr "Идёт"
msgid "Completed"
msgstr "Завершена"
msgid "Register"
msgstr "Зарегистрироваться"
msgid "Unregister"
msgstr "Отменить регистрацию"
msgid "Leaderboard"
msgstr "Таблица лидеров"
msgid "Participants"
msgstr "Участники"
msgid "Subject"
msgstr "Предмет"
msgid "Create Olympiad"
msgstr "Создать олимпиаду"
msgid "New Olympiad"
msgstr "Новая олимпиада"
msgid "Edit Olympiad"
msgstr "Редактировать олимпиаду"
msgid "olympiads"
msgstr "олимпиад"
msgid "Draft"
msgstr "Черновик"
msgid "Fill in the olympiad details below"
msgstr "Заполните данные олимпиады ниже"
msgid "Olympiad title"
msgstr "Название олимпиады"
msgid "Describe the olympiad..."
msgstr "Опишите олимпиаду..."
msgid "Registration Start"
msgstr "Начало регистрации"
msgid "Registration End"
msgstr "Конец регистрации"
msgid "Olympiad Start"
msgstr "Начало олимпиады"
msgid "Olympiad End"
msgstr "Конец олимпиады"
msgid "Max Participants"
msgstr "Максимум участников"
msgid "Leave empty for unlimited"
msgstr "Оставьте пустым для неограниченного количества"
msgid "Passing Score"
msgstr "Проходной балл"
msgid "Save Changes"
msgstr "Сохранить изменения"
msgid "Title is required"
msgstr "Название обязательно"
msgid "Olympiad updated"
msgstr "Олимпиада обновлена"
msgid "Olympiad created"
msgstr "Олимпиада создана"
msgid "Failed to load olympiad: "
msgstr "Не удалось загрузить олимпиаду: "
msgid "Error saving olympiad: "
msgstr "Ошибка сохранения олимпиады: "