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

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):
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]:
stmt = select(ProjectMember).filter_by(id=project_member_id)
result = await self.db.execute(stmt)
@ -43,5 +48,7 @@ class ProjectMembersRepository:
for project_member in project_members:
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()
return project_members
return project_member

View File

@ -11,6 +11,19 @@ from app.infrastructure.project_members_service import ProjectMembersService
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(
'/by-project/{project_id}/',
response_model=list[ProjectMemberEntity],
@ -84,3 +97,17 @@ async def delete_project_members(
service = ProjectMembersService(db)
await service.delete_project_members_by_project_id(project_id)
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(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
teams_service = TeamsService(db)
return await teams_service.get_all_teams()
@ -33,7 +32,7 @@ async def get_all_teams(
summary='Get active team',
description='Returns active team',
)
async def get_all_teams(
async def get_active_team(
db: AsyncSession = Depends(get_db),
):
teams_service = TeamsService(db)
@ -61,7 +60,7 @@ async def create_team(
summary='Make team active',
description='Makes team active',
)
async def update_team(
async def set_active_team(
team_id: int,
db: AsyncSession = Depends(get_db),
user=Depends(require_admin),

View File

@ -1,15 +1,29 @@
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Body
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.dependencies import get_current_user
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
@router.put(
'/{user_id}/',
@ -17,9 +31,9 @@ router = APIRouter()
summary='Change user password',
description='Change user password',
)
async def create_user(
async def change_user_password(
user_id: int,
new_password: str,
new_password: str = Body(..., embed=True), # <--- ИЗМЕНЕНИЕ ЗДЕСЬ! Явно указывает, что в теле запроса
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):

View File

@ -16,6 +16,13 @@ class ProjectMembersService:
self.projects_repository = ProjectsRepository(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]:
project_members = await self.project_members_repository.get_by_project_id(project_id)
return [
@ -107,6 +114,17 @@ class ProjectMembersService:
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
def model_to_entity(project: ProjectMember) -> 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_members_router import router as project_members_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.teams_router import router as team_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',
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'])

View File

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

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 CONFIG from "@/core/config.js";
const changeUserPassword = async (userId, newPasswordData) => {
const changeUserPassword = async (userId, newPassword) => {
try {
const token = localStorage.getItem("access_token");
const response = await axios.patch(
`${CONFIG.BASE_URL}/users/${userId}/password`,
newPasswordData,
const response = await axios.put(
`${CONFIG.BASE_URL}/users/${userId}/`,
{ new_password: newPassword },
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}
@ -19,6 +20,11 @@ const changeUserPassword = async (userId, newPasswordData) => {
throw new Error(error.response.data.detail);
} else if (error.response?.status === 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);
}

View File

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

View File

@ -40,7 +40,8 @@ import {
QItemLabel,
QItem,
QImg,
QFile
QFile,
QSelect
} from 'quasar'
@ -60,7 +61,7 @@ app.use(Quasar, {
QSeparator, QCardActions, QDialog, QIcon, QSpace,
QAvatar, QTooltip, QBanner, QSlideTransition, QToggle,
QList, QSpinnerDots, QCarouselSlide, QCarousel,
QItemSection, QItemLabel, QItem, QImg, QFile
QItemSection, QItemLabel, QItem, QImg, QFile, QSelect
},
directives: {
Ripple

View File

@ -15,6 +15,7 @@
<q-tab name="teams" label="Команды" />
<q-tab name="projects" label="Проекты" />
<q-tab name="contests" label="Конкурсы" />
<q-tab name="project_members" label="Участники проектов" />
</q-tabs>
</q-header>
@ -27,7 +28,14 @@
<q-btn
label="Создание пользователя"
color="primary"
@click="createHandler"
@click="createNewUserHandler"
/>
<q-btn
round
color="primary"
icon="cached"
title="Поменять пароль у пользователя"
@click="changePasswordHandler"
/>
</div>
<q-table
@ -110,6 +118,28 @@
</div>
</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-page-container>
@ -121,7 +151,7 @@
Редактирование команды {{ dialogData.title || '' }}
</template>
<template v-else-if="dialogType === 'profiles'">
Редактирование пользователя {{ dialogData.name || dialogData.login || '' }}
Редактирование пользователя {{ `${dialogData.first_name} ${dialogData.last_name}` }}
</template>
<template v-else-if="dialogType === 'projects'">
Редактирование проекта {{ dialogData.title || '' }}
@ -129,6 +159,9 @@
<template v-else-if="dialogType === 'contests'">
Редактирование конкурса {{ dialogData.title || '' }}
</template>
<template v-else-if="dialogType === 'project_members'">
Привязка участника к проекту
</template>
<template v-else>
Редактирование {{ dialogType }}
</template>
@ -141,9 +174,59 @@
<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" />
<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-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 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.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.role_id" label="Роль" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.team_id" label="Команда" dense clearable class="q-mt-sm" />
<q-select
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" />
<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.results" label="Результаты" dense clearable 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-input v-model="dialogData.status_id" label="Статус" dense clearable class="q-mt-sm" />
<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.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" />
<div class="text-h6 q-mb-sm">Фотографии карусели конкурса</div>
@ -407,6 +528,34 @@
/>
</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>
<pre>{{ dialogData }}</pre>
</template>
@ -441,10 +590,17 @@
color="negative"
@click="deleteItem"
/>
<q-btn
v-if="dialogType === 'project_members' && dialogData.id"
flat
label="Удалить"
color="negative"
@click="deleteItem"
/>
<q-space />
<q-btn flat label="Закрыть" color="primary" @click="closeDialog" />
<q-btn
v-if="['teams', 'profiles', 'projects', 'contests'].includes(dialogType)"
v-if="['teams', 'profiles', 'projects', 'contests', 'project_members'].includes(dialogType)"
flat
label="Сохранить"
color="primary"
@ -454,6 +610,122 @@
</q-card>
</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
:icon="'logout'"
class="fixed-bottom-right q-ma-md"
@ -465,18 +737,19 @@
</q-layout>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { Notify, useQuasar } from 'quasar'
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 updateTeam from '@/api/teams/updateTeam.js'
import deleteTeamById from '@/api/teams/deleteTeam.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 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 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 CONFIG from '@/core/config.js'
@ -520,6 +802,21 @@ const $q = useQuasar()
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 loadingProfiles = ref(false)
const profileColumns = [
@ -543,6 +840,10 @@ const teamColumns = [
{ 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 loadingProjects = ref(false)
const projectColumns = [
@ -557,17 +858,53 @@ const contestColumns = [
{ name: 'title', label: 'Название конкурса', field: 'title', sortable: true },
{ name: 'description', label: 'Описание', field: 'description', 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: 'is_win', label: 'Победа', field: 'is_win', sortable: true },
{ name: 'project_id', label: 'Проект', field: 'project_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 dialogData = ref({})
const dialogType = ref('')
const profilePhotos = ref([])
const loadingProfilePhotos = ref(false)
const newProfilePhotoFile = ref(null)
@ -586,12 +923,15 @@ const projectFiles = ref([])
const loadingProjectFiles = ref(false)
const newProjectFile = ref(null)
// TO DO
// ДОБАВИТЬ ПРАВИЛЬНЫЙ URL
const getPhotoUrl = (photoId, type) => {
if (type === 'profile') {
return `${CONFIG.BASE_URL}/profile_photos/${photoId}/file`;
} else if (type === 'contest') {
return `${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/file`;
} else if (type === 'teams') {
return `${CONFIG.BASE_URL}//${photoId}/file`;
}
return '';
}
@ -676,6 +1016,9 @@ function handleNewProjectFileSelected(file) {
newProjectFile.value = file;
}
function handleNewTeamLogoSelected(file) {
newTeamLogoFile.value = file;
}
async function uploadNewProfilePhoto() {
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() {
if (!newContestPhotoFile.value || !dialogData.value.id) {
Notify.create({
@ -959,25 +1336,39 @@ async function downloadExistingProjectFile(fileId) {
function openEdit(type, row) {
dialogType.value = type
dialogType.value = type;
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 {
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') {
dialogData.value = { title: '', description: '', repository_url: '' }
dialogData.value = { title: '', description: '', repository_url: '' };
projectFiles.value = [];
} 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 = [];
} 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 = [];
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) {
loadProfilePhotos(dialogData.value.id);
@ -986,7 +1377,24 @@ function openEdit(type, row) {
loadContestFiles(dialogData.value.id);
} else if (type === 'projects' && 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 = [];
contestPhotos.value = [];
contestFiles.value = [];
@ -1016,12 +1424,13 @@ async function saveChanges() {
try {
if (dialogType.value === 'teams') {
if (dialogData.value.id) {
await updateTeam(dialogData.value)
const idx = teams.value.findIndex(t => t.id === dialogData.value.id)
if (idx !== -1) teams.value[idx] = JSON.parse(JSON.stringify(dialogData.value))
await updateTeam(dialogData.value);
const idx = teams.value.findIndex(t => t.id === dialogData.value.id);
if (idx !== -1) teams.value[idx] = JSON.parse(JSON.stringify(dialogData.value));
} else {
const newTeam = await createTeam(dialogData.value)
teams.value.push(newTeam)
const newTeamData = { ...dialogData.value, is_active: false };
const newTeam = await createTeam(newTeamData);
teams.value.push(newTeam);
}
} else if (dialogType.value === 'projects') {
if (dialogData.value.id) {
@ -1050,6 +1459,54 @@ async function saveChanges() {
const newContest = await createContest(dialogData.value)
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()
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) {
if (name === 'teams') {
loadingTeams.value = true
@ -1100,6 +1721,7 @@ async function loadData(name) {
loadingProfiles.value = true
try {
profiles.value = await fetchProfiles() || []
allProfiles.value = await fetchProfiles() || []
} catch (error) {
profiles.value = []
Notify.create({
@ -1124,6 +1746,27 @@ async function loadData(name) {
} finally {
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
$q.dialog({
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,
persistent: true,
ok: {
@ -1156,6 +1804,9 @@ async function deleteItem() {
} else if (dialogType.value === 'contests') {
await deleteContestById(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()
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() {
dialogData.value = {};
openEdit(tab.value, null)

View File

@ -60,7 +60,7 @@
<q-card-section class="q-pa-md">
<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
v-for="(monthLabel, idx) in monthLabels"
:key="monthLabel"
@ -72,7 +72,7 @@
</div>
<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
v-for="(day, idx) in weekDays"
:key="day"
@ -168,8 +168,21 @@ const activityData = ref([]);
const dayHeight = 14;
const squareSize = ref(12);
// Подписи месяцев (с июня 2024 по май 2025)
const monthLabels = ['июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.', 'янв.', 'февр.', 'март', 'апр.', 'май'];
function getDynamicMonthLabelsShort() {
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)
const weekDays = ['пн', 'ср', 'пт'];
@ -227,9 +240,14 @@ async function loadTeamData() {
try {
const teams = await fetchTeams();
const activeTeam = teams.find(team => team.is_active === true);
if (activeTeam) {
teamName.value = activeTeam.name;
teamLogo.value = activeTeam.logo;
teamName.value = activeTeam.title || 'Название не указано';
teamLogo.value = activeTeam.logo || '';
// Вы также можете сохранить другие данные активной команды, если они нужны:
// teamDescription.value = activeTeam.description || '';
// teamGitUrl.value = activeTeam.git_url || '';
} else {
Notify.create({
type: 'warning',
@ -252,10 +270,17 @@ async function loadMembers() {
try {
const profiles = await fetchProfiles();
members.value = profiles.map(profile => ({
id: profile.id,
name: profile.name || 'Без имени',
role: profile.role || 'Участник',
avatar: profile.avatar || 'https://randomuser.me/api/portraits/men/1.jpg',
id: profile.id, // ID всегда остается
name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Без имени', // Объединяем имя и фамилию
role: profile.role_id || 'Участник', // Используем role_id
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) {
console.error('Ошибка загрузки участников:', error);
@ -437,4 +462,7 @@ function decreaseScale() {
flex-shrink: 0;
position: absolute; /* Для точного позиционирования */
}
.weekdays-column {
margin-top: -15px; /* Попробуйте разные значения, например, -10px, -30px, пока не найдете оптимальное */
}
</style>

View File

@ -53,8 +53,6 @@ const authorisation = async () => {
const roleId = profileResponse.data.role_id
Notify.create({
type: 'positive',
message: 'Успешный вход!',
@ -67,15 +65,23 @@ const authorisation = async () => {
await router.push('/')
}
if (roleId === 1) {
await router.push('/admin')
} else {
await router.push('/')
}
} 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({
type: 'negative',
message: error.message || 'Ошибка входа',
message: errorMessage,
icon: 'error'
})
} finally {
@ -209,8 +215,6 @@ const authorisation = async () => {
animation: marquee-move var(--marquee-duration) linear infinite;
}
.marquee-text {
display: flex;
align-items: center;