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:
@@ -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>
|
||||
|
||||
345
frontend/src/pages/Olympiads/OlympiadForm.vue
Normal file
345
frontend/src/pages/Olympiads/OlympiadForm.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user