feat: добавил отображение аватарки
This commit is contained in:
1 parent
34e450815b
commit
038f0d89a6
22 files changed
+598
-28
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user