diff --git a/frontend/src/pages/LeaderBoard.vue b/frontend/src/pages/LeaderBoard.vue index 64f3857f..720ede01 100644 --- a/frontend/src/pages/LeaderBoard.vue +++ b/frontend/src/pages/LeaderBoard.vue @@ -1,210 +1,182 @@ @@ -216,14 +188,12 @@ import { createResource } from 'frappe-ui' const store = usersStore() const { userResource, allUsers } = store -// Группы ролей которые нас интересуют const roleGroups = [ - { role: 'LMS Student', label: __('Students') }, - { role: 'Course Creator', label: __('Teachers') }, - { role: 'LMS Schoolchild', label: __('Schoolchildren') } + { role: 'LMS Student', label: __('Студенты') }, + { role: 'Course Creator', label: __('Преподаватели') }, + { role: 'LMS Schoolchild', label: __('Школьники') } ] -// Определяем группу текущего пользователя function getUserRoleGroup(userRoles) { if (!userRoles) return 'LMS Student' const validRole = userRoles.find(role => @@ -235,7 +205,6 @@ function getUserRoleGroup(userRoles) { const activeGroup = ref('LMS Student') const usersList = computed(() => allUsers.data || []) -const usersCount = computed(() => usersList.value.length) const logsResource = createResource({ url: "frappe.client.get_list", @@ -250,12 +219,10 @@ const logsResource = createResource({ const leaderboard = ref([]) -// Текущий пользователь const currentUser = computed(() => { return userResource.data || {} }) -// Инициалы текущего пользователя const currentUserInitial = computed(() => { if (currentUser.value.full_name) { return currentUser.value.full_name.charAt(0).toUpperCase() @@ -263,12 +230,20 @@ const currentUserInitial = computed(() => { 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], + () => { + if (logsResource.data && allUsers.data) { + calculateLeaderboard() + } + }, + { deep: true } +) + watch([currentUser, usersList], () => { if (currentUser.value.roles && usersList.value.length > 0) { const userGroup = getUserRoleGroup(currentUser.value.roles) @@ -276,25 +251,21 @@ watch([currentUser, usersList], () => { } }) -// Активная группа label const activeGroupLabel = computed(() => { const group = roleGroups.find(g => g.role === activeGroup.value) return group ? group.label : '' }) -// Текущий лидерборд для активной группы const currentLeaderboard = computed(() => { return leaderboard.value .filter(user => user.roles.includes(activeGroup.value)) .slice(0, 50) }) -// Лучший пользователь в активной группе const topUserInGroup = computed(() => { return currentLeaderboard.value[0] }) -// Позиция текущего пользователя в активной группе const currentUserPosition = computed(() => { const userInLeaderboard = currentLeaderboard.value.findIndex( user => user.user === currentUser.value.name @@ -302,27 +273,21 @@ const currentUserPosition = computed(() => { return userInLeaderboard !== -1 ? userInLeaderboard + 1 : '-' }) -// Очки текущего пользователя const currentUserPoints = computed(() => { const userEntry = leaderboard.value.find(user => user.user === currentUser.value.name) return userEntry ? userEntry.points : 0 }) -// Очки до следующего места const pointsToNext = computed(() => { const userIndex = currentLeaderboard.value.findIndex( user => user.user === currentUser.value.name ) - if (userIndex === -1 || userIndex === 0) return 0 - - const currentUserPoints = currentLeaderboard.value[userIndex].points + const currentPoints = currentLeaderboard.value[userIndex].points const nextUserPoints = currentLeaderboard.value[userIndex - 1].points - - return nextUserPoints - currentUserPoints + 1 + return nextUserPoints - currentPoints + 1 }) -// Следующая позиция const nextPosition = computed(() => { const userIndex = currentLeaderboard.value.findIndex( user => user.user === currentUser.value.name @@ -330,84 +295,29 @@ const nextPosition = computed(() => { return userIndex > 0 ? userIndex : 1 }) -// Очки следующего пользователя -const nextPositionPoints = computed(() => { - const userIndex = currentLeaderboard.value.findIndex( - user => user.user === currentUser.value.name - ) - return userIndex > 0 ? currentLeaderboard.value[userIndex - 1].points : currentUserPoints.value -}) - -// Классы для групп -function getGroupButtonClass(role) { - const classes = { - 'LMS Student': 'bg-teal-400 text-white shadow-md', - 'Course Creator': 'bg-teal-600 text-white shadow-md', - 'LMS Schoolchild': 'bg-teal-200 text-white shadow-md' - } - return classes[role] || 'bg-blue-500 text-white shadow-md' -} - -function getGroupGradientClass(role) { - const classes = { - 'LMS Student': 'from-teal-400 to-teal-500', - 'Course Creator': 'from-teal-600 to-teal-700', - 'LMS Schoolchild': 'from-teal-300 to-teal-400' - } - return classes[role] || 'from-blue-500 to-blue-600' -} - -function getGroupBorderClass(role) { - const classes = { - 'LMS Student': 'border-teal-400', - 'Course Creator': 'border-teal-600', - 'LMS Schoolchild': 'border-teal-300' - } - return classes[role] || 'border-blue-400' -} - -// Статистика по группам -function getGroupStats(role) { - const groupUsers = leaderboard.value.filter(user => user.roles.includes(role)) - return { - count: groupUsers.length, - totalPoints: groupUsers.reduce((sum, user) => sum + user.points, 0) - } -} - function calculateLeaderboard() { - if (!logsResource.data) return + if (!logsResource.data || !usersList.value) return const pointsMap = {} - // Считаем сумму очков для каждого пользователя logsResource.data.forEach(log => { if (!pointsMap[log.user]) pointsMap[log.user] = 0 pointsMap[log.user] += log.points }) - // Создаем leaderboard только для пользователей с нужными ролями leaderboard.value = Object.keys(pointsMap) .map(user => { const u = usersList.value.find(x => x.name === user) - - // Пропускаем пользователей без данных или без нужных ролей if (!u) return null - // Проверяем есть ли у пользователя нужные роли const hasValidRole = u.roles && u.roles.some(role => roleGroups.some(group => group.role === role) ) if (!hasValidRole) return null - - // Пропускаем пользователей с отрицательными или нулевыми баллами if (pointsMap[user] < 0) return null - // Определяем является ли пользователь школьником const isSchoolchild = u.roles.includes('LMS Schoolchild') - - // Вычисляем бонус только для школьников const bonus = isSchoolchild ? Math.min(Math.floor(pointsMap[user] / 100), 10) : 0 return { @@ -417,7 +327,7 @@ function calculateLeaderboard() { username: u.username, roles: u.roles || [], bonus: bonus, - isSchoolchild: isSchoolchild // Добавляем флаг для удобства + isSchoolchild: isSchoolchild } }) .filter(user => user !== null) @@ -432,8 +342,377 @@ onMounted(async () => { + +/* === HEADER & TABS === */ +.lms-page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 32px; + flex-wrap: wrap; + gap: 20px; +} + +.page-title { + font-size: 28px; + font-weight: 700; + margin: 0; + color: #111827; + letter-spacing: -0.02em; +} + +.lms-tabs-container { + display: inline-flex; + background-color: #f3f4f6; + padding: 4px; + border-radius: 8px; + border: 1px solid #e5e7eb; +} + +.lms-tab-btn { + background: transparent; + border: none; + color: #6b7280; + font-size: 14px; + font-weight: 500; + padding: 8px 20px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.lms-tab-btn:hover { + color: #374151; +} + +.lms-tab-btn.active { + background: #ffffff; + color: #111827; + font-weight: 600; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* === MAIN GRID LAYOUT === */ +.lms-layout-grid { + display: grid; + grid-template-columns: 340px 1fr; + gap: 24px; + align-items: start; +} + +.lms-sidebar { + display: flex; + flex-direction: column; + gap: 24px; +} + +/* === CARDS === */ +.lms-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + overflow: hidden; +} + +.lms-card-body { + padding: 24px; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.card-subtitle { + font-size: 13px; + font-weight: 600; + color: #6b7280; + 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; } + +.card-title { + font-size: 20px; + font-weight: 700; + margin: 0 0 24px 0; + color: #111827; + line-height: 1.3; +} + +.card-link { + text-decoration: none; + color: #111827; + transition: color 0.2s; +} +.card-link:hover { color: #00a9a6; } + +.card-stats { + display: flex; + gap: 32px; + margin-bottom: 20px; + border-bottom: 1px solid #f3f4f6; + padding-bottom: 20px; +} + +.stat-block { + display: flex; + flex-direction: column; +} + +.stat-value { + font-size: 28px; + font-weight: 800; + line-height: 1; + color: #111827; +} + +.stat-label { + font-size: 12px; + color: #6b7280; + 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; } + +.card-footer { + min-height: 20px; +} + +/* === TABLE === */ +.table-card { + min-height: 500px; +} + +.lms-table-header { + padding: 20px 24px; + background: #ffffff; + border-bottom: 1px solid #e5e7eb; +} + +.lms-table-header h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #111827; + display: flex; + align-items: center; + gap: 12px; +} + +.count-badge { + background: #f3f4f6; + color: #4b5563; + padding: 2px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 500; +} + +/* --- FIX: Настройки скролла для длинной таблицы --- */ +.table-responsive { + overflow-x: auto; + overflow-y: auto; + max-height: 650px; /* Ограничиваем высоту, чтобы таблица не уезжала вниз */ +} + +/* Кастомный красивый скроллбар */ +.table-responsive::-webkit-scrollbar { + width: 6px; + height: 6px; +} +.table-responsive::-webkit-scrollbar-track { + background: transparent; +} +.table-responsive::-webkit-scrollbar-thumb { + background-color: #d1d5db; + border-radius: 10px; +} +.table-responsive::-webkit-scrollbar-thumb:hover { + background-color: #9ca3af; +} + +.lms-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 14px; + table-layout: fixed; +} + +.lms-table th { + background: #f9fafb; + color: #4b5563; + font-weight: 500; + font-size: 13px; + padding: 14px 24px; + /* Заменяем border-bottom на box-shadow для sticky, чтобы не было просветов */ + box-shadow: inset 0 -1px 0 #e5e7eb; + white-space: nowrap; + /* Делаем шапку прилипающей */ + position: sticky; + top: 0; + z-index: 10; +} + +.lms-table td { + padding: 16px 24px; + border-bottom: 1px solid #e5e7eb; + color: #374151; + vertical-align: middle; +} + +.lms-table tr:last-child td { + border-bottom: none; +} + +.lms-table tr:hover { + background-color: #f9fafb; +} + +.lms-table tr.is-current-user { + background-color: #f0fdfa; +} +.lms-table tr.is-current-user td { + border-bottom-color: #ccfbf1; +} + +.medal-emoji { + font-size: 18px; +} + +.user-cell { + display: flex; + align-items: center; + gap: 14px; +} + +.user-avatar { + width: 36px; + height: 36px; + background: #e5e7eb; + color: #4b5563; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 13px; + flex-shrink: 0; +} +.is-current-user .user-avatar { + background: #00a9a6; + color: #ffffff; +} + +.user-link { + color: #111827; + text-decoration: none; + font-weight: 600; +} +.user-link:hover { + color: #00a9a6; + text-decoration: underline; +} + +.truncate-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.bonus-badge { + display: inline-flex; + align-items: center; + background: #ecfdf5; + color: #059669; + padding: 4px 10px; + border-radius: 6px; + font-weight: 600; + font-size: 13px; +} + +.lms-empty-state { + padding: 60px 20px; + text-align: center; + color: #6b7280; + font-size: 15px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.empty-icon { + font-size: 32px; + color: #d1d5db; +} + +/* === RESPONSIVE === */ +@media (max-width: 1024px) { + .lms-layout-grid { + grid-template-columns: 1fr; + } + + .lms-sidebar { + flex-direction: row; + } + + .lms-card { + flex: 1; + } +} + +@media (max-width: 768px) { + .lms-page-container { + padding: 16px; + } + + .lms-sidebar { + flex-direction: column; + } + + .lms-tabs-container { + 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; + } +} + \ No newline at end of file