feat: добавил отображение аватарки
gitea-push-docker.yml (push) Invalid workflow file
Backend CI / build-and-test (push) Successful in 3m34s
Frontend CI / build-and-check (push) Failing after 21s

This commit is contained in:
serega404 committed 2026-10-01 00:17:16 +03:00
1 parent 34e450815b
commit 038f0d89a6
22 files changed
+598 -28

No files matched your search

+1
View File
@@ -64,6 +64,7 @@ export const lecturesApi = {
export const usersApi = {
me: () => apiRequest<CurrentUserDto>('/users/me'),
myPhoto: () => apiRequestBlob('/users/me/photo', { headers: { Accept: 'image/*' } }),
updateMe: (payload: { displayName?: string | null; avatarUrl?: string | null }) =>
apiRequest<CurrentUserDto>('/users/me', {
method: 'PUT',
+17 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { ref, computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
@@ -17,6 +17,8 @@ const isCoinDialogOpen = ref(false)
const isLevelDialogOpen = ref(false)
const isSlotsDialogOpen = ref(false)
const profileMenuRef = ref<HTMLElement | null>(null)
const avatarFailed = ref(false)
watch(() => auth.avatarUrl, () => { avatarFailed.value = false })
const unreadCount = computed(() => userStore.unreadCount())
const showStudentChips = computed(() => auth.user?.activeRole === 'student')
@@ -192,7 +194,14 @@ onBeforeUnmount(() => {
@click="toggleProfileMenu"
@keydown.esc.prevent="closeProfileMenu"
>
<AppIcon class="avatar-icon" icon="user" :size="18" />
<img
v-if="auth.avatarUrl && !avatarFailed"
class="avatar-photo"
:src="auth.avatarUrl"
alt=""
@error="avatarFailed = true"
/>
<AppIcon v-else class="avatar-icon" icon="user" :size="18" />
<span class="avatar-name" v-if="auth.user">{{ formatUserName(auth.user.name) }}</span>
</button>
@@ -445,6 +454,12 @@ onBeforeUnmount(() => {
.avatar-icon {
color: var(--color-text-secondary);
}
.avatar-photo {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
}
.avatar-name {
font-size: 13px;
font-weight: 600;
+27 -1
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { authApi } from '@/api'
import { authApi, usersApi } from '@/api'
import { mapApiUser } from '@/api/mappers'
import { buildApiUrl, setApiAccessToken } from '@/api/client'
import type { AuthResponse } from '@/api/types'
@@ -37,10 +37,26 @@ export const useAuthStore = defineStore('auth', () => {
const initialized = ref(false)
const error = ref<string | null>(null)
const accessToken = ref<string | null>(localStorage.getItem(TOKEN_STORAGE_KEY))
const profilePhotoUrl = ref<string | null>(null)
let photoRequestId = 0
if (accessToken.value) setApiAccessToken(accessToken.value)
const isAuthenticated = computed(() => Boolean(user.value && accessToken.value))
const avatarUrl = computed(() => user.value?.avatar || profilePhotoUrl.value)
async function loadProfilePhoto() {
const requestId = ++photoRequestId
if (!user.value || user.value.avatar) return
try {
const photo = await usersApi.myPhoto()
if (!photo.type.startsWith('image/') || photo.size === 0 || requestId !== photoRequestId) return
if (profilePhotoUrl.value) URL.revokeObjectURL(profilePhotoUrl.value)
profilePhotoUrl.value = URL.createObjectURL(photo)
} catch {
// The Microsoft photo is optional; retain the existing placeholder.
}
}
async function hydrateFromResponse(response: AuthResponse) {
accessToken.value = response.accessToken
@@ -58,12 +74,14 @@ export const useAuthStore = defineStore('auth', () => {
await hydrateFromResponse(refreshed)
const me = await authApi.me()
user.value = restoreStoredActiveRole(mapApiUser(me))
void loadProfilePhoto()
return true
} catch (refreshError) {
if (accessToken.value) {
try {
const me = await authApi.me()
user.value = restoreStoredActiveRole(mapApiUser(me))
void loadProfilePhoto()
return true
} catch {
// Fall through to local cleanup below.
@@ -90,6 +108,9 @@ export const useAuthStore = defineStore('auth', () => {
const redirectUri = getAbsoluteAuthReturnUrl()
const response = await authApi.loginMicrosoft(code, redirectUri)
await hydrateFromResponse(response)
const me = await authApi.me()
user.value = restoreStoredActiveRole(mapApiUser(me))
void loadProfilePhoto()
initialized.value = true
return true
} catch (err) {
@@ -110,6 +131,7 @@ export const useAuthStore = defineStore('auth', () => {
setApiAccessToken(token)
const me = await authApi.me()
user.value = restoreStoredActiveRole(mapApiUser(me))
void loadProfilePhoto()
initialized.value = true
return true
} catch (err) {
@@ -136,6 +158,9 @@ export const useAuthStore = defineStore('auth', () => {
}
function clearSession() {
photoRequestId++
if (profilePhotoUrl.value) URL.revokeObjectURL(profilePhotoUrl.value)
profilePhotoUrl.value = null
user.value = null
accessToken.value = null
localStorage.removeItem(TOKEN_STORAGE_KEY)
@@ -158,6 +183,7 @@ export const useAuthStore = defineStore('auth', () => {
user,
accessToken,
isAuthenticated,
avatarUrl,
loading,
initialized,
error,
+23 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue'
@@ -11,6 +11,8 @@ import AppIcon from '@/components/ui/AppIcon.vue'
const auth = useAuthStore()
const userStore = useUserStore()
const user = computed(() => auth.user!)
const avatarFailed = ref(false)
watch(() => auth.avatarUrl, () => { avatarFailed.value = false })
const userMetaLine = computed(() => {
const parts: string[] = []
@@ -110,7 +112,15 @@ onMounted(() => {
<div class="profile-grid">
<GlassCard>
<div class="user-info">
<div class="avatar">👤</div>
<div class="avatar">
<img
v-if="auth.avatarUrl && !avatarFailed"
:src="auth.avatarUrl"
:alt="`Фото ${user.name}`"
@error="avatarFailed = true"
/>
<span v-else aria-hidden="true">👤</span>
</div>
<div>
<div class="name">{{ user.name }}</div>
<div class="email">{{ user.email }}</div>
@@ -245,10 +255,20 @@ onMounted(() => {
margin-bottom: 16px;
}
.avatar {
width: 68px;
height: 68px;
flex: 0 0 68px;
display: grid;
place-items: center;
overflow: hidden;
font-size: 38px;
background: rgba(34, 197, 94, 0.15);
border-radius: 16px;
padding: 12px;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.name {
font-weight: 700;
+49
View File
@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test'
import { mockApi } from './support/mockApi'
import { mockCurrentUser } from '../mocks/fixtures'
const photo = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><circle cx="10" cy="10" r="10" fill="green"/></svg>'
test('shows Microsoft photo in profile and topbar, including mobile layout', async ({ page }) => {
await mockApi(page, { authenticated: true })
await page.route('**/api/v1/users/me/photo', route => route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: photo,
}))
await page.goto('/profile')
await expect(page.locator('.user-info img')).toBeVisible()
await expect(page.locator('.avatar-photo')).toBeVisible()
await page.setViewportSize({ width: 390, height: 844 })
await expect(page.locator('.user-info img')).toBeVisible()
await expect(page.locator('.avatar-photo')).toBeVisible()
})
test('keeps custom avatar without requesting Microsoft photo', async ({ page }) => {
await mockApi(page, { authenticated: true })
let photoRequests = 0
await page.route('**/api/v1/auth/me', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...mockCurrentUser, avatarUrl: `data:image/svg+xml,${encodeURIComponent(photo)}` }),
}))
await page.route('**/api/v1/users/me/photo', route => {
photoRequests++
return route.fulfill({ status: 404 })
})
await page.goto('/profile')
await expect(page.locator('.user-info img')).toBeVisible()
expect(photoRequests).toBe(0)
})
test('uses placeholder when Microsoft photo is unavailable', async ({ page }) => {
await mockApi(page, { authenticated: true })
await page.route('**/api/v1/users/me/photo', route => route.fulfill({ status: 404 }))
await page.goto('/profile')
await expect(page.locator('.user-info .avatar span')).toBeVisible()
await expect(page.locator('.avatar-icon')).toBeVisible()
})