сделал шаблон для участников

This commit is contained in:
Мельников Данил 2025-06-10 18:25:47 +05:00
parent 49cc307f2c
commit 49b88ba505
25 changed files with 1209 additions and 101 deletions

View File

@ -10,6 +10,11 @@ class ProjectMembersRepository:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db self.db = db
async def get_all(self) -> Sequence[ProjectMember]:
stmt = select(ProjectMember)
result = await self.db.execute(stmt)
return result.scalars().all()
async def get_by_id(self, project_member_id: int) -> Optional[ProjectMember]: async def get_by_id(self, project_member_id: int) -> Optional[ProjectMember]:
stmt = select(ProjectMember).filter_by(id=project_member_id) stmt = select(ProjectMember).filter_by(id=project_member_id)
result = await self.db.execute(stmt) result = await self.db.execute(stmt)
@ -43,5 +48,7 @@ class ProjectMembersRepository:
for project_member in project_members: for project_member in project_members:
await self.db.delete(project_member) await self.db.delete(project_member)
async def delete_member(self, project_member: ProjectMember) -> ProjectMember:
await self.db.delete(project_member)
await self.db.commit() await self.db.commit()
return project_members return project_member

View File

@ -11,6 +11,19 @@ from app.infrastructure.project_members_service import ProjectMembersService
router = APIRouter() router = APIRouter()
@router.get(
'/',
response_model=list[ProjectMemberEntity],
summary='Get all project members',
description='Returns all project members',
)
async def get_all_project_members(
db: AsyncSession = Depends(get_db),
):
project_members_service = ProjectMembersService(db)
return await project_members_service.get_all_project_members()
@router.get( @router.get(
'/by-project/{project_id}/', '/by-project/{project_id}/',
response_model=list[ProjectMemberEntity], response_model=list[ProjectMemberEntity],
@ -84,3 +97,17 @@ async def delete_project_members(
service = ProjectMembersService(db) service = ProjectMembersService(db)
await service.delete_project_members_by_project_id(project_id) await service.delete_project_members_by_project_id(project_id)
return {"message": "All project members have been successfully deleted."} return {"message": "All project members have been successfully deleted."}
@router.delete(
'/member/{member_id}/',
summary='Delete a single project member by ID',
description='Deletes a specific project member by their unique ID',
)
async def delete_single_project_member(
member_id: int,
db: AsyncSession = Depends(get_db),
user=Depends(require_admin),
):
service = ProjectMembersService(db)
deleted_member = await service.delete_project_member_by_id(member_id)
return deleted_member

View File

@ -1,26 +0,0 @@
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_db
from app.domain.entities.register import RegisterEntity
from app.domain.entities.user import UserEntity
from app.infrastructure.users_service import UsersService
router = APIRouter()
@router.post(
'/',
response_model=Optional[UserEntity],
summary='User Registration',
description='Performs user registration in the system',
)
async def register_user(
user_data: RegisterEntity,
db: AsyncSession = Depends(get_db)
):
users_service = UsersService(db)
user = await users_service.register_user(user_data)
return user

View File

@ -21,7 +21,6 @@ router = APIRouter()
) )
async def get_all_teams( async def get_all_teams(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
): ):
teams_service = TeamsService(db) teams_service = TeamsService(db)
return await teams_service.get_all_teams() return await teams_service.get_all_teams()
@ -33,7 +32,7 @@ async def get_all_teams(
summary='Get active team', summary='Get active team',
description='Returns active team', description='Returns active team',
) )
async def get_all_teams( async def get_active_team(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
teams_service = TeamsService(db) teams_service = TeamsService(db)
@ -61,7 +60,7 @@ async def create_team(
summary='Make team active', summary='Make team active',
description='Makes team active', description='Makes team active',
) )
async def update_team( async def set_active_team(
team_id: int, team_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user=Depends(require_admin), user=Depends(require_admin),

View File

@ -1,15 +1,29 @@
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Body
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_db from app.database.session import get_db
from app.domain.entities.register import RegisterEntity
from app.domain.entities.user import UserEntity from app.domain.entities.user import UserEntity
from app.infrastructure.dependencies import get_current_user from app.infrastructure.dependencies import get_current_user
from app.infrastructure.users_service import UsersService from app.infrastructure.users_service import UsersService
router = APIRouter() router = APIRouter()
@router.post(
'/',
response_model=Optional[UserEntity],
summary='User Registration',
description='Performs user registration in the system',
)
async def register_user(
user_data: RegisterEntity,
db: AsyncSession = Depends(get_db)
):
users_service = UsersService(db)
user = await users_service.register_user(user_data)
return user
@router.put( @router.put(
'/{user_id}/', '/{user_id}/',
@ -17,9 +31,9 @@ router = APIRouter()
summary='Change user password', summary='Change user password',
description='Change user password', description='Change user password',
) )
async def create_user( async def change_user_password(
user_id: int, user_id: int,
new_password: str, new_password: str = Body(..., embed=True), # <--- ИЗМЕНЕНИЕ ЗДЕСЬ! Явно указывает, что в теле запроса
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user=Depends(get_current_user), user=Depends(get_current_user),
): ):

View File

@ -16,6 +16,13 @@ class ProjectMembersService:
self.projects_repository = ProjectsRepository(db) self.projects_repository = ProjectsRepository(db)
self.profiles_repository = ProfilesRepository(db) self.profiles_repository = ProfilesRepository(db)
async def get_all_project_members(self) -> list[ProjectMemberEntity]:
project_members = await self.project_members_repository.get_all()
return [
self.model_to_entity(project_member)
for project_member in project_members
]
async def get_project_members_by_project_id(self, project_id: int) -> list[ProjectMemberEntity]: async def get_project_members_by_project_id(self, project_id: int) -> list[ProjectMemberEntity]:
project_members = await self.project_members_repository.get_by_project_id(project_id) project_members = await self.project_members_repository.get_by_project_id(project_id)
return [ return [
@ -107,6 +114,17 @@ class ProjectMembersService:
for project_member in project_members for project_member in project_members
] ]
async def delete_project_member_by_id(self, member_id: int) -> Optional[ProjectMemberEntity]:
member = await self.project_members_repository.get_by_id(member_id)
if not member:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Project member with ID {member_id} not found'
)
await self.project_members_repository.delete_member(member)
return self.model_to_entity(member)
@staticmethod @staticmethod
def model_to_entity(project: ProjectMember) -> ProjectMemberEntity: def model_to_entity(project: ProjectMember) -> ProjectMemberEntity:
return ProjectMemberEntity( return ProjectMemberEntity(

View File

@ -7,7 +7,6 @@ from app.contollers.profiles_router import router as profiles_router
from app.contollers.project_files_router import router as project_files_router from app.contollers.project_files_router import router as project_files_router
from app.contollers.project_members_router import router as project_members_router from app.contollers.project_members_router import router as project_members_router
from app.contollers.projects_router import router as projects_router from app.contollers.projects_router import router as projects_router
from app.contollers.register_router import router as register_router
from app.contollers.rss_router import router as rss_router 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
@ -35,7 +34,6 @@ def start_app():
api_app.include_router(project_members_router, prefix=f'{settings.PREFIX}/project_members', api_app.include_router(project_members_router, prefix=f'{settings.PREFIX}/project_members',
tags=['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(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'])

View File

@ -11,11 +11,12 @@ const loginUser = async (loginData) => {
return { access_token, user_id }; return { access_token, user_id };
} catch (error) { } catch (error) {
if (error.response?.status === 401) { if (error.response) {
throw new Error("Неверное имя пользователя или пароль"); if (error.response.status === 403) {
throw new Error("Неверное имя пользователя или пароль");
}
} }
throw new Error(error.message || "Произошла неизвестная ошибка при входе.");
throw new Error(error.message);
} }
}; };

View File

@ -0,0 +1,40 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const createProjectMember = async (projectMemberData) => {
try {
const token = localStorage.getItem("access_token");
if (!projectMemberData.project_id) {
throw new Error("Отсутствует ID проекта для создания участника проекта.");
}
const requestPayload = [projectMemberData];
const response = await axios.post(
`${CONFIG.BASE_URL}/project_members/${projectMemberData.project_id}/`,
requestPayload,
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
return response.data; // Бэкенд возвращает список, но мы ожидаем один созданный объект
} catch (error) {
if (error.response?.status === 401) {
throw new Error("Нет доступа для создания участников проекта (401): Требуется авторизация.");
} else if (error.response?.status === 403) {
throw new Error("Доступ запрещён (403): Недостаточно прав (требуются права администратора).");
} else if (error.response?.status === 400) {
throw new Error(`Ошибка запроса: ${error.response.data.detail || error.message}`);
}
throw new Error(`Ошибка при создании участников проекта: ${error.message}`);
}
};
export default createProjectMember;

View File

@ -0,0 +1,30 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const deleteProjectMember = async (projectId) => {
try {
const token = localStorage.getItem("access_token");
const response = await axios.delete(
`${CONFIG.BASE_URL}/project_members/${projectId}/`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
return response.data;
} catch (error) {
if (error.response?.status === 401) {
throw new Error("Нет доступа для удаления участников проекта (401): Требуется авторизация.");
} else if (error.response?.status === 403) {
throw new Error("Доступ запрещён (403): Недостаточно прав.");
} else if (error.response?.status === 405) {
throw new Error("Ошибка метода (405): Сервер не разрешает DELETE для этого ресурса. Проверьте конфигурацию API и соответствие URL.");
} else if (error.response?.status === 400 || error.response?.status === 422) {
throw new Error(`Ошибка запроса: ${error.response.data.detail || error.message}`);
}
throw new Error(`Ошибка при удалении участников проекта: ${error.message}`);
}
};
export default deleteProjectMember;

View File

@ -0,0 +1,30 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const deleteSingleProjectMember = async (memberId) => {
try {
const token = localStorage.getItem("access_token");
const response = await axios.delete(
`${CONFIG.BASE_URL}/project_members/member/${memberId}/`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
return response.data;
} catch (error) {
if (error.response?.status === 401) {
throw new Error("Нет доступа для удаления участника проекта (401): Требуется авторизация.");
} else if (error.response?.status === 403) {
throw new Error("Доступ запрещён (403): Недостаточно прав.");
} else if (error.response?.status === 404) { // Добавлено 404, если участник не найден
throw new Error("Участник проекта не найден (404).");
} else if (error.response?.status === 400 || error.response?.status === 422) {
throw new Error(`Ошибка запроса: ${error.response.data.detail || error.message}`);
}
throw new Error(`Ошибка при удалении участника проекта: ${error.message}`);
}
};
export default deleteSingleProjectMember;

View File

@ -0,0 +1,26 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const getAllProjectMembers = async () => {
try {
const token = localStorage.getItem("access_token"); // Получаем токен из localStorage
const response = await axios.get(`${CONFIG.BASE_URL}/project_members/`, { // Запрос к эндпоинту членов проекта
headers: {
Authorization: `Bearer ${token}`, // Отправляем токен для авторизации
},
});
return response.data; // Возвращаем полученные данные
} catch (error) {
// Обработка ошибок в зависимости от статуса ответа
if (error.response?.status === 401) {
throw new Error("Нет доступа к членам проекта (401). Требуется авторизация.");
} else if (error.response?.status === 403) {
throw new new Error("Доступ запрещён (403). У вас нет прав для просмотра членов проекта.");
}
// В случае других ошибок, выбрасываем стандартную ошибку
throw new Error(error.message || "Неизвестная ошибка при получении членов проекта.");
}
};
export default getAllProjectMembers;

View File

@ -0,0 +1,24 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const getProjectMembersByProject = async (projectId) => {
try {
const token = localStorage.getItem("access_token");
const response = await axios.get(`${CONFIG.BASE_URL}/project_members/by-project/${projectId}/`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
return response.data;
} catch (error) {
if (error.response?.status === 401) {
throw new Error("Нет доступа к участникам проекта (401): Требуется авторизация.");
} else if (error.response?.status === 403) {
throw new Error("Доступ запрещён (403): Недостаточно прав.");
}
throw new Error(`Ошибка при получении участников проекта: ${error.message}`);
}
};
export default getProjectMembersByProject;

View File

@ -0,0 +1,24 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const getProjectMembersByProfile = async (profileId) => {
try {
const token = localStorage.getItem("access_token");
const response = await axios.get(`${CONFIG.BASE_URL}/project_members/by-profile/${profileId}/`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
return response.data;
} catch (error) {
if (error.response?.status === 401) {
throw new Error("Нет доступа к данным профиля (401): Требуется авторизация.");
} else if (error.response?.status === 403) {
throw new Error("Доступ запрещён (403): Недостаточно прав.");
}
throw new Error(`Ошибка при получении участников проекта по профилю: ${error.message}`);
}
};
export default getProjectMembersByProfile;

View File

@ -0,0 +1,52 @@
// @/api/project_members/updateProjectMember.js
import axios from "axios";
import CONFIG from "@/core/config.js";
// Эта функция теперь будет принимать project_id и ПОЛНЫЙ СПИСОК projectMembersData
// для данного проекта.
const updateProjectMember = async (projectId, projectMembersData) => { // Изменено: теперь принимает project_id и список
try {
const token = localStorage.getItem("access_token");
// Убедимся, что project_id присутствует
if (!projectId) {
throw new Error("Отсутствует ID проекта для обновления списка участников.");
}
// Убедимся, что projectMembersData - это массив
if (!Array.isArray(projectMembersData)) {
throw new Error("Данные для обновления участников проекта должны быть массивом.");
}
// Бэкенд ожидает, что каждый объект в списке также будет содержать project_id,
// и что этот project_id будет совпадать с projectId из URL.
const payloadWithProjectIds = projectMembersData.map(member => ({
...member,
project_id: projectId // Убедимся, что project_id установлен в каждом объекте
}));
const response = await axios.put(
`${CONFIG.BASE_URL}/project_members/${projectId}/`, // URL теперь использует project_id
payloadWithProjectIds, // Отправляем полный список с project_id в каждом элементе
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
return response.data;
} catch (error) {
if (error.response?.status === 401) {
throw new Error("Нет доступа для обновления участников проекта (401): Требуется авторизация.");
} else if (error.response?.status === 403) {
throw new Error("Доступ запрещён (403): Недостаточно прав.");
} else if (error.response?.status === 400 || error.response?.status === 422) {
console.error("Backend Error Detail:", error.response.data); // Выводим детали ошибки бэкенда
throw new Error(`Ошибка запроса при обновлении: ${error.response.data.detail || error.message}`);
}
throw new Error(`Ошибка при обновлении участников проекта: ${error.message}`);
}
};
export default updateProjectMember;

View File

@ -0,0 +1,27 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const downloadTeamPhotoFile = async (teamId) => {
try {
const token = localStorage.getItem('access_token')
const response = await axios.get(
`${CONFIG.BASE_URL}/teams/${teamId}/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 ${teamId}:`, errorMessage)
throw new Error(`Не удалось загрузить файл логотипа команды: ${errorMessage}`)
}
}
export default downloadTeamPhotoFile

View File

@ -0,0 +1,28 @@
import axios from 'axios';
import CONFIG from '@/core/config.js';
const getActiveTeam = async () => {
try {
const token = localStorage.getItem('access_token');
const response = await axios.get(
`${CONFIG.BASE_URL}/teams/active/`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
return response.data;
} catch (error) {
if (error.response?.status === 401) {
throw new Error('Нет доступа для получения активной команды (401): Требуется авторизация.');
} else if (error.response?.status === 403) {
throw new Error('Доступ запрещён (403): Недостаточно прав.');
} else if (error.response?.status === 404) {
return null;
}
throw new Error(`Ошибка при получении активной команды: ${error.message}`);
}
};
export default getActiveTeam;

View File

@ -0,0 +1,32 @@
import axios from 'axios';
import CONFIG from '@/core/config.js';
const setActiveTeam = async (teamId) => {
try {
const token = localStorage.getItem('access_token');
const response = await axios.put(
`${CONFIG.BASE_URL}/teams/${teamId}/set-active/`,
{},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
}
);
return response.data;
} catch (error) {
if (error.response?.status === 401) {
throw new Error('Нет доступа для установки активной команды (401): Требуется авторизация.');
} else if (error.response?.status === 403) {
throw new Error('Доступ запрещён (403): Недостаточно прав (требуются права администратора).');
} else if (error.response?.status === 400) {
throw new Error(`Ошибка запроса: ${error.response.data.detail || error.message}`);
} else if (error.response?.status === 404) {
throw new Error(`Команда с ID ${teamId} не найдена.`);
}
throw new Error(`Ошибка при установке активной команды: ${error.message}`);
}
};
export default setActiveTeam;

View File

@ -0,0 +1,30 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const uploadTeamPhoto = async (teamId, file) => {
try {
const token = localStorage.getItem('access_token')
const formData = new FormData()
formData.append('file', file)
const response = await axios.post(
`${CONFIG.BASE_URL}/teams/${teamId}/upload/`,
formData,
{
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`,
}
}
)
return response.data
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message
console.error(`Ошибка загрузки фотографии для команды ${teamId}:`, errorMessage)
throw new Error(`Не удалось загрузить фотографию команды: ${errorMessage}`)
}
}
export default uploadTeamPhoto

View File

@ -1,14 +1,15 @@
import axios from "axios"; import axios from "axios";
import CONFIG from "@/core/config.js"; import CONFIG from "@/core/config.js";
const changeUserPassword = async (userId, newPasswordData) => { const changeUserPassword = async (userId, newPassword) => {
try { try {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
const response = await axios.patch( const response = await axios.put(
`${CONFIG.BASE_URL}/users/${userId}/password`, `${CONFIG.BASE_URL}/users/${userId}/`,
newPasswordData, { new_password: newPassword },
{ {
headers: { headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
} }
@ -19,6 +20,11 @@ const changeUserPassword = async (userId, newPasswordData) => {
throw new Error(error.response.data.detail); throw new Error(error.response.data.detail);
} else if (error.response?.status === 403) { } else if (error.response?.status === 403) {
throw new Error("Доступ запрещён (403)"); throw new Error("Доступ запрещён (403)");
} else if (error.response?.status === 422) {
const errorMessage = error.response.data.detail ?
error.response.data.detail.map(err => `${err.loc.join('.')} - ${err.msg}`).join('; ') :
'Неизвестная ошибка валидации';
throw new Error(`Ошибка валидации данных (422): ${errorMessage}`);
} }
throw new Error(error.message); throw new Error(error.message);
} }

View File

@ -1,18 +1,23 @@
import axios from "axios"; import axios from 'axios';
import CONFIG from "@/core/config.js"; import CONFIG from '@/core/config.js';
const registerUser = async (userData) => { const registerUser = async (userData) => {
try { try {
const token = localStorage.getItem('access_token');
const response = await axios.post( const response = await axios.post(
`${CONFIG.BASE_URL}/users/register`, `${CONFIG.BASE_URL}/users/`,
userData userData,
{
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
}
}
); );
return response.data; return response.data;
} catch (error) { } catch (error) {
if (error.response?.status === 400) { throw new Error(error.response?.data?.detail || error.message);
throw new Error(error.response.data.detail);
}
throw new Error(error.message);
} }
}; };

View File

@ -40,7 +40,8 @@ import {
QItemLabel, QItemLabel,
QItem, QItem,
QImg, QImg,
QFile QFile,
QSelect
} from 'quasar' } from 'quasar'
@ -60,7 +61,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, QImg, QFile QItemSection, QItemLabel, QItem, QImg, QFile, QSelect
}, },
directives: { directives: {
Ripple Ripple

View File

@ -15,6 +15,7 @@
<q-tab name="teams" label="Команды" /> <q-tab name="teams" label="Команды" />
<q-tab name="projects" label="Проекты" /> <q-tab name="projects" label="Проекты" />
<q-tab name="contests" label="Конкурсы" /> <q-tab name="contests" label="Конкурсы" />
<q-tab name="project_members" label="Участники проектов" />
</q-tabs> </q-tabs>
</q-header> </q-header>
@ -27,7 +28,14 @@
<q-btn <q-btn
label="Создание пользователя" label="Создание пользователя"
color="primary" color="primary"
@click="createHandler" @click="createNewUserHandler"
/>
<q-btn
round
color="primary"
icon="cached"
title="Поменять пароль у пользователя"
@click="changePasswordHandler"
/> />
</div> </div>
<q-table <q-table
@ -110,6 +118,28 @@
</div> </div>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="project_members">
<div class="violet-card q-pa-md">
<div class="q-gutter-sm q-mb-sm row items-center justify-between">
<q-btn
label="Привязать участника к проекту"
color="primary"
@click="createHandler"
/>
</div>
<q-table
title="Участники проектов"
:rows="projectMembers"
:columns="projectMemberColumns"
row-key="id"
@row-click="onRowClick"
:loading="loadingProjectMembers"
dense
flat
/>
</div>
</q-tab-panel>
</q-tab-panels> </q-tab-panels>
</q-page-container> </q-page-container>
@ -121,7 +151,7 @@
Редактирование команды {{ dialogData.title || '' }} Редактирование команды {{ dialogData.title || '' }}
</template> </template>
<template v-else-if="dialogType === 'profiles'"> <template v-else-if="dialogType === 'profiles'">
Редактирование пользователя {{ dialogData.name || dialogData.login || '' }} Редактирование пользователя {{ `${dialogData.first_name} ${dialogData.last_name}` }}
</template> </template>
<template v-else-if="dialogType === 'projects'"> <template v-else-if="dialogType === 'projects'">
Редактирование проекта {{ dialogData.title || '' }} Редактирование проекта {{ dialogData.title || '' }}
@ -129,6 +159,9 @@
<template v-else-if="dialogType === 'contests'"> <template v-else-if="dialogType === 'contests'">
Редактирование конкурса {{ dialogData.title || '' }} Редактирование конкурса {{ dialogData.title || '' }}
</template> </template>
<template v-else-if="dialogType === 'project_members'">
Привязка участника к проекту
</template>
<template v-else> <template v-else>
Редактирование {{ dialogType }} Редактирование {{ dialogType }}
</template> </template>
@ -141,9 +174,59 @@
<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" />
<q-input v-model="dialogData.logo" label="Логотип" dense clearable class="q-mt-sm" />
<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" />
<q-toggle v-model="dialogData.is_active" label="Активна" dense class="q-mt-sm" /> <q-toggle
v-model="dialogData.is_active"
label="Активна"
dense
class="q-mt-sm"
@update:model-value="handleTeamIsActiveToggle"
/>
<q-separator class="q-my-md" />
<div class="text-h6 q-mb-sm">Логотип команды</div>
<div v-if="loadingTeamLogo" class="text-center q-py-md">
<q-spinner-dots color="primary" size="2em" />
<div>Загрузка логотипа...</div>
</div>
<div v-else-if="!dialogData.logoUrl" 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 class="col-auto" style="width: 120px; height: 120px;">
<q-img
:src="dialogData.logoUrl"
alt="Team Logo"
style="width: 100%; height: 100%; object-fit: cover;"
/>
</q-card>
</div>
<q-file
v-model="newTeamLogoFile"
label="Выберите новый логотип"
outlined
dense
clearable
accept="image/*"
@update:model-value="handleNewTeamLogoSelected"
class="q-mt-sm"
:rules="dialogData.id ? [] : [val => !!val || 'Логотип обязателен']"
>
<template v-slot:append>
<q-icon v-if="newTeamLogoFile" name="check" color="positive" />
<q-icon name="photo" />
</template>
</q-file>
<q-btn
v-if="newTeamLogoFile"
label="Загрузить новый логотип"
color="primary"
class="q-mt-sm full-width"
@click="uploadNewTeamLogo"
:loading="uploadingLogo"
/>
</template> </template>
<template v-else-if="dialogType === 'profiles'"> <template v-else-if="dialogType === 'profiles'">
@ -153,8 +236,27 @@
<q-input v-model="dialogData.birthday" label="День рождения" dense clearable class="q-mt-sm" type="date" /> <q-input v-model="dialogData.birthday" label="День рождения" dense clearable class="q-mt-sm" type="date" />
<q-input v-model="dialogData.email" label="Почта" dense clearable class="q-mt-sm" /> <q-input v-model="dialogData.email" 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.phone" label="Телефон" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.role_id" label="Роль" dense clearable class="q-mt-sm" /> <q-select
<q-input v-model="dialogData.team_id" label="Команда" dense clearable class="q-mt-sm" /> v-model="dialogData.role_id"
label="Роль"
dense
clearable
class="q-mt-sm"
:options="[{ label: 'Администратор', value: 1 }, { label: 'Участник', value: 2 }]" emit-value
map-options
/>
<q-select
v-model="dialogData.team_id"
label="Команда"
dense
clearable
class="q-mt-sm"
:options="teams"
option-value="id"
option-label="title"
emit-value
map-options
/>
<q-separator class="q-my-md" /> <q-separator class="q-my-md" />
<div class="text-h6 q-mb-sm">Фотографии профиля</div> <div class="text-h6 q-mb-sm">Фотографии профиля</div>
@ -286,8 +388,27 @@
<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.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" />
<q-input v-model="dialogData.project_id" label="Проект" dense clearable class="q-mt-sm" /> <q-select
<q-input v-model="dialogData.status_id" label="Статус" dense clearable class="q-mt-sm" /> v-model="dialogData.project_id"
label="Проект"
dense
clearable
class="q-mt-sm"
:options="allProjects"
option-value="id"
option-label="title"
emit-value
map-options
/>
<q-select
v-model="dialogData.status_id"
label="Статус"
dense
clearable
class="q-mt-sm"
:options="[{ label: 'Завершен', value: 1 }, { label: 'В процессе', value: 2 }, { label: 'Ожидает начала', value: 3 }]" emit-value
map-options
/>
<q-separator class="q-my-md" /> <q-separator class="q-my-md" />
<div class="text-h6 q-mb-sm">Фотографии карусели конкурса</div> <div class="text-h6 q-mb-sm">Фотографии карусели конкурса</div>
@ -407,6 +528,34 @@
/> />
</template> </template>
<template v-else-if="dialogType === 'project_members'">
<q-input v-model="dialogData.description" label="Описание" dense autofocus clearable type="textarea" />
<q-select
v-model="dialogData.project_id"
label="Проект"
dense
clearable
class="q-mt-sm"
:options="allProjects"
option-value="id"
option-label="title"
emit-value
map-options
/>
<q-select
v-model="dialogData.profile_id"
label="Профиль"
dense
clearable
class="q-mt-sm"
:options="allProfiles"
option-value="id"
:option-label="item => `${item.first_name} ${item.last_name}`"
emit-value
map-options
/>
</template>
<template v-else> <template v-else>
<pre>{{ dialogData }}</pre> <pre>{{ dialogData }}</pre>
</template> </template>
@ -441,10 +590,17 @@
color="negative" color="negative"
@click="deleteItem" @click="deleteItem"
/> />
<q-btn
v-if="dialogType === 'project_members' && dialogData.id"
flat
label="Удалить"
color="negative"
@click="deleteItem"
/>
<q-space /> <q-space />
<q-btn flat label="Закрыть" color="primary" @click="closeDialog" /> <q-btn flat label="Закрыть" color="primary" @click="closeDialog" />
<q-btn <q-btn
v-if="['teams', 'profiles', 'projects', 'contests'].includes(dialogType)" v-if="['teams', 'profiles', 'projects', 'contests', 'project_members'].includes(dialogType)"
flat flat
label="Сохранить" label="Сохранить"
color="primary" color="primary"
@ -454,6 +610,122 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="createUserDialogVisible" persistent>
<q-card style="min-width: 350px; max-width: 700px;">
<q-card-section>
<div class="text-h6">
Создание нового пользователя
</div>
</q-card-section>
<q-separator />
<q-card-section class="q-pt-none">
<q-input v-model="newUserData.first_name" label="Имя" dense autofocus clearable class="q-mt-md" />
<q-input v-model="newUserData.last_name" label="Фамилия" dense clearable class="q-mt-sm" />
<q-input v-model="newUserData.patronymic" label="Отчество" dense clearable class="q-mt-sm" />
<q-input v-model="newUserData.birthday" label="День рождения" dense clearable class="q-mt-sm" type="date" />
<q-input v-model="newUserData.email" label="Почта" dense clearable class="q-mt-sm" />
<q-input v-model="newUserData.phone" label="Телефон" dense clearable class="q-mt-sm" />
<q-select
v-model="newUserData.role_id"
label="Роль"
dense
clearable
class="q-mt-sm"
:options="[{ label: 'Администратор', value: 1 }, { label: 'Участник', value: 2 }]"
emit-value
map-options
/>
<q-select
v-model="newUserData.team_id"
label="Команда"
dense
clearable
class="q-mt-sm"
:options="teams"
option-value="id"
option-label="title"
emit-value
map-options
/>
<q-input v-model="newUserData.login" label="Логин" dense clearable class="q-mt-sm" />
<q-input
v-model="newUserData.password"
label="Пароль"
type="password"
dense
clearable
class="q-mt-sm"
/>
<q-input
v-model="confirmNewUserPassword"
label="Подтвердите пароль"
type="password"
dense
clearable
class="q-mt-sm"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="primary" @click="createUserDialogVisible = false" />
<q-btn flat label="Создать" color="primary" @click="saveNewUser" />
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="changePasswordDialogVisible" persistent>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">
Смена пароля для пользователя
</div>
</q-card-section>
<q-separator />
<q-card-section class="q-pt-none">
<q-select
v-model="selectedUserForPasswordChange"
label="Выберите пользователя"
dense
clearable
class="q-mt-md"
:options="allProfiles"
option-value="id"
:option-label="item => `${item.first_name} ${item.last_name} (${item.email})`"
options-dense
/>
<q-input
v-model="newPasswordField"
label="Новый пароль"
type="password"
dense
autofocus
clearable
class="q-mt-md"
/>
<q-input
v-model="confirmPasswordField"
label="Подтвердите пароль"
type="password"
dense
clearable
class="q-mt-sm"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Отмена" color="primary" @click="changePasswordDialogVisible = false" />
<q-btn flat label="Сохранить пароль" color="primary" @click="saveNewPassword" />
</q-card-actions>
</q-card>
</q-dialog>
<q-btn <q-btn
:icon="'logout'" :icon="'logout'"
class="fixed-bottom-right q-ma-md" class="fixed-bottom-right q-ma-md"
@ -465,18 +737,19 @@
</q-layout> </q-layout>
</template> </template>
<script setup> <script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { Notify, useQuasar } from 'quasar' import { Notify, useQuasar } from 'quasar'
import registerUser from '@/api/users/registerUser.js' import registerUser from '@/api/users/registerUser.js'
import changePassword from '@/api/users/changeUserPassword.js' import changeUserPassword from '@/api/users/changeUserPassword.js'
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'
import createTeam from '@/api/teams/createTeam.js' import createTeam from '@/api/teams/createTeam.js'
import setActiveTeam from '@/api/teams/setActiveTeam.js'
import uploadTeamLogo from '@/api/teams/uploadTeamPhoto.js'
import fetchProjects from '@/api/projects/getProjects.js' import fetchProjects from '@/api/projects/getProjects.js'
import updateProject from '@/api/projects/updateProject.js' import updateProject from '@/api/projects/updateProject.js'
@ -513,6 +786,15 @@ import uploadProjectFile from '@/api/projects/project_files/uploadProjectFile.js
import deleteProjectFile from '@/api/projects/project_files/deleteProjectFile.js' import deleteProjectFile from '@/api/projects/project_files/deleteProjectFile.js'
import downloadProjectFile from '@/api/projects/project_files/downloadProjectFile.js' import downloadProjectFile from '@/api/projects/project_files/downloadProjectFile.js'
import getProjectMemberByProject from "@/api/project_members/getProjectMemberByProject.js";
import getProjectMemberByProfile from "@/api/project_members/getProjectMembersByProfile.js";
import createProjectMember from "@/api/project_members/createProjectMember.js";
import deleteProjectMember from "@/api/project_members/deleteProjectMember.js";
import updateProjectMember from "@/api/project_members/updateProjectMember.js";
import getAllProjectMembers from "@/api/project_members/getAllProjectMembers.js";
import deleteSingleProjectMember from "@/api/project_members/deleteSingleProjectMember.js"; // НОВЫЙ ИМПОРТ
import router from "@/router/index.js"; import router from "@/router/index.js";
import CONFIG from '@/core/config.js' import CONFIG from '@/core/config.js'
@ -520,6 +802,21 @@ const $q = useQuasar()
const tab = ref('profiles') const tab = ref('profiles')
const createUserDialogVisible = ref(false);
const newUserData = ref({ // Модель для новых данных пользователя
first_name: '',
last_name: '',
patronymic: '',
birthday: null, // Используйте null для даты
email: '',
phone: '',
role_id: null, // null для q-select, если по умолчанию не выбрано
team_id: null, // null для q-select
login: '',
password: '',
});
const confirmNewUserPassword = ref('');
const profiles = ref([]) const profiles = ref([])
const loadingProfiles = ref(false) const loadingProfiles = ref(false)
const profileColumns = [ const profileColumns = [
@ -543,6 +840,10 @@ const teamColumns = [
{ name: 'is_active', label: 'Активна', field: 'is_active', sortable: true }, { name: 'is_active', label: 'Активна', field: 'is_active', sortable: true },
] ]
const newTeamLogoFile = ref(null);
const loadingTeamLogo = ref(false);
const uploadingLogo = ref(false);
const projects = ref([]) const projects = ref([])
const loadingProjects = ref(false) const loadingProjects = ref(false)
const projectColumns = [ const projectColumns = [
@ -557,17 +858,53 @@ const contestColumns = [
{ name: 'title', label: 'Название конкурса', field: 'title', sortable: true }, { name: 'title', label: 'Название конкурса', field: 'title', sortable: true },
{ name: 'description', label: 'Описание', field: 'description', sortable: true }, { name: 'description', label: 'Описание', field: 'description', sortable: true },
{ name: 'web_url', label: 'URL сайта', field: 'web_url', sortable: true }, { name: 'web_url', label: 'URL сайта', field: 'web_url', sortable: true },
{ name: 'photo', label: 'Фото', field: 'photo', sortable: true },
{ name: 'results', label: 'Результаты', field: 'results', sortable: true }, { name: 'results', label: 'Результаты', field: 'results', sortable: true },
{ name: 'is_win', label: 'Победа', field: 'is_win', sortable: true }, { name: 'is_win', label: 'Победа', field: 'is_win', sortable: true },
{ name: 'project_id', label: 'Проект', field: 'project_id', sortable: true }, { name: 'project_id', label: 'Проект', field: 'project_id', sortable: true },
{ name: 'status_id', label: 'Статус', field: 'status_id', sortable: true }, { name: 'status_id', label: 'Статус', field: 'status_id', sortable: true },
] ]
const projectMembers = ref([])
const loadingProjectMembers = ref(false)
const projectMemberColumns = [
{ name: 'description', label: 'Описание', field: 'description', sortable: true },
{
name: 'project_id',
label: 'Проект',
field: 'project_id',
sortable: true,
// Форматирование для отображения названия проекта
format: (val, row) => {
const project = allProjects.value.find(p => p.id === val);
return project ? project.title : 'Неизвестный проект';
}
},
{
name: 'profile_id',
label: 'Профиль',
field: 'profile_id',
sortable: true,
// Форматирование для отображения имени профиля
format: (val, row) => {
const profile = allProfiles.value.find(p => p.id === val);
return profile ? `${profile.first_name} ${profile.last_name}` : 'Неизвестный профиль';
}
},
];
const allProjects = ref([]); // Для списка проектов в q-select
const allProfiles = ref([]); // Для списка профилей в q-select
const changePasswordDialogVisible = ref(false);
const selectedUserForPasswordChange = ref(null);
const newPasswordField = ref('');
const confirmPasswordField = ref('');
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogData = ref({}) const dialogData = ref({})
const dialogType = ref('') const dialogType = ref('')
const profilePhotos = ref([]) const profilePhotos = ref([])
const loadingProfilePhotos = ref(false) const loadingProfilePhotos = ref(false)
const newProfilePhotoFile = ref(null) const newProfilePhotoFile = ref(null)
@ -586,12 +923,15 @@ const projectFiles = ref([])
const loadingProjectFiles = ref(false) const loadingProjectFiles = ref(false)
const newProjectFile = ref(null) const newProjectFile = ref(null)
// TO DO
// ДОБАВИТЬ ПРАВИЛЬНЫЙ URL
const getPhotoUrl = (photoId, type) => { const getPhotoUrl = (photoId, type) => {
if (type === 'profile') { if (type === 'profile') {
return `${CONFIG.BASE_URL}/profile_photos/${photoId}/file`; return `${CONFIG.BASE_URL}/profile_photos/${photoId}/file`;
} else if (type === 'contest') { } else if (type === 'contest') {
return `${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/file`; return `${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/file`;
} else if (type === 'teams') {
return `${CONFIG.BASE_URL}//${photoId}/file`;
} }
return ''; return '';
} }
@ -676,6 +1016,9 @@ function handleNewProjectFileSelected(file) {
newProjectFile.value = file; newProjectFile.value = file;
} }
function handleNewTeamLogoSelected(file) {
newTeamLogoFile.value = file;
}
async function uploadNewProfilePhoto() { async function uploadNewProfilePhoto() {
if (!newProfilePhotoFile.value || !dialogData.value.id) { if (!newProfilePhotoFile.value || !dialogData.value.id) {
@ -708,6 +1051,40 @@ async function uploadNewProfilePhoto() {
} }
} }
async function uploadNewTeamLogo() {
if (!newTeamLogoFile.value || !dialogData.value.id) {
Notify.create({
type: 'warning',
message: 'Выберите файл логотипа и убедитесь, что команда выбрана.',
icon: 'warning',
});
return;
}
uploadingLogo.value = true; // Используем существующий индикатор
try {
const uploadedLogo = await uploadTeamLogo(dialogData.value.id, newTeamLogoFile.value);
dialogData.value.logoUrl = `${CONFIG.BASE_URL}/teams/${dialogData.value.id}/logo`;
newTeamLogoFile.value = null; // Очищаем выбранный файл
Notify.create({
type: 'positive',
message: 'Логотип команды успешно загружен!',
icon: 'check_circle',
});
await loadData();
} catch (error) {
Notify.create({
type: 'negative',
message: `Ошибка загрузки логотипа команды: ${error.message}`,
icon: 'error',
});
} finally {
uploadingLogo.value = false;
}
}
async function uploadNewContestPhoto() { async function uploadNewContestPhoto() {
if (!newContestPhotoFile.value || !dialogData.value.id) { if (!newContestPhotoFile.value || !dialogData.value.id) {
Notify.create({ Notify.create({
@ -959,25 +1336,39 @@ async function downloadExistingProjectFile(fileId) {
function openEdit(type, row) { function openEdit(type, row) {
dialogType.value = type dialogType.value = type;
if (row) { if (row) {
dialogData.value = JSON.parse(JSON.stringify(row)) dialogData.value = JSON.parse(JSON.stringify(row));
if (type === 'teams' && dialogData.value.logo) {
dialogData.value.logoUrl = `${CONFIG.BASE_URL}/teams/${dialogData.value.id}/logo`;
} else if (type === 'teams') {
dialogData.value.logoUrl = null;
}
} else { } else {
if (type === 'teams') { if (type === 'teams') {
dialogData.value = { title: '', description: '', logo: '', git_url: '', is_active: '' } dialogData.value = {
title: '',
description: '',
logo: '',
logoUrl: null,
git_url: '',
is_active: true,
};
} else if (type === 'projects') { } else if (type === 'projects') {
dialogData.value = { title: '', description: '', repository_url: '' } dialogData.value = { title: '', description: '', repository_url: '' };
projectFiles.value = []; projectFiles.value = [];
} else if (type === 'profiles') { } else if (type === 'profiles') {
dialogData.value = { first_name: '', last_name: '', patronymic: '', birthday: '', email: '', phone: '', role_id: null, team_id: null } dialogData.value = { first_name: '', last_name: '', patronymic: '', birthday: '', email: '', phone: '', role_id: null, team_id: null };
profilePhotos.value = []; profilePhotos.value = [];
} else if (type === 'contests') { } else if (type === 'contests') {
dialogData.value = { title: '', description: '', web_url: '', photo: '', results: '', is_win: false, project_id: null, status_id: null } dialogData.value = { title: '', description: '', web_url: '', photo: '', results: '', is_win: false, project_id: null, status_id: null };
contestPhotos.value = []; contestPhotos.value = [];
contestFiles.value = []; contestFiles.value = [];
} else if (type === 'project_members') {
dialogData.value = { description: '', project_id: null, profile_id: null };
} }
} }
dialogVisible.value = true dialogVisible.value = true;
if (type === 'profiles' && dialogData.value.id) { if (type === 'profiles' && dialogData.value.id) {
loadProfilePhotos(dialogData.value.id); loadProfilePhotos(dialogData.value.id);
@ -986,7 +1377,24 @@ function openEdit(type, row) {
loadContestFiles(dialogData.value.id); loadContestFiles(dialogData.value.id);
} else if (type === 'projects' && dialogData.value.id) { } else if (type === 'projects' && dialogData.value.id) {
loadProjectFiles(dialogData.value.id); loadProjectFiles(dialogData.value.id);
} else { } else if (type === 'project_members' || type === 'profiles' || type === 'contests') {
if (allProjects.value.length === 0) {
fetchProjects().then(data => {
allProjects.value = data || [];
}).catch(error => {
Notify.create({ type: 'negative', message: `Ошибка загрузки проектов для списка: ${error.message}` });
});
}
if (allProfiles.value.length === 0) {
fetchProfiles().then(data => {
allProfiles.value = data || [];
}).catch(error => {
Notify.create({ type: 'negative', message: `Ошибка загрузки профилей для списка: ${error.message}` });
});
}
}
else {
profilePhotos.value = []; profilePhotos.value = [];
contestPhotos.value = []; contestPhotos.value = [];
contestFiles.value = []; contestFiles.value = [];
@ -1016,12 +1424,13 @@ async function saveChanges() {
try { try {
if (dialogType.value === 'teams') { if (dialogType.value === 'teams') {
if (dialogData.value.id) { if (dialogData.value.id) {
await updateTeam(dialogData.value) await updateTeam(dialogData.value);
const idx = teams.value.findIndex(t => t.id === dialogData.value.id) const idx = teams.value.findIndex(t => t.id === dialogData.value.id);
if (idx !== -1) teams.value[idx] = JSON.parse(JSON.stringify(dialogData.value)) if (idx !== -1) teams.value[idx] = JSON.parse(JSON.stringify(dialogData.value));
} else { } else {
const newTeam = await createTeam(dialogData.value) const newTeamData = { ...dialogData.value, is_active: false };
teams.value.push(newTeam) const newTeam = await createTeam(newTeamData);
teams.value.push(newTeam);
} }
} else if (dialogType.value === 'projects') { } else if (dialogType.value === 'projects') {
if (dialogData.value.id) { if (dialogData.value.id) {
@ -1050,6 +1459,54 @@ async function saveChanges() {
const newContest = await createContest(dialogData.value) const newContest = await createContest(dialogData.value)
contests.value.push(newContest) contests.value.push(newContest)
} }
} else if (dialogType.value === 'project_members') {
const memberData = {
id: dialogData.value.id,
description: dialogData.value.description,
project_id: parseInt(dialogData.value.project_id),
profile_id: parseInt(dialogData.value.profile_id)
};
const isDuplicate = projectMembers.value.some(pm => {
if (pm.id === memberData.id) {
return false;
}
return pm.project_id === memberData.project_id && pm.profile_id === memberData.profile_id;
});
if (isDuplicate) {
Notify.create({
type: 'warning',
message: 'Этот профиль уже является участником данного проекта.',
icon: 'warning',
});
return;
}
if (dialogData.value.id) {
if (!memberData.project_id) {
Notify.create({ type: 'negative', message: 'Невозможно обновить: ID проекта для участника отсутствует.', icon: 'error' });
return;
}
const currentMembersForProject = await getProjectMemberByProject(memberData.project_id) || [];
const updatedMembersList = currentMembersForProject.map(member => {
if (member.id === memberData.id) {
return { ...member, ...memberData };
}
return member;
});
await updateProjectMember(memberData.project_id, updatedMembersList);
await loadData('project_members');
} else {
const newMemberPayload = {
description: memberData.description,
project_id: memberData.project_id,
profile_id: memberData.profile_id
};
const newProjectMemberResponse = await createProjectMember(newMemberPayload);
projectMembers.value.push(newProjectMemberResponse[0] || newProjectMemberResponse);
}
} }
closeDialog() closeDialog()
Notify.create({ Notify.create({
@ -1067,6 +1524,170 @@ async function saveChanges() {
} }
} }
async function handleTeamIsActiveToggle(newValue) {
// Проверяем, что это команда, она существует (есть ID) и флажок активен
if (dialogType.value === 'teams' && dialogData.value.id && newValue) {
try {
await setActiveTeam(dialogData.value.id);
Notify.create({
type: 'positive',
message: `Команда "${dialogData.value.title}" успешно установлена как активная.`,
});
await loadData('teams'); // Вызываем loadData для обновления списка команд
} catch (error) {
console.error("Ошибка при установке активной команды:", error);
Notify.create({
type: 'negative',
message: `Ошибка при установке активной команды: ${error.message}`,
});
dialogData.value.is_active = !newValue;
}
}
}
async function saveNewUser() {
// Валидация входных данных
if (!newUserData.value.first_name || !newUserData.value.last_name || !newUserData.value.email || !newUserData.value.login || !newUserData.value.password || !newUserData.value.role_id) {
$q.notify({
type: 'negative',
message: 'Пожалуйста, заполните все обязательные поля (Имя, Фамилия, Почта, Логин, Пароль, Роль).',
icon: 'error',
});
return;
}
const validationError = validatePassword(newUserData.value.password);
if (validationError) {
$q.notify({
type: 'negative',
message: `Ошибка пароля: ${validationError}`,
icon: 'error',
});
return;
}
if (newUserData.value.password !== confirmNewUserPassword.value) {
$q.notify({
type: 'negative',
message: 'Пароли не совпадают.',
icon: 'error',
});
return;
}
try {
const dataToSend = { ...newUserData.value };
if (dataToSend.birthday) {
if (typeof dataToSend.birthday === 'string' && dataToSend.birthday.includes('/')) {
dataToSend.birthday = dataToSend.birthday.replace(/\//g, '-');
} else if (dataToSend.birthday instanceof Date) {
dataToSend.birthday = dataToSend.birthday.toISOString().split('T')[0];
}
} else {
dataToSend.birthday = null;
}
await registerUser(dataToSend);
$q.notify({
type: 'positive',
message: 'Пользователь успешно создан!',
icon: 'check_circle',
});
createUserDialogVisible.value = false;
await loadData('profiles'); // Обновляем список пользователей
} catch (error) {
console.error('Ошибка при создании пользователя:', error.message);
$q.notify({
type: 'negative',
message: `Ошибка при создании пользователя: ${error.message}`,
icon: 'error',
});
}
}
function changePasswordHandler() {
selectedUserForPasswordChange.value = null;
newPasswordField.value = '';
confirmPasswordField.value = '';
changePasswordDialogVisible.value = true;
}
function validatePassword(password) {
if (password.length < 8) {
return 'Пароль должен быть не менее 8 символов.';
}
if (!/[A-Z]/.test(password)) { // Проверка на заглавные буквы
return 'Пароль должен содержать хотя бы одну заглавную букву.';
}
if (!/[a-z]/.test(password)) { // Проверка на строчные буквы
return 'Пароль должен содержать хотя бы одну строчную букву.';
}
if (!/\d/.test(password)) { // Проверка на цифры
return 'Пароль должен содержать хотя бы одну цифру.';
}
if (!/[!@#$%^&*()_+]/.test(password)) { // Проверка на спецсимволы
return 'Пароль должен содержать хотя бы один специальный символ (!@#$%^&*()_+).';
}
if (!/[a-zA-Z]/.test(password)) { // Проверка на наличие хотя бы одной буквы (латиницы)
return 'Пароль должен содержать хотя бы одну букву (латиницу).';
}
return ''; // Пустая строка означает, что валидация прошла успешно
}
async function saveNewPassword() {
if (!selectedUserForPasswordChange.value) {
$q.notify({
type: 'negative',
message: 'Пожалуйста, выберите пользователя.',
icon: 'error',
});
return;
}
const validationError = validatePassword(newPasswordField.value);
if (validationError) {
$q.notify({
type: 'negative',
message: validationError,
icon: 'error',
});
return;
}
if (newPasswordField.value !== confirmPasswordField.value) {
$q.notify({
type: 'negative',
message: 'Пароли не совпадают.',
icon: 'error',
});
return;
}
try {
await changeUserPassword(selectedUserForPasswordChange.value.id, newPasswordField.value);
$q.notify({
type: 'positive',
message: 'Пароль успешно изменен!',
icon: 'check_circle',
});
changePasswordDialogVisible.value = false;
selectedUserForPasswordChange.value = null;
newPasswordField.value = '';
confirmPasswordField.value = '';
} catch (error) {
console.error('Ошибка при смене пароля:', error.message);
$q.notify({
type: 'negative',
message: `Ошибка при смене пароля: ${error.message}`,
icon: 'error',
});
}
}
async function loadData(name) { async function loadData(name) {
if (name === 'teams') { if (name === 'teams') {
loadingTeams.value = true loadingTeams.value = true
@ -1100,6 +1721,7 @@ async function loadData(name) {
loadingProfiles.value = true loadingProfiles.value = true
try { try {
profiles.value = await fetchProfiles() || [] profiles.value = await fetchProfiles() || []
allProfiles.value = await fetchProfiles() || []
} catch (error) { } catch (error) {
profiles.value = [] profiles.value = []
Notify.create({ Notify.create({
@ -1124,6 +1746,27 @@ async function loadData(name) {
} finally { } finally {
loadingContests.value = false loadingContests.value = false
} }
} else if (name === 'project_members') {
loadingProjectMembers.value = true;
try {
projectMembers.value = await getAllProjectMembers() || [];
// Эти запросы нужны для корректного отображения названий в таблице и q-select
allProjects.value = await fetchProjects() || [];
allProfiles.value = await fetchProfiles() || [];
} catch (error) {
projectMembers.value = [];
allProjects.value = [];
allProfiles.value = [];
Notify.create({
type: 'negative',
message: `Ошибка загрузки участников проектов или связанных данных: ${error.message}`,
icon: 'error',
});
} finally {
loadingProjectMembers.value = false;
}
} }
} }
@ -1131,7 +1774,12 @@ async function deleteItem() {
if (!dialogData.value.id) return if (!dialogData.value.id) return
$q.dialog({ $q.dialog({
title: 'Подтверждение удаления', title: 'Подтверждение удаления',
message: `Вы уверены, что хотите удалить ${dialogType.value === 'profiles' ? 'пользователя' : dialogType.value}?`, message: `Вы уверены, что хотите удалить
${dialogType.value === 'profiles' ? 'пользователя' : dialogType.value
=== 'project_members' ? 'участника проекта' : dialogType.value
=== 'teams' ? 'команду' : dialogType.value
=== 'projects' ? 'проект' : dialogType.value
=== 'contests' ? 'конкурс' : dialogType.value}?`,
cancel: true, cancel: true,
persistent: true, persistent: true,
ok: { ok: {
@ -1156,6 +1804,9 @@ async function deleteItem() {
} else if (dialogType.value === 'contests') { } else if (dialogType.value === 'contests') {
await deleteContestById(dialogData.value.id) await deleteContestById(dialogData.value.id)
contests.value = contests.value.filter(c => c.id !== dialogData.value.id) contests.value = contests.value.filter(c => c.id !== dialogData.value.id)
} else if (dialogType.value === 'project_members') {
await deleteSingleProjectMember(dialogData.value.id);
projectMembers.value = projectMembers.value.filter(pm => pm.id !== dialogData.value.id);
} }
closeDialog() closeDialog()
Notify.create({ Notify.create({
@ -1174,6 +1825,38 @@ async function deleteItem() {
}); });
} }
async function createNewUserHandler() {
newUserData.value = {
first_name: '',
last_name: '',
patronymic: '',
birthday: null,
email: '',
phone: '',
role_id: null,
team_id: null,
login: '',
password: '',
};
confirmNewUserPassword.value = '';
if (teams.value.length === 0 || allProfiles.value.length === 0) {
try {
await loadData('teams');
await loadData('profiles');
} catch (error) {
console.error('Ошибка загрузки данных для формы создания пользователя:', error);
$q.notify({
type: 'negative',
message: 'Не удалось загрузить списки команд или профилей.',
icon: 'error',
});
}
}
createUserDialogVisible.value = true; // Открываем новый диалог
}
function createHandler() { function createHandler() {
dialogData.value = {}; dialogData.value = {};
openEdit(tab.value, null) openEdit(tab.value, null)

View File

@ -60,7 +60,7 @@
<q-card-section class="q-pa-md"> <q-card-section class="q-pa-md">
<div class="text-h6 text-indigo-10 q-mb-md">Активность команды за последний год</div> <div class="text-h6 text-indigo-10 q-mb-md">Активность команды за последний год</div>
<div class="months-row flex" style="margin-left: 40px; margin-bottom: 4px; user-select: none;"> <div class="months-row flex" style="margin-left: 60px; margin-bottom: 4px; user-select: none;">
<div <div
v-for="(monthLabel, idx) in monthLabels" v-for="(monthLabel, idx) in monthLabels"
:key="monthLabel" :key="monthLabel"
@ -72,7 +72,7 @@
</div> </div>
<div class="activity-grid-row row no-wrap"> <div class="activity-grid-row row no-wrap">
<div class="weekdays-column column q-pr-sm" style="width: 40px; user-select: none; justify-content: space-around;"> <div class="weekdays-column column q-pr-sm" style="width: 30px; user-select: none; justify-content: space-around;">
<div <div
v-for="(day, idx) in weekDays" v-for="(day, idx) in weekDays"
:key="day" :key="day"
@ -168,8 +168,21 @@ const activityData = ref([]);
const dayHeight = 14; const dayHeight = 14;
const squareSize = ref(12); const squareSize = ref(12);
// Подписи месяцев (с июня 2024 по май 2025) function getDynamicMonthLabelsShort() {
const monthLabels = ['июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.', 'янв.', 'февр.', 'март', 'апр.', 'май']; const monthNames = [
'янв.', 'февр.', 'март', 'апр.', 'май', 'июн.',
'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.'
];
const currentMonthIndex = new Date().getMonth();
// Создаем массив из 12 элементов, затем используем map для получения названий месяцев
return Array.from({ length: 12 }, (_, i) => {
const monthIndex = (currentMonthIndex + i) % 12;
return monthNames[monthIndex];
});
}
const monthLabels = getDynamicMonthLabelsShort();
// Дни недели (пн, ср, пт, как в Gitea) // Дни недели (пн, ср, пт, как в Gitea)
const weekDays = ['пн', 'ср', 'пт']; const weekDays = ['пн', 'ср', 'пт'];
@ -227,9 +240,14 @@ async function loadTeamData() {
try { try {
const teams = await fetchTeams(); const teams = await fetchTeams();
const activeTeam = teams.find(team => team.is_active === true); const activeTeam = teams.find(team => team.is_active === true);
if (activeTeam) { if (activeTeam) {
teamName.value = activeTeam.name; teamName.value = activeTeam.title || 'Название не указано';
teamLogo.value = activeTeam.logo; teamLogo.value = activeTeam.logo || '';
// Вы также можете сохранить другие данные активной команды, если они нужны:
// teamDescription.value = activeTeam.description || '';
// teamGitUrl.value = activeTeam.git_url || '';
} else { } else {
Notify.create({ Notify.create({
type: 'warning', type: 'warning',
@ -252,10 +270,17 @@ async function loadMembers() {
try { try {
const profiles = await fetchProfiles(); const profiles = await fetchProfiles();
members.value = profiles.map(profile => ({ members.value = profiles.map(profile => ({
id: profile.id, id: profile.id, // ID всегда остается
name: profile.name || 'Без имени', name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Без имени', // Объединяем имя и фамилию
role: profile.role || 'Участник', role: profile.role_id || 'Участник', // Используем role_id
avatar: profile.avatar || 'https://randomuser.me/api/portraits/men/1.jpg', avatar: profile.avatar || 'https://randomuser.me/api/portraits/men/1.jpg', // Аватар остался прежним
// Добавляем остальные поля, которые могут быть полезны
patronymic: profile.patronymic || '',
birthday: profile.birthday || '',
email: profile.email || '',
phone: profile.phone || '',
team_id: profile.team_id || null,
})); }));
} catch (error) { } catch (error) {
console.error('Ошибка загрузки участников:', error); console.error('Ошибка загрузки участников:', error);
@ -437,4 +462,7 @@ function decreaseScale() {
flex-shrink: 0; flex-shrink: 0;
position: absolute; /* Для точного позиционирования */ position: absolute; /* Для точного позиционирования */
} }
.weekdays-column {
margin-top: -15px; /* Попробуйте разные значения, например, -10px, -30px, пока не найдете оптимальное */
}
</style> </style>

View File

@ -53,8 +53,6 @@ const authorisation = async () => {
const roleId = profileResponse.data.role_id const roleId = profileResponse.data.role_id
Notify.create({ Notify.create({
type: 'positive', type: 'positive',
message: 'Успешный вход!', message: 'Успешный вход!',
@ -67,15 +65,23 @@ const authorisation = async () => {
await router.push('/') await router.push('/')
} }
if (roleId === 1) {
await router.push('/admin')
} else {
await router.push('/')
}
} catch (error) { } catch (error) {
let errorMessage = 'Ошибка входа';
if (error.response) {
if (error.response.status === 401) {
errorMessage = 'Неверное имя пользователя или пароль.';
} else if (error.response.data && error.response.data.detail) {
errorMessage = error.response.data.detail;
} else {
errorMessage = 'Произошла ошибка при попытке входа. Пожалуйста, попробуйте еще раз.';
}
} else if (error.message) {
errorMessage = error.message;
}
Notify.create({ Notify.create({
type: 'negative', type: 'negative',
message: error.message || 'Ошибка входа', message: errorMessage,
icon: 'error' icon: 'error'
}) })
} finally { } finally {
@ -209,8 +215,6 @@ const authorisation = async () => {
animation: marquee-move var(--marquee-duration) linear infinite; animation: marquee-move var(--marquee-duration) linear infinite;
} }
.marquee-text { .marquee-text {
display: flex; display: flex;
align-items: center; align-items: center;