сделал редактирование фотографий у пользователей, сделал методы для карусели фотографий конкурсов
This commit is contained in:
parent
c7533fe87c
commit
a884065680
32
API/app/application/contest_carousel_photos_repository.py
Normal file
32
API/app/application/contest_carousel_photos_repository.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.models import ContestCarouselPhoto
|
||||||
|
|
||||||
|
|
||||||
|
class ContestCarouselPhotosRepository:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_by_id(self, photo_id: int) -> Optional[ContestCarouselPhoto]:
|
||||||
|
stmt = select(ContestCarouselPhoto).filter_by(id=photo_id)
|
||||||
|
result = await self.db.execute(stmt)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def get_by_contest_id(self, contest_id: int) -> Sequence[ContestCarouselPhoto]:
|
||||||
|
stmt = select(ContestCarouselPhoto).filter_by(contest_id=contest_id)
|
||||||
|
result = await self.db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def create(self, photo: ContestCarouselPhoto) -> ContestCarouselPhoto:
|
||||||
|
self.db.add(photo)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(photo)
|
||||||
|
return photo
|
||||||
|
|
||||||
|
async def delete(self, photo: ContestCarouselPhoto) -> ContestCarouselPhoto:
|
||||||
|
await self.db.delete(photo)
|
||||||
|
await self.db.commit()
|
||||||
|
return photo
|
||||||
67
API/app/contollers/contest_carousel_photos_router.py
Normal file
67
API/app/contollers/contest_carousel_photos_router.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
from fastapi import Depends, File, UploadFile, APIRouter
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.responses import FileResponse
|
||||||
|
|
||||||
|
from app.database.session import get_db
|
||||||
|
from app.domain.entities.contest_carousel_photo import ContestCarouselPhotoEntity
|
||||||
|
from app.infrastructure.contest_carousel_photos_service import ContestCarouselPhotosService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/contests/{contest_id}/",
|
||||||
|
response_model=list[ContestCarouselPhotoEntity],
|
||||||
|
summary="Get all carousel photos metadata for a contest",
|
||||||
|
description="Returns metadata of all carousel photos for the given contest_id",
|
||||||
|
)
|
||||||
|
async def get_photos_by_contest_id(
|
||||||
|
contest_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = ContestCarouselPhotosService(db)
|
||||||
|
return await service.get_photos_by_contest_id(contest_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{photo_id}/file",
|
||||||
|
response_class=FileResponse,
|
||||||
|
summary="Download carousel photo file by photo ID",
|
||||||
|
description="Returns the image file for the given carousel photo ID",
|
||||||
|
)
|
||||||
|
async def download_photo_file(
|
||||||
|
photo_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = ContestCarouselPhotosService(db)
|
||||||
|
return await service.get_photo_file_by_id(photo_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/contests/{contest_id}/upload",
|
||||||
|
response_model=ContestCarouselPhotoEntity,
|
||||||
|
summary="Upload a new carousel photo for a contest",
|
||||||
|
description="Uploads a new photo file and associates it with the given contest ID",
|
||||||
|
)
|
||||||
|
async def upload_photo(
|
||||||
|
contest_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = ContestCarouselPhotosService(db)
|
||||||
|
return await service.upload_photo(contest_id, file)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{photo_id}/",
|
||||||
|
response_model=ContestCarouselPhotoEntity,
|
||||||
|
summary="Delete a carousel photo by ID",
|
||||||
|
description="Deletes a carousel photo and its file from storage",
|
||||||
|
)
|
||||||
|
async def delete_photo(
|
||||||
|
photo_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
|
||||||
|
service = ContestCarouselPhotosService(db)
|
||||||
|
|
||||||
|
return await service.delete_photo(photo_id)
|
||||||
9
API/app/domain/entities/contest_carousel_photo.py
Normal file
9
API/app/domain/entities/contest_carousel_photo.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ContestCarouselPhotoEntity(BaseModel):
|
||||||
|
id: Optional[int] = None
|
||||||
|
file_path: str
|
||||||
|
number: int
|
||||||
|
contest_id: int
|
||||||
112
API/app/infrastructure/contest_carousel_photos_service.py
Normal file
112
API/app/infrastructure/contest_carousel_photos_service.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
import magic
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.responses import FileResponse
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from app.application.contest_carousel_photos_repository import ContestCarouselPhotosRepository
|
||||||
|
from app.domain.entities.contest_carousel_photo import ContestCarouselPhotoEntity
|
||||||
|
from app.domain.models import ContestCarouselPhoto
|
||||||
|
|
||||||
|
|
||||||
|
class ContestCarouselPhotosService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.contest_carousel_photos_repository = ContestCarouselPhotosRepository(db)
|
||||||
|
|
||||||
|
async def get_photo_file_by_id(self, photo_id: int) -> FileResponse:
|
||||||
|
photo = await self.contest_carousel_photos_repository.get_by_id(photo_id)
|
||||||
|
|
||||||
|
if not photo:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo not found")
|
||||||
|
|
||||||
|
if not os.path.exists(photo.file_path):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found on disk")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
photo.file_path,
|
||||||
|
media_type=self.get_media_type(photo.file_path), # Use file_path to infer type
|
||||||
|
filename=os.path.basename(photo.file_path), # Extract filename from path
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_photos_by_contest_id(self, contest_id: int) -> list[ContestCarouselPhotoEntity]:
|
||||||
|
photos = await self.contest_carousel_photos_repository.get_by_contest_id(contest_id)
|
||||||
|
return [
|
||||||
|
self.model_to_entity(photo)
|
||||||
|
for photo in photos
|
||||||
|
]
|
||||||
|
|
||||||
|
async def upload_photo(self, contest_id: int, file: UploadFile): # Removed 'user: User' for simplicity, add if needed
|
||||||
|
self.validate_file_type(file)
|
||||||
|
|
||||||
|
filename = self.generate_filename(file)
|
||||||
|
file_path = await self.save_file(file, filename=filename)
|
||||||
|
|
||||||
|
photo = ContestCarouselPhoto(
|
||||||
|
file_path=file_path,
|
||||||
|
number=0,
|
||||||
|
contest_id=contest_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.model_to_entity(
|
||||||
|
await self.contest_carousel_photos_repository.create(photo)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_photo(self, photo_id: int):
|
||||||
|
photo = await self.contest_carousel_photos_repository.get_by_id(photo_id)
|
||||||
|
if not photo:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo not found")
|
||||||
|
|
||||||
|
if os.path.exists(photo.file_path):
|
||||||
|
os.remove(photo.file_path)
|
||||||
|
|
||||||
|
return self.model_to_entity(
|
||||||
|
await self.contest_carousel_photos_repository.delete(photo)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save_file(self, file: UploadFile, filename: str, upload_dir: str = "uploads/contest_carousel_photos") -> str:
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
file_path = os.path.join(upload_dir, filename)
|
||||||
|
|
||||||
|
async with aiofiles.open(file_path, 'wb') as out_file:
|
||||||
|
content = await file.read()
|
||||||
|
await out_file.write(content)
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_file_type(file: UploadFile):
|
||||||
|
contents = file.file.read(1024)
|
||||||
|
file.file.seek(0)
|
||||||
|
mime = magic.Magic(mime=True)
|
||||||
|
file_type = mime.from_buffer(contents)
|
||||||
|
|
||||||
|
if file_type not in ["image/jpeg", "image/png"]:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid file type. Only JPEG and PNG images are allowed.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_filename(file: UploadFile):
|
||||||
|
original_filename = secure_filename(file.filename)
|
||||||
|
return f"{uuid.uuid4()}_{original_filename}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def model_to_entity(photo_model: ContestCarouselPhoto) -> ContestCarouselPhotoEntity:
|
||||||
|
return ContestCarouselPhotoEntity(
|
||||||
|
id=photo_model.id,
|
||||||
|
file_path=photo_model.file_path,
|
||||||
|
number=photo_model.number,
|
||||||
|
contest_id=photo_model.contest_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_media_type(file_path: str) -> str:
|
||||||
|
extension = file_path.split('.')[-1].lower()
|
||||||
|
if extension in ['jpeg', 'jpg']:
|
||||||
|
return "image/jpeg"
|
||||||
|
elif extension == 'png':
|
||||||
|
return "image/png"
|
||||||
|
else:
|
||||||
|
return "application/octet-stream"
|
||||||
@ -12,6 +12,7 @@ from app.contollers.rss_router import router as rss_router
|
|||||||
from app.contollers.teams_router import router as team_router
|
from app.contollers.teams_router import router as team_router
|
||||||
from app.contollers.users_router import router as users_router
|
from app.contollers.users_router import router as users_router
|
||||||
from app.contollers.contests_router import router as contest_router
|
from app.contollers.contests_router import router as contest_router
|
||||||
|
from app.contollers.contests_router import router as contest_carousel_photos_router
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
|
|
||||||
@ -30,13 +31,16 @@ def start_app():
|
|||||||
api_app.include_router(profile_photos_router, prefix=f'{settings.PREFIX}/profile_photos', tags=['profile_photos'])
|
api_app.include_router(profile_photos_router, prefix=f'{settings.PREFIX}/profile_photos', tags=['profile_photos'])
|
||||||
api_app.include_router(profiles_router, prefix=f'{settings.PREFIX}/profiles', tags=['profiles'])
|
api_app.include_router(profiles_router, prefix=f'{settings.PREFIX}/profiles', tags=['profiles'])
|
||||||
api_app.include_router(project_files_router, prefix=f'{settings.PREFIX}/project_files', tags=['project_files'])
|
api_app.include_router(project_files_router, prefix=f'{settings.PREFIX}/project_files', tags=['project_files'])
|
||||||
api_app.include_router(project_members_router, prefix=f'{settings.PREFIX}/project_members', tags=['project_members'])
|
api_app.include_router(project_members_router, prefix=f'{settings.PREFIX}/project_members',
|
||||||
|
tags=['project_members'])
|
||||||
api_app.include_router(projects_router, prefix=f'{settings.PREFIX}/projects', tags=['projects'])
|
api_app.include_router(projects_router, prefix=f'{settings.PREFIX}/projects', tags=['projects'])
|
||||||
api_app.include_router(register_router, prefix=f'{settings.PREFIX}/register', tags=['register'])
|
api_app.include_router(register_router, prefix=f'{settings.PREFIX}/register', tags=['register'])
|
||||||
api_app.include_router(rss_router, prefix=f'{settings.PREFIX}/rss', tags=['rss_router'])
|
api_app.include_router(rss_router, prefix=f'{settings.PREFIX}/rss', tags=['rss_router'])
|
||||||
api_app.include_router(team_router, prefix=f'{settings.PREFIX}/teams', tags=['teams'])
|
api_app.include_router(team_router, prefix=f'{settings.PREFIX}/teams', tags=['teams'])
|
||||||
api_app.include_router(users_router, prefix=f'{settings.PREFIX}/users', tags=['users'])
|
api_app.include_router(users_router, prefix=f'{settings.PREFIX}/users', tags=['users'])
|
||||||
api_app.include_router(contest_router,prefix=f'{settings.PREFIX}/contests', tags=['contests'])
|
api_app.include_router(contest_router, prefix=f'{settings.PREFIX}/contests', tags=['contests'])
|
||||||
|
api_app.include_router(contest_carousel_photos_router, prefix=f'{settings.PREFIX}/contest_carousel_photos',
|
||||||
|
tags=['contest_carousel_photos'])
|
||||||
|
|
||||||
return api_app
|
return api_app
|
||||||
|
|
||||||
|
|||||||
27
WEB/src/api/profiles/profile_photos/deletePhoto.js
Normal file
27
WEB/src/api/profiles/profile_photos/deletePhoto.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import CONFIG from '@/core/config.js'
|
||||||
|
|
||||||
|
|
||||||
|
const deletePhoto = async (photoId) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
|
||||||
|
const response = await axios.delete(
|
||||||
|
`${CONFIG.BASE_URL}/profile_photos/${photoId}/`,
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error.response?.data?.detail || error.message
|
||||||
|
console.error(`Ошибка удаления фотографии с ID ${photoId}:`, errorMessage)
|
||||||
|
throw new Error(`Не удалось удалить фотографию: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default deletePhoto
|
||||||
28
WEB/src/api/profiles/profile_photos/downloadPhotoFile.js
Normal file
28
WEB/src/api/profiles/profile_photos/downloadPhotoFile.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import CONFIG from '@/core/config.js'
|
||||||
|
|
||||||
|
|
||||||
|
const downloadPhotoFile = async (photoId) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
|
||||||
|
const response = await axios.get(
|
||||||
|
`${CONFIG.BASE_URL}/profile_photos/${photoId}/file`,
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
responseType: 'blob'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error.response?.data?.detail || error.message
|
||||||
|
console.error(`Ошибка загрузки файла фотографии с ID ${photoId}:`, errorMessage)
|
||||||
|
throw new Error(`Не удалось загрузить файл фотографии: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default downloadPhotoFile
|
||||||
28
WEB/src/api/profiles/profile_photos/getPhotoFileById.js
Normal file
28
WEB/src/api/profiles/profile_photos/getPhotoFileById.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import CONFIG from '@/core/config.js'
|
||||||
|
|
||||||
|
|
||||||
|
const getPhotosByProfileId = async (profileId) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
|
||||||
|
const response = await axios.get(
|
||||||
|
`${CONFIG.BASE_URL}/profile_photos/profiles/${profileId}/`,
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
// Улучшенная обработка ошибок для ясности
|
||||||
|
const errorMessage = error.response?.data?.detail || error.message
|
||||||
|
console.error(`Ошибка получения фотографий для профиля ${profileId}:`, errorMessage)
|
||||||
|
throw new Error(`Не удалось загрузить фотографии: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getPhotosByProfileId
|
||||||
31
WEB/src/api/profiles/profile_photos/uploadProfilePhoto.js
Normal file
31
WEB/src/api/profiles/profile_photos/uploadProfilePhoto.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import CONFIG from '@/core/config.js'
|
||||||
|
|
||||||
|
|
||||||
|
const uploadProfilePhoto = async (profileId, file) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`${CONFIG.BASE_URL}/profile_photos/profiles/${profileId}/upload`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error.response?.data?.detail || error.message
|
||||||
|
console.error(`Ошибка загрузки фотографии для профиля ${profileId}:`, errorMessage)
|
||||||
|
throw new Error(`Не удалось загрузить фотографию: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default uploadProfilePhoto
|
||||||
@ -39,6 +39,8 @@ import {
|
|||||||
QItemSection,
|
QItemSection,
|
||||||
QItemLabel,
|
QItemLabel,
|
||||||
QItem,
|
QItem,
|
||||||
|
QImg,
|
||||||
|
QFile
|
||||||
} from 'quasar'
|
} from 'quasar'
|
||||||
|
|
||||||
|
|
||||||
@ -58,7 +60,7 @@ app.use(Quasar, {
|
|||||||
QSeparator, QCardActions, QDialog, QIcon, QSpace,
|
QSeparator, QCardActions, QDialog, QIcon, QSpace,
|
||||||
QAvatar, QTooltip, QBanner, QSlideTransition, QToggle,
|
QAvatar, QTooltip, QBanner, QSlideTransition, QToggle,
|
||||||
QList, QSpinnerDots, QCarouselSlide, QCarousel,
|
QList, QSpinnerDots, QCarouselSlide, QCarousel,
|
||||||
QItemSection, QItemLabel, QItem
|
QItemSection, QItemLabel, QItem, QImg, QFile
|
||||||
},
|
},
|
||||||
directives: {
|
directives: {
|
||||||
Ripple
|
Ripple
|
||||||
|
|||||||
@ -21,7 +21,6 @@
|
|||||||
<q-page-container class="q-pa-md">
|
<q-page-container class="q-pa-md">
|
||||||
<q-tab-panels v-model="tab" animated transition-prev="slide-right" transition-next="slide-left">
|
<q-tab-panels v-model="tab" animated transition-prev="slide-right" transition-next="slide-left">
|
||||||
|
|
||||||
<!-- Пользователи -->
|
|
||||||
<q-tab-panel name="profiles">
|
<q-tab-panel name="profiles">
|
||||||
<div class="violet-card q-pa-md">
|
<div class="violet-card q-pa-md">
|
||||||
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
|
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
|
||||||
@ -44,7 +43,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|
||||||
<!-- Команды -->
|
|
||||||
<q-tab-panel name="teams">
|
<q-tab-panel name="teams">
|
||||||
<div class="violet-card q-pa-md">
|
<div class="violet-card q-pa-md">
|
||||||
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
|
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
|
||||||
@ -68,7 +66,6 @@
|
|||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|
||||||
|
|
||||||
<!-- Проекты -->
|
|
||||||
<q-tab-panel name="projects">
|
<q-tab-panel name="projects">
|
||||||
<div class="violet-card q-pa-md">
|
<div class="violet-card q-pa-md">
|
||||||
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
|
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
|
||||||
@ -91,7 +88,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|
||||||
<!-- Конкурсы -->
|
|
||||||
<q-tab-panel name="contests">
|
<q-tab-panel name="contests">
|
||||||
<div class="violet-card q-pa-md">
|
<div class="violet-card q-pa-md">
|
||||||
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
|
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
|
||||||
@ -117,7 +113,6 @@
|
|||||||
</q-tab-panels>
|
</q-tab-panels>
|
||||||
</q-page-container>
|
</q-page-container>
|
||||||
|
|
||||||
<!-- Модальное окно редактирования -->
|
|
||||||
<q-dialog v-model="dialogVisible" persistent>
|
<q-dialog v-model="dialogVisible" persistent>
|
||||||
<q-card style="min-width: 350px; max-width: 700px;">
|
<q-card style="min-width: 350px; max-width: 700px;">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
@ -143,7 +138,6 @@
|
|||||||
<q-separator />
|
<q-separator />
|
||||||
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<!-- Teams -->
|
|
||||||
<template v-if="dialogType === 'teams'">
|
<template v-if="dialogType === 'teams'">
|
||||||
<q-input v-model="dialogData.title" label="Название команды" dense autofocus clearable />
|
<q-input v-model="dialogData.title" label="Название команды" dense autofocus clearable />
|
||||||
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm" />
|
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm" />
|
||||||
@ -151,7 +145,6 @@
|
|||||||
<q-input v-model="dialogData.git_url" label="Git URL" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.git_url" label="Git URL" dense clearable class="q-mt-sm" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Profiles -->
|
|
||||||
<template v-else-if="dialogType === 'profiles'">
|
<template v-else-if="dialogType === 'profiles'">
|
||||||
<q-input v-model="dialogData.first_name" label="Имя" dense autofocus clearable />
|
<q-input v-model="dialogData.first_name" label="Имя" dense autofocus clearable />
|
||||||
<q-input v-model="dialogData.last_name" label="Фамилия" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.last_name" label="Фамилия" dense clearable class="q-mt-sm" />
|
||||||
@ -161,21 +154,73 @@
|
|||||||
<q-input v-model="dialogData.phone" label="Телефон" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.phone" label="Телефон" dense clearable class="q-mt-sm" />
|
||||||
<q-input v-model="dialogData.role_id" label="Роль" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.role_id" label="Роль" dense clearable class="q-mt-sm" />
|
||||||
<q-input v-model="dialogData.team_id" label="Команда" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.team_id" label="Команда" dense clearable class="q-mt-sm" />
|
||||||
|
|
||||||
|
<q-separator class="q-my-md" />
|
||||||
|
<div class="text-h6 q-mb-sm">Фотографии профиля</div>
|
||||||
|
|
||||||
|
<div v-if="loadingProfilePhotos" class="text-center q-py-md">
|
||||||
|
<q-spinner-dots color="primary" size="2em" />
|
||||||
|
<div>Загрузка фотографий...</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="profilePhotos.length === 0" class="text-center q-py-md text-grey-7">
|
||||||
|
Пока нет фотографий.
|
||||||
|
</div>
|
||||||
|
<div v-else class="q-gutter-md q-mb-md row wrap justify-center">
|
||||||
|
<q-card v-for="photo in profilePhotos" :key="photo.id" class="col-auto" style="width: 120px; height: 120px; position: relative;">
|
||||||
|
<q-img
|
||||||
|
:src="getPhotoUrl(photo.id)"
|
||||||
|
alt="Profile Photo"
|
||||||
|
style="width: 100%; height: 100%; object-fit: cover;"
|
||||||
|
>
|
||||||
|
<div class="absolute-bottom text-right q-pa-xs">
|
||||||
|
<q-btn
|
||||||
|
icon="delete"
|
||||||
|
color="negative"
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
size="sm"
|
||||||
|
@click="confirmDeletePhoto(photo.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-img>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-file
|
||||||
|
v-model="newProfilePhotoFile"
|
||||||
|
label="Выберите фото для загрузки"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
accept="image/*"
|
||||||
|
@update:model-value="handleNewPhotoSelected"
|
||||||
|
class="q-mt-sm"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon v-if="newProfilePhotoFile" name="check" color="positive" />
|
||||||
|
<q-icon name="photo" />
|
||||||
|
</template>
|
||||||
|
</q-file>
|
||||||
|
<q-btn
|
||||||
|
v-if="newProfilePhotoFile"
|
||||||
|
label="Загрузить фото"
|
||||||
|
color="primary"
|
||||||
|
class="q-mt-sm full-width"
|
||||||
|
@click="uploadNewPhoto"
|
||||||
|
:loading="uploadingPhoto"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Projects -->
|
|
||||||
<template v-else-if="dialogType === 'projects'">
|
<template v-else-if="dialogType === 'projects'">
|
||||||
<q-input v-model="dialogData.title" label="Название проекта" dense autofocus clearable />
|
<q-input v-model="dialogData.title" label="Название проекта" dense autofocus clearable />
|
||||||
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm" />
|
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm" />
|
||||||
<q-input v-model="dialogData.repository_url" label="URL репозитория" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.repository_url" label="URL репозитория" dense clearable class="q-mt-sm" />
|
||||||
<!-- Другие поля проекта -->
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Contests -->
|
|
||||||
<template v-else-if="dialogType === 'contests'">
|
<template v-else-if="dialogType === 'contests'">
|
||||||
<q-input v-model="dialogData.title" label="Название конкурса" dense autofocus clearable />
|
<q-input v-model="dialogData.title" label="Название конкурса" dense autofocus clearable />
|
||||||
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm" />
|
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm" />
|
||||||
<q-input v-model="dialogData.web_url" label="URL сайта" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.web_url" label="URL сайта" dense clearable class="q-mt-sm" />
|
||||||
<q-input v-model="dialogData.photo" label="Фото" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.photo" label="Фото" dense clearable class="q-mt-sm" />
|
||||||
<q-input v-model="dialogData.results" label="Результаты" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.results" label="Результаты" dense clearable class="q-mt-sm" />
|
||||||
<q-toggle v-model="dialogData.is_win" label="Победа (Да/Нет)" dense class="q-mt-sm" />
|
<q-toggle v-model="dialogData.is_win" label="Победа (Да/Нет)" dense class="q-mt-sm" />
|
||||||
@ -230,7 +275,6 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<!-- Кнопка выхода / авторизации -->
|
|
||||||
<q-btn
|
<q-btn
|
||||||
:icon="'logout'"
|
:icon="'logout'"
|
||||||
class="fixed-bottom-right q-ma-md"
|
class="fixed-bottom-right q-ma-md"
|
||||||
@ -245,7 +289,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { Notify, useQuasar } from 'quasar' // Импортируем useQuasar для QDialog
|
||||||
|
|
||||||
|
// Импорты API для управления данными
|
||||||
import fetchTeams from '@/api/teams/getTeams.js'
|
import fetchTeams from '@/api/teams/getTeams.js'
|
||||||
import updateTeam from '@/api/teams/updateTeam.js'
|
import updateTeam from '@/api/teams/updateTeam.js'
|
||||||
import deleteTeamById from '@/api/teams/deleteTeam.js'
|
import deleteTeamById from '@/api/teams/deleteTeam.js'
|
||||||
@ -265,10 +311,18 @@ import fetchContests from '@/api/contests/getContests.js'
|
|||||||
import updateContest from '@/api/contests/updateContest.js'
|
import updateContest from '@/api/contests/updateContest.js'
|
||||||
import deleteContestById from '@/api/contests/deleteContest.js'
|
import deleteContestById from '@/api/contests/deleteContest.js'
|
||||||
import createContest from '@/api/contests/createContest.js'
|
import createContest from '@/api/contests/createContest.js'
|
||||||
import {Notify} from "quasar";
|
|
||||||
import router from "@/router/index.js";
|
|
||||||
|
|
||||||
// Текущая вкладка — 'teams' или 'projects'
|
import router from "@/router/index.js"
|
||||||
|
import CONFIG from '@/core/config.js' // Убедитесь, что у вас есть этот файл конфигурации
|
||||||
|
|
||||||
|
// --- Импорты для работы с фотографиями профиля ---
|
||||||
|
import getPhotosByProfileId from '@/api/profiles/profile_photos/getPhotoFileById.js'
|
||||||
|
import uploadProfilePhoto from '@/api/profiles/profile_photos/uploadProfilePhoto.js'
|
||||||
|
import deletePhoto from '@/api/profiles/profile_photos/deletePhoto.js'
|
||||||
|
|
||||||
|
const $q = useQuasar()
|
||||||
|
|
||||||
|
// Текущая вкладка
|
||||||
const tab = ref('profiles')
|
const tab = ref('profiles')
|
||||||
|
|
||||||
// --- Profiles ---
|
// --- Profiles ---
|
||||||
@ -321,7 +375,112 @@ const contestColumns = [
|
|||||||
// Общие состояния для диалогов
|
// Общие состояния для диалогов
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const dialogData = ref({})
|
const dialogData = ref({})
|
||||||
const dialogType = ref('') // 'teams' или 'projects'
|
const dialogType = ref('')
|
||||||
|
|
||||||
|
// --- Состояния для фотографий профиля ---
|
||||||
|
const profilePhotos = ref([])
|
||||||
|
const loadingProfilePhotos = ref(false)
|
||||||
|
const newProfilePhotoFile = ref(null)
|
||||||
|
const uploadingPhoto = ref(false)
|
||||||
|
|
||||||
|
// Функция для получения URL фото
|
||||||
|
const getPhotoUrl = (photoId) => {
|
||||||
|
return `${CONFIG.BASE_URL}/profile_photos/${photoId}/file`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка фотографий профиля при открытии диалога для профиля
|
||||||
|
async function loadProfilePhotos(profileId) {
|
||||||
|
loadingProfilePhotos.value = true;
|
||||||
|
try {
|
||||||
|
profilePhotos.value = await getPhotosByProfileId(profileId);
|
||||||
|
} catch (error) {
|
||||||
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: `Ошибка загрузки фотографий профиля: ${error.message}`,
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
|
profilePhotos.value = [];
|
||||||
|
} finally {
|
||||||
|
loadingProfilePhotos.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик выбора нового файла
|
||||||
|
function handleNewPhotoSelected(file) {
|
||||||
|
newProfilePhotoFile.value = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка новой фотографии
|
||||||
|
async function uploadNewPhoto() {
|
||||||
|
if (!newProfilePhotoFile.value || !dialogData.value.id) {
|
||||||
|
Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Выберите файл и убедитесь, что профиль выбран.',
|
||||||
|
icon: 'warning',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadingPhoto.value = true;
|
||||||
|
try {
|
||||||
|
const uploadedPhoto = await uploadProfilePhoto(dialogData.value.id, newProfilePhotoFile.value);
|
||||||
|
// Добавляем новую фото в список
|
||||||
|
profilePhotos.value.push(uploadedPhoto);
|
||||||
|
newProfilePhotoFile.value = null; // Сбрасываем выбранный файл
|
||||||
|
Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Фотография успешно загружена!',
|
||||||
|
icon: 'check_circle',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: `Ошибка загрузки фотографии: ${error.message}`,
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
uploadingPhoto.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подтверждение и удаление фотографии
|
||||||
|
function confirmDeletePhoto(photoId) {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Подтверждение удаления',
|
||||||
|
message: 'Вы уверены, что хотите удалить эту фотографию?',
|
||||||
|
cancel: true,
|
||||||
|
persistent: true,
|
||||||
|
ok: {
|
||||||
|
label: 'Удалить',
|
||||||
|
color: 'negative'
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
label: 'Отмена',
|
||||||
|
color: 'primary'
|
||||||
|
}
|
||||||
|
}).onOk(async () => {
|
||||||
|
await deleteExistingPhoto(photoId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteExistingPhoto(photoId) {
|
||||||
|
try {
|
||||||
|
await deletePhoto(photoId);
|
||||||
|
profilePhotos.value = profilePhotos.value.filter(p => p.id !== photoId);
|
||||||
|
Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Фотография успешно удалена!',
|
||||||
|
icon: 'check_circle',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: `Ошибка удаления фотографии: ${error.message}`,
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function openEdit(type, row) {
|
function openEdit(type, row) {
|
||||||
dialogType.value = type
|
dialogType.value = type
|
||||||
@ -332,9 +491,20 @@ function openEdit(type, row) {
|
|||||||
dialogData.value = { title: '', description: '', logo: '', git_url: '' }
|
dialogData.value = { title: '', description: '', logo: '', git_url: '' }
|
||||||
} else if (type === 'projects') {
|
} else if (type === 'projects') {
|
||||||
dialogData.value = { name: '', summary: '', deadline: '' }
|
dialogData.value = { name: '', summary: '', deadline: '' }
|
||||||
|
} else if (type === 'profiles') {
|
||||||
|
dialogData.value = { first_name: '', last_name: '', patronymic: '', birthday: '', email: '', phone: '', role_id: null, team_id: null }
|
||||||
|
profilePhotos.value = [];
|
||||||
|
} else if (type === 'contests') {
|
||||||
|
dialogData.value = { title: '', description: '', web_url: '', photo: '', results: '', is_win: false, project_id: null, status_id: null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
|
|
||||||
|
if (type === 'profiles' && dialogData.value.id) {
|
||||||
|
loadProfilePhotos(dialogData.value.id);
|
||||||
|
} else {
|
||||||
|
profilePhotos.value = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRowClick(event, row) {
|
function onRowClick(event, row) {
|
||||||
@ -343,6 +513,9 @@ function onRowClick(event, row) {
|
|||||||
|
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
|
profilePhotos.value = [];
|
||||||
|
newProfilePhotoFile.value = null;
|
||||||
|
uploadingPhoto.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveChanges() {
|
async function saveChanges() {
|
||||||
@ -381,12 +554,22 @@ async function saveChanges() {
|
|||||||
if (idx !== -1) contests.value[idx] = JSON.parse(JSON.stringify(dialogData.value))
|
if (idx !== -1) contests.value[idx] = JSON.parse(JSON.stringify(dialogData.value))
|
||||||
} else {
|
} else {
|
||||||
const newContest = await createContest(dialogData.value)
|
const newContest = await createContest(dialogData.value)
|
||||||
profiles.value.push(newContest)
|
contests.value.push(newContest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
closeDialog()
|
closeDialog()
|
||||||
|
Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Изменения успешно сохранены!',
|
||||||
|
icon: 'check_circle',
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при сохранении:', error.message)
|
console.error('Ошибка при сохранении:', error.message)
|
||||||
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: `Ошибка при сохранении: ${error.message}`,
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -397,7 +580,11 @@ async function loadData(name) {
|
|||||||
teams.value = await fetchTeams() || []
|
teams.value = await fetchTeams() || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
teams.value = []
|
teams.value = []
|
||||||
console.error(error.message)
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: `Ошибка загрузки команд: ${error.message}`,
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
loadingTeams.value = false
|
loadingTeams.value = false
|
||||||
}
|
}
|
||||||
@ -407,7 +594,11 @@ async function loadData(name) {
|
|||||||
projects.value = await fetchProjects() || []
|
projects.value = await fetchProjects() || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
projects.value = []
|
projects.value = []
|
||||||
console.error(error.message)
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: `Ошибка загрузки проектов: ${error.message}`,
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
loadingProjects.value = false
|
loadingProjects.value = false
|
||||||
}
|
}
|
||||||
@ -416,8 +607,12 @@ async function loadData(name) {
|
|||||||
try {
|
try {
|
||||||
profiles.value = await fetchProfiles() || []
|
profiles.value = await fetchProfiles() || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
projects.value = []
|
profiles.value = []
|
||||||
console.error(error.message)
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: `Ошибка загрузки профилей: ${error.message}`,
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
loadingProfiles.value = false
|
loadingProfiles.value = false
|
||||||
}
|
}
|
||||||
@ -427,7 +622,11 @@ async function loadData(name) {
|
|||||||
contests.value = await fetchContests() || []
|
contests.value = await fetchContests() || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
contests.value = []
|
contests.value = []
|
||||||
console.error(error.message)
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: `Ошибка загрузки конкурсов: ${error.message}`,
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
loadingContests.value = false
|
loadingContests.value = false
|
||||||
}
|
}
|
||||||
@ -436,6 +635,20 @@ async function loadData(name) {
|
|||||||
|
|
||||||
async function deleteItem() {
|
async function deleteItem() {
|
||||||
if (!dialogData.value.id) return
|
if (!dialogData.value.id) return
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Подтверждение удаления',
|
||||||
|
message: `Вы уверены, что хотите удалить ${dialogType.value === 'profiles' ? 'пользователя' : dialogType.value}?`,
|
||||||
|
cancel: true,
|
||||||
|
persistent: true,
|
||||||
|
ok: {
|
||||||
|
label: 'Удалить',
|
||||||
|
color: 'negative'
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
label: 'Отмена',
|
||||||
|
color: 'primary'
|
||||||
|
}
|
||||||
|
}).onOk(async () => {
|
||||||
try {
|
try {
|
||||||
if (dialogType.value === 'teams') {
|
if (dialogType.value === 'teams') {
|
||||||
await deleteTeamById(dialogData.value.id)
|
await deleteTeamById(dialogData.value.id)
|
||||||
@ -451,9 +664,20 @@ async function deleteItem() {
|
|||||||
contests.value = contests.value.filter(c => c.id !== dialogData.value.id)
|
contests.value = contests.value.filter(c => c.id !== dialogData.value.id)
|
||||||
}
|
}
|
||||||
closeDialog()
|
closeDialog()
|
||||||
|
Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Элемент успешно удален!',
|
||||||
|
icon: 'check_circle',
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при удалении:', error.message)
|
console.error('Ошибка при удалении:', error.message)
|
||||||
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: `Ошибка при удалении: ${error.message}`,
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHandler() {
|
function createHandler() {
|
||||||
@ -476,30 +700,11 @@ onMounted(() => {
|
|||||||
watch(tab, (newTab) => {
|
watch(tab, (newTab) => {
|
||||||
loadData(newTab)
|
loadData(newTab)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleAuthAction = () => {
|
|
||||||
const isAuthenticated = ref(!!localStorage.getItem('access_token'))
|
|
||||||
if (isAuthenticated.value) {
|
|
||||||
localStorage.removeItem('access_token')
|
|
||||||
localStorage.removeItem('user_id')
|
|
||||||
isAuthenticated.value = false
|
|
||||||
|
|
||||||
|
|
||||||
Notify.create({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Выход успешно осуществлен',
|
|
||||||
icon: 'check_circle',
|
|
||||||
})
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.bg-violet-strong {
|
.bg-violet-strong {
|
||||||
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
|
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.violet-card {
|
.violet-card {
|
||||||
@ -507,4 +712,14 @@ const handleAuthAction = () => {
|
|||||||
background: #ede9fe;
|
background: #ede9fe;
|
||||||
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
|
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.q-table {
|
||||||
|
background: #ede9fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-table th {
|
||||||
|
background-color: #7c3aed;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
Loading…
x
Reference in New Issue
Block a user