From 9e31d45492413134225ed1050c03359c153721f3 Mon Sep 17 00:00:00 2001 From: joylessorchid Date: Sat, 21 Mar 2026 09:19:20 +0300 Subject: [PATCH] feat: redesign leaderboard with podium, progress bars, colored avatars Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/LeaderBoard.vue | 1440 +++++++++++++++++++--------- 1 file changed, 963 insertions(+), 477 deletions(-) diff --git a/frontend/src/pages/LeaderBoard.vue b/frontend/src/pages/LeaderBoard.vue index c62cd270..648e0e6a 100644 --- a/frontend/src/pages/LeaderBoard.vue +++ b/frontend/src/pages/LeaderBoard.vue @@ -1,212 +1,306 @@ - -
- - - - - - - - - - - - - - - - - - - - -
#{{ __('Пользователь') }}{{ __('Всего') }}
{{ index + 1 }} -
-
{{ user.full_name?.charAt(0).toUpperCase() || 'U' }}
- - {{ user.full_name }} - -
-
0
-
- -
- -

{{ __('В этой категории пока нет участников.') }}

-
+ + + +
+ + +
+
+ + +
+
+ {{ getUserInitial(currentLeaderboard[1]) }} +
+ + {{ currentLeaderboard[1].full_name }} + +
{{ currentLeaderboard[1].points }}
+
+ 🥈 + 2 +
+
+ + +
+
👑
+
+ {{ getUserInitial(currentLeaderboard[0]) }} +
+ + {{ currentLeaderboard[0].full_name }} + +
{{ currentLeaderboard[0].points }}
+
+ 🥇 + 1 +
+
+ + +
+
+ {{ getUserInitial(currentLeaderboard[2]) }} +
+ + {{ currentLeaderboard[2].full_name }} + +
{{ currentLeaderboard[2].points }}
+
+ 🥉 + 3 +
+
+ +
+
+ + +
+
+
+

{{ activeGroupLabel }}

+ {{ currentLeaderboard.length }} +
+ + {{ __('Баллы МПГУ') }} + +
+ +
+ + + + + + + + +
+ 🎉 {{ __('Все участники показаны на подиуме выше') }} +
+ + +
+
🏆
+

{{ __('Нет участников') }}

+

{{ __('В этой категории пока нет участников.') }}

+
+ +
+
+ +
@@ -215,7 +309,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' +import { Trophy, User } from 'lucide-vue-next' const store = usersStore() const { userResource, allUsers } = store @@ -223,546 +317,938 @@ const { userResource, allUsers } = store const roleGroups = [ { role: 'LMS Student', label: __('Студенты') }, { role: 'Course Creator', label: __('Преподаватели') }, - { role: 'LMS Schoolchild', label: __('Школьники') } + { role: 'LMS Schoolchild', label: __('Школьники') }, ] function getUserRoleGroup(userRoles) { if (!userRoles) return 'LMS Student' - const validRole = userRoles.find(role => - roleGroups.some(group => group.role === role) - ) + const validRole = userRoles.find(role => roleGroups.some(g => g.role === role)) return validRole || 'LMS Student' } const activeGroup = ref('LMS Student') - const usersList = computed(() => allUsers.data || []) const logsResource = createResource({ - url: "frappe.client.get_list", + url: 'frappe.client.get_list', params: { - doctype: "Energy Point Log", - fields: ["user", "points"], - limit_page_length: 10000 + doctype: 'Energy Point Log', + fields: ['user', 'points'], + limit_page_length: 10000, }, auto: false, - onError: (err) => console.error("Error loading Energy Point Log:", err) + onError: (err) => console.error('Error loading Energy Point Log:', err), }) const leaderboard = ref([]) -const currentUser = computed(() => { - return userResource.data || {} -}) +const currentUser = computed(() => userResource.data || {}) 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' +} + +const AVATAR_COLORS = [ + '#4F7EFF', '#FF6B6B', '#51CF66', '#845EF7', '#FFA94D', + '#20C997', '#F06595', '#339AF0', '#CC5DE8', '#94D82D', + '#FF922B', '#22B8CF', '#F783AC', '#74C0FC', '#69DB7C', +] + +function getUserColor(name) { + if (!name) return AVATAR_COLORS[0] + let hash = 0 + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash) + } + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] } watch( [() => logsResource.data, () => allUsers.data], - () => { - if (allUsers.data) { - calculateLeaderboard() - } - }, - { deep: true } + () => { if (allUsers.data) calculateLeaderboard() }, + { deep: true }, ) watch([currentUser, usersList], () => { if (currentUser.value.roles && usersList.value.length > 0) { - const userGroup = getUserRoleGroup(currentUser.value.roles) - activeGroup.value = userGroup + activeGroup.value = getUserRoleGroup(currentUser.value.roles) } }) const activeGroupLabel = computed(() => { - const group = roleGroups.find(g => g.role === activeGroup.value) - return group ? group.label : '' + return roleGroups.find(g => g.role === activeGroup.value)?.label || '' }) -const currentLeaderboard = computed(() => { - return leaderboard.value - .filter(user => user.roles.includes(activeGroup.value)) - .slice(0, 50) -}) +const currentLeaderboard = computed(() => + leaderboard.value + .filter(u => u.roles.includes(activeGroup.value)) + .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)) + .filter(u => u.roles?.some(r => r === activeGroup.value)) .slice(0, 50) }) -const topUserInGroup = computed(() => { - return currentLeaderboard.value[0] -}) +const topUserInGroup = computed(() => currentLeaderboard.value[0]) const currentUserPosition = computed(() => { - const userInLeaderboard = currentLeaderboard.value.findIndex( - user => user.user === currentUser.value.name - ) - return userInLeaderboard !== -1 ? userInLeaderboard + 1 : '-' + const idx = currentLeaderboard.value.findIndex(u => u.user === currentUser.value.name) + return idx !== -1 ? idx + 1 : '-' }) const currentUserPoints = computed(() => { - const userEntry = leaderboard.value.find(user => user.user === currentUser.value.name) - return userEntry ? userEntry.points : 0 + return leaderboard.value.find(u => u.user === currentUser.value.name)?.points || 0 }) const pointsToNext = computed(() => { - const userIndex = currentLeaderboard.value.findIndex( - user => user.user === currentUser.value.name - ) - if (userIndex === -1 || userIndex === 0) return 0 - const currentPoints = currentLeaderboard.value[userIndex].points - const nextUserPoints = currentLeaderboard.value[userIndex - 1].points - return nextUserPoints - currentPoints + 1 + const idx = currentLeaderboard.value.findIndex(u => u.user === currentUser.value.name) + if (idx <= 0) return 0 + return currentLeaderboard.value[idx - 1].points - currentLeaderboard.value[idx].points + 1 }) const nextPosition = computed(() => { - const userIndex = currentLeaderboard.value.findIndex( - user => user.user === currentUser.value.name - ) - return userIndex > 0 ? userIndex : 1 + const idx = currentLeaderboard.value.findIndex(u => u.user === currentUser.value.name) + return idx > 0 ? idx : 1 }) +const progressPercent = computed(() => { + const idx = currentLeaderboard.value.findIndex(u => u.user === currentUser.value.name) + if (idx <= 0) return 100 + const myPts = currentLeaderboard.value[idx].points + const nextPts = currentLeaderboard.value[idx - 1].points + const prevPts = idx + 1 < currentLeaderboard.value.length + ? currentLeaderboard.value[idx + 1].points + : 0 + const range = nextPts - prevPts + if (range <= 0) return 100 + return Math.min(95, Math.max(5, ((myPts - prevPts) / range) * 100)) +}) + +const positionClass = computed(() => { + if (currentUserPosition.value === 1) return 'lb-your-gold' + if (currentUserPosition.value === 2) return 'lb-your-silver' + if (currentUserPosition.value === 3) return 'lb-your-bronze' + return '' +}) + +function getProgressWidth(points) { + const maxPts = currentLeaderboard.value[0]?.points || 1 + return Math.max(4, (points / maxPts) * 100) +} + function calculateLeaderboard() { if (!usersList.value) return - - const pointsMap = {} - + const map = {} if (logsResource.data) { logsResource.data.forEach(log => { - if (!pointsMap[log.user]) pointsMap[log.user] = 0 - pointsMap[log.user] += log.points + if (!map[log.user]) map[log.user] = 0 + map[log.user] += log.points }) } - - leaderboard.value = Object.keys(pointsMap) + leaderboard.value = Object.keys(map) .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 hasRole = u.roles?.some(r => roleGroups.some(g => g.role === r)) + if (!hasRole || map[user] < 0) return null const isSchoolchild = u.roles.includes('LMS Schoolchild') - const bonus = isSchoolchild ? Math.min(Math.floor(pointsMap[user] / 100), 10) : 0 - return { user, - points: pointsMap[user], + points: map[user], full_name: u.full_name, username: u.username, roles: u.roles || [], - bonus: bonus, - isSchoolchild: isSchoolchild + bonus: isSchoolchild ? Math.min(Math.floor(map[user] / 100), 10) : 0, + isSchoolchild, } }) - .filter(user => user !== null) + .filter(Boolean) .sort((a, b) => b.points - a.points) } onMounted(async () => { await allUsers.reload() - // Загружаем Energy Point Log параллельно, ошибка не блокирует отображение logsResource.reload().catch(() => {}) calculateLeaderboard() })