сделал редактирование фотографий у пользователей, сделал методы для карусели фотографий конкурсов
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.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_carousel_photos_router
|
||||
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(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_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(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(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(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
|
||||
|
||||
|
||||
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,
|
||||
QItemLabel,
|
||||
QItem,
|
||||
QImg,
|
||||
QFile
|
||||
} from 'quasar'
|
||||
|
||||
|
||||
@ -58,7 +60,7 @@ app.use(Quasar, {
|
||||
QSeparator, QCardActions, QDialog, QIcon, QSpace,
|
||||
QAvatar, QTooltip, QBanner, QSlideTransition, QToggle,
|
||||
QList, QSpinnerDots, QCarouselSlide, QCarousel,
|
||||
QItemSection, QItemLabel, QItem
|
||||
QItemSection, QItemLabel, QItem, QImg, QFile
|
||||
},
|
||||
directives: {
|
||||
Ripple
|
||||
|
||||
@ -21,7 +21,6 @@
|
||||
<q-page-container class="q-pa-md">
|
||||
<q-tab-panels v-model="tab" animated transition-prev="slide-right" transition-next="slide-left">
|
||||
|
||||
<!-- Пользователи -->
|
||||
<q-tab-panel name="profiles">
|
||||
<div class="violet-card q-pa-md">
|
||||
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
|
||||
@ -44,7 +43,6 @@
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- Команды -->
|
||||
<q-tab-panel name="teams">
|
||||
<div class="violet-card q-pa-md">
|
||||
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
|
||||
@ -68,7 +66,6 @@
|
||||
</q-tab-panel>
|
||||
|
||||
|
||||
<!-- Проекты -->
|
||||
<q-tab-panel name="projects">
|
||||
<div class="violet-card q-pa-md">
|
||||
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
|
||||
@ -91,7 +88,6 @@
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- Конкурсы -->
|
||||
<q-tab-panel name="contests">
|
||||
<div class="violet-card q-pa-md">
|
||||
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
|
||||
@ -117,7 +113,6 @@
|
||||
</q-tab-panels>
|
||||
</q-page-container>
|
||||
|
||||
<!-- Модальное окно редактирования -->
|
||||
<q-dialog v-model="dialogVisible" persistent>
|
||||
<q-card style="min-width: 350px; max-width: 700px;">
|
||||
<q-card-section>
|
||||
@ -143,7 +138,6 @@
|
||||
<q-separator />
|
||||
|
||||
<q-card-section>
|
||||
<!-- Teams -->
|
||||
<template v-if="dialogType === 'teams'">
|
||||
<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" />
|
||||
@ -151,7 +145,6 @@
|
||||
<q-input v-model="dialogData.git_url" label="Git URL" dense clearable class="q-mt-sm" />
|
||||
</template>
|
||||
|
||||
<!-- Profiles -->
|
||||
<template v-else-if="dialogType === 'profiles'">
|
||||
<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" />
|
||||
@ -161,21 +154,73 @@
|
||||
<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.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>
|
||||
|
||||
<!-- Projects -->
|
||||
<template v-else-if="dialogType === 'projects'">
|
||||
<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.repository_url" label="URL репозитория" dense clearable class="q-mt-sm" />
|
||||
<!-- Другие поля проекта -->
|
||||
</template>
|
||||
|
||||
<!-- Contests -->
|
||||
<template v-else-if="dialogType === 'contests'">
|
||||
<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.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.results" label="Результаты" dense clearable class="q-mt-sm" />
|
||||
<q-toggle v-model="dialogData.is_win" label="Победа (Да/Нет)" dense class="q-mt-sm" />
|
||||
@ -230,7 +275,6 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Кнопка выхода / авторизации -->
|
||||
<q-btn
|
||||
:icon="'logout'"
|
||||
class="fixed-bottom-right q-ma-md"
|
||||
@ -245,7 +289,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { Notify, useQuasar } from 'quasar' // Импортируем useQuasar для QDialog
|
||||
|
||||
// Импорты API для управления данными
|
||||
import fetchTeams from '@/api/teams/getTeams.js'
|
||||
import updateTeam from '@/api/teams/updateTeam.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 deleteContestById from '@/api/contests/deleteContest.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')
|
||||
|
||||
// --- Profiles ---
|
||||
@ -321,7 +375,112 @@ const contestColumns = [
|
||||
// Общие состояния для диалогов
|
||||
const dialogVisible = ref(false)
|
||||
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) {
|
||||
dialogType.value = type
|
||||
@ -332,9 +491,20 @@ function openEdit(type, row) {
|
||||
dialogData.value = { title: '', description: '', logo: '', git_url: '' }
|
||||
} else if (type === 'projects') {
|
||||
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
|
||||
|
||||
if (type === 'profiles' && dialogData.value.id) {
|
||||
loadProfilePhotos(dialogData.value.id);
|
||||
} else {
|
||||
profilePhotos.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function onRowClick(event, row) {
|
||||
@ -343,6 +513,9 @@ function onRowClick(event, row) {
|
||||
|
||||
function closeDialog() {
|
||||
dialogVisible.value = false
|
||||
profilePhotos.value = [];
|
||||
newProfilePhotoFile.value = null;
|
||||
uploadingPhoto.value = false;
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
@ -381,12 +554,22 @@ async function saveChanges() {
|
||||
if (idx !== -1) contests.value[idx] = JSON.parse(JSON.stringify(dialogData.value))
|
||||
} else {
|
||||
const newContest = await createContest(dialogData.value)
|
||||
profiles.value.push(newContest)
|
||||
contests.value.push(newContest)
|
||||
}
|
||||
}
|
||||
closeDialog()
|
||||
Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Изменения успешно сохранены!',
|
||||
icon: 'check_circle',
|
||||
});
|
||||
} catch (error) {
|
||||
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() || []
|
||||
} catch (error) {
|
||||
teams.value = []
|
||||
console.error(error.message)
|
||||
Notify.create({
|
||||
type: 'negative',
|
||||
message: `Ошибка загрузки команд: ${error.message}`,
|
||||
icon: 'error',
|
||||
});
|
||||
} finally {
|
||||
loadingTeams.value = false
|
||||
}
|
||||
@ -407,7 +594,11 @@ async function loadData(name) {
|
||||
projects.value = await fetchProjects() || []
|
||||
} catch (error) {
|
||||
projects.value = []
|
||||
console.error(error.message)
|
||||
Notify.create({
|
||||
type: 'negative',
|
||||
message: `Ошибка загрузки проектов: ${error.message}`,
|
||||
icon: 'error',
|
||||
});
|
||||
} finally {
|
||||
loadingProjects.value = false
|
||||
}
|
||||
@ -416,8 +607,12 @@ async function loadData(name) {
|
||||
try {
|
||||
profiles.value = await fetchProfiles() || []
|
||||
} catch (error) {
|
||||
projects.value = []
|
||||
console.error(error.message)
|
||||
profiles.value = []
|
||||
Notify.create({
|
||||
type: 'negative',
|
||||
message: `Ошибка загрузки профилей: ${error.message}`,
|
||||
icon: 'error',
|
||||
});
|
||||
} finally {
|
||||
loadingProfiles.value = false
|
||||
}
|
||||
@ -427,7 +622,11 @@ async function loadData(name) {
|
||||
contests.value = await fetchContests() || []
|
||||
} catch (error) {
|
||||
contests.value = []
|
||||
console.error(error.message)
|
||||
Notify.create({
|
||||
type: 'negative',
|
||||
message: `Ошибка загрузки конкурсов: ${error.message}`,
|
||||
icon: 'error',
|
||||
});
|
||||
} finally {
|
||||
loadingContests.value = false
|
||||
}
|
||||
@ -436,6 +635,20 @@ async function loadData(name) {
|
||||
|
||||
async function deleteItem() {
|
||||
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 {
|
||||
if (dialogType.value === 'teams') {
|
||||
await deleteTeamById(dialogData.value.id)
|
||||
@ -451,9 +664,20 @@ async function deleteItem() {
|
||||
contests.value = contests.value.filter(c => c.id !== dialogData.value.id)
|
||||
}
|
||||
closeDialog()
|
||||
Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Элемент успешно удален!',
|
||||
icon: 'check_circle',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении:', error.message)
|
||||
Notify.create({
|
||||
type: 'negative',
|
||||
message: `Ошибка при удалении: ${error.message}`,
|
||||
icon: 'error',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createHandler() {
|
||||
@ -476,30 +700,11 @@ onMounted(() => {
|
||||
watch(tab, (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>
|
||||
|
||||
<style scoped>
|
||||
.bg-violet-strong {
|
||||
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.violet-card {
|
||||
@ -507,4 +712,14 @@ const handleAuthAction = () => {
|
||||
background: #ede9fe;
|
||||
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>
|
||||
Loading…
x
Reference in New Issue
Block a user