все готово

This commit is contained in:
Мельников Данил 2025-06-11 04:19:17 +05:00
parent b147d50d8e
commit f5ab4f8fe0
25 changed files with 3223 additions and 997 deletions

View File

@ -0,0 +1,32 @@
"""0008_добавил_url_репозитория_у_профилей
Revision ID: 7c4a804d9c4b
Revises: b6c6c906cd2b
Create Date: 2025-06-11 00:03:04.123410
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7c4a804d9c4b'
down_revision: Union[str, None] = 'b6c6c906cd2b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('profiles', sa.Column('repository_url', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('profiles', 'repository_url')
# ### end Alembic commands ###

View File

@ -11,6 +11,7 @@ class BaseProfileEntity(BaseModel):
birthday: datetime.date birthday: datetime.date
email: Optional[str] = None email: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
repository_url: Optional[str] = None
role_id: int role_id: int
team_id: int team_id: int

View File

@ -20,5 +20,5 @@ class Contest(AdvancedBaseModel):
project = relationship('Project', back_populates='contests') project = relationship('Project', back_populates='contests')
status = relationship('ContestStatus', back_populates='contests') status = relationship('ContestStatus', back_populates='contests')
carousel_photos = relationship('ContestCarouselPhoto', back_populates='contest') carousel_photos = relationship('ContestCarouselPhoto', back_populates='contest', cascade='all')
files = relationship('ContestFile', back_populates='contest') files = relationship('ContestFile', back_populates='contest', cascade='all')

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, VARCHAR, Date, ForeignKey, Integer from sqlalchemy import Column, VARCHAR, Date, ForeignKey, Integer, String
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.domain.models.base import AdvancedBaseModel from app.domain.models.base import AdvancedBaseModel
@ -13,6 +13,7 @@ class Profile(AdvancedBaseModel):
birthday = Column(Date, nullable=False) birthday = Column(Date, nullable=False)
email = Column(VARCHAR(150)) email = Column(VARCHAR(150))
phone = Column(VARCHAR(28)) phone = Column(VARCHAR(28))
repository_url = Column(String, nullable=True)
role_id = Column(Integer, ForeignKey('roles.id'), nullable=False) role_id = Column(Integer, ForeignKey('roles.id'), nullable=False)
team_id = Column(Integer, ForeignKey('teams.id'), nullable=False) team_id = Column(Integer, ForeignKey('teams.id'), nullable=False)
@ -21,5 +22,5 @@ class Profile(AdvancedBaseModel):
team = relationship('Team', back_populates='profiles') team = relationship('Team', back_populates='profiles')
user = relationship('User', back_populates='profile', cascade='all') user = relationship('User', back_populates='profile', cascade='all')
profile_photos = relationship('ProfilePhoto', back_populates='profile') profile_photos = relationship('ProfilePhoto', back_populates='profile', cascade='all')
projects = relationship('ProjectMember', back_populates='profile') projects = relationship('ProjectMember', back_populates='profile')

View File

@ -12,5 +12,5 @@ class Project(AdvancedBaseModel):
repository_url = Column(String, nullable=False) repository_url = Column(String, nullable=False)
contests = relationship("Contest", back_populates="project") contests = relationship("Contest", back_populates="project")
files = relationship("ProjectFile", back_populates="project") files = relationship("ProjectFile", back_populates="project", cascade="all")
members = relationship("ProjectMember", back_populates="project") members = relationship("ProjectMember", back_populates="project")

View File

@ -83,6 +83,7 @@ class ProfilesService:
profile_model.birthday = profile.birthday profile_model.birthday = profile.birthday
profile_model.email = profile.email profile_model.email = profile.email
profile_model.phone = profile.phone profile_model.phone = profile.phone
profile_model.repository_url = profile.repository_url
profile_model.role_id = profile.role_id profile_model.role_id = profile.role_id
profile_model.team_id = profile.team_id profile_model.team_id = profile.team_id
@ -130,6 +131,7 @@ class ProfilesService:
birthday=profile_model.birthday, birthday=profile_model.birthday,
email=profile_model.email, email=profile_model.email,
phone=profile_model.phone, phone=profile_model.phone,
repository_url=profile_model.repository_url,
role_id=profile_model.role_id, role_id=profile_model.role_id,
team_id=profile_model.team_id, team_id=profile_model.team_id,
) )
@ -143,6 +145,7 @@ class ProfilesService:
birthday=profile_entity.birthday, birthday=profile_entity.birthday,
email=profile_entity.email, email=profile_entity.email,
phone=profile_entity.phone, phone=profile_entity.phone,
repository_url=profile_entity.repository_url,
role_id=profile_entity.role_id, role_id=profile_entity.role_id,
team_id=profile_entity.team_id, team_id=profile_entity.team_id,
) )

View File

@ -21,7 +21,12 @@ def start_app():
api_app.add_middleware( api_app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["https://api.numerum.team", "https://numerum.team", "http://localhost:5173"], allow_origins=[
"https://api.numerum.team",
"https://numerum.team",
"http://localhost:5173", # Это уже есть
"http://127.0.0.1:5173" # <-- Добавьте эту строку
],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@ -47,7 +52,6 @@ def start_app():
app = start_app() app = start_app()
@app.get("/") @app.get("/")
async def root(): async def root():
return {"message": "Hello API"} return {"message": "Hello API"}

View File

@ -7,7 +7,7 @@ const deleteContestCarouselPhoto = async (photoId) => {
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
const response = await axios.delete( const response = await axios.delete(
`${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/`, // Изменено здесь `${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/`,
{ {
withCredentials: true, withCredentials: true,
headers: { headers: {

View File

@ -7,7 +7,7 @@ const downloadContestCarouselPhotoFile = async (photoId) => {
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
const response = await axios.get( const response = await axios.get(
`${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/file`, `${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/file/`,
{ {
withCredentials: true, withCredentials: true,
headers: { headers: {

View File

@ -12,7 +12,7 @@ const uploadContestCarouselPhoto = async (contestId, file) => {
formData.append('contest_id', contestId); formData.append('contest_id', contestId);
const response = await axios.post( const response = await axios.post(
`${CONFIG.BASE_URL}/contest_carousel_photos/contests/${contestId}/upload`, `${CONFIG.BASE_URL}/contest_carousel_photos/contests/${contestId}/upload/`,
formData, formData,
{ {
withCredentials: true, withCredentials: true,

View File

@ -1,38 +1,82 @@
import axios from "axios"; import axios from "axios";
import CONFIG from "@/core/config.js"; import CONFIG from "@/core/config.js";
const downloadContestFile = async (fileId) => { // Добавляем параметры suggestedFileName и suggestedFileFormat для большей надежности
const downloadContestFile = async (fileId, suggestedFileName, suggestedFileFormat) => {
try { try {
const response = await axios.get(`${CONFIG.BASE_URL}/contest_files/${fileId}/file`, { const response = await axios.get(
responseType: 'blob', `${CONFIG.BASE_URL}/contest_files/${fileId}/file`, // Убедитесь, что это правильный URL для скачивания самого файла
withCredentials: true, {
}); responseType: 'blob', // Важно для бинарных данных
const url = window.URL.createObjectURL(new Blob([response.data])); withCredentials: true, // Важно для аутентификации
}
);
// Получаем MIME-тип файла из заголовков ответа
const contentType = response.headers['content-type'] || 'application/octet-stream';
// Создаем Blob с правильным MIME-типом
const blob = new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
let filename = `contest_file_${fileId}`; // Запасное имя файла
// 1. Попытка получить имя файла из заголовка Content-Disposition
const contentDisposition = response.headers['content-disposition']; const contentDisposition = response.headers['content-disposition'];
let filename = 'download';
if (contentDisposition) { if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="(.+)"/); // Расширенное регулярное выражение для извлечения filename или filename* (с поддержкой UTF-8)
if (filenameMatch && filenameMatch[1]) { const filenameMatch = contentDisposition.match(/filename\*=(?:UTF-8'')?([^;]+)|filename="([^"]+)"/i);
filename = filenameMatch[1]; if (filenameMatch) {
if (filenameMatch[1]) { // filename* (RFC 5987)
try {
filename = decodeURIComponent(filenameMatch[1]);
} catch (e) {
console.warn("Ошибка декодирования filename* из Content-Disposition:", e);
filename = filenameMatch[1]; // Используем как есть, если декодирование не удалось
}
} else if (filenameMatch[2]) { // Обычный filename
filename = filenameMatch[2];
} }
} }
}
// 2. Если имя файла всё ещё не идеально или не содержит расширения,
// используем переданные suggestedFileName и suggestedFileFormat как запасной вариант
if (!filename || filename === `contest_file_${fileId}` || !filename.includes('.')) {
let finalName = suggestedFileName || `contest_file_${fileId}`;
if (suggestedFileFormat && !finalName.toLowerCase().endsWith(`.${suggestedFileFormat.toLowerCase()}`)) {
finalName = `${finalName}.${suggestedFileFormat.toLowerCase()}`;
}
filename = finalName;
}
link.setAttribute('download', filename); link.setAttribute('download', filename);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.remove(); link.remove();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url); // Освобождаем память Blob URL
return filename; return filename;
} catch (error) { } catch (error) {
if (error.response?.status === 401) { // Улучшенная обработка ошибок для более информативных сообщений
throw new Error("Нет доступа для скачивания файла (401)"); if (error.response) {
if (error.response.status === 401) {
throw new Error("Недостаточно прав для скачивания файла конкурса (401). Пожалуйста, авторизуйтесь.");
} }
if (error.response?.status === 404) { if (error.response.status === 404) {
throw new Error("Файл не найден (404)"); throw new Error(`Файл конкурса с ID ${fileId} не найден (404). Возможно, он был удалён.`);
}
// Попробуем получить сообщение об ошибке из данных ответа, если это JSON
if (error.response.data && typeof error.response.data === 'object' && error.response.data.detail) {
throw new Error(`Ошибка сервера при скачивании файла конкурса: ${error.response.data.detail}`);
}
throw new Error(`Ошибка сети или сервера при скачивании файла конкурса: Статус ${error.response.status}`);
} else if (error.request) {
throw new Error("Не удалось отправить запрос на скачивание файла конкурса. Проверьте ваше интернет-соединение.");
} else {
throw new Error(`Неизвестная ошибка при скачивании файла конкурса: ${error.message}`);
} }
throw new Error(error.message);
} }
}; };

View File

@ -4,12 +4,7 @@ import CONFIG from '@/core/config.js'
const updateContest = async (profile) => { const updateContest = async (profile) => {
try { try {
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
// Убираем id из тела запроса, он идет в URL
const { id, ...profileData } = profile const { id, ...profileData } = profile
console.log('Отправляем на сервер:', profileData)
const response = await axios.put( const response = await axios.put(
`${CONFIG.BASE_URL}/contests/${id}/`, `${CONFIG.BASE_URL}/contests/${id}/`,
profileData, profileData,
@ -20,8 +15,6 @@ const updateContest = async (profile) => {
} }
} }
) )
console.log('Ответ от сервера:', response.data)
return response.data return response.data
} catch (error) { } catch (error) {
throw new Error(error.response?.data?.detail || error.message) throw new Error(error.response?.data?.detail || error.message)

View File

@ -7,7 +7,7 @@ const downloadPhotoFile = async (photoId) => {
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
const response = await axios.get( const response = await axios.get(
`${CONFIG.BASE_URL}/profile_photos/${photoId}/file`, `${CONFIG.BASE_URL}/profile_photos/${photoId}/file/`,
{ {
withCredentials: true, withCredentials: true,
headers: { headers: {

View File

@ -0,0 +1,33 @@
import axios from 'axios';
import CONFIG from '@/core/config.js';
const getProfileByUserId = async (profileId) => {
try {
const token = localStorage.getItem('access_token');
if (!profileId) {
throw new Error('ID профиля не указан.');
}
const response = await axios.get(
`${CONFIG.BASE_URL}/profiles/user/${profileId}/`,
{
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`
}
}
);
return response.data; // Возвращаем данные профиля
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message;
console.error(`Ошибка получения профиля ${profileId}:`, errorMessage);
if (error.response && error.response.status === 404) {
throw new Error(`Профиль с ID ${profileId} не найден.`);
} else {
throw new Error(`Не удалось загрузить профиль: ${errorMessage}`);
}
}
};
export default getProfileByUserId;

View File

@ -10,7 +10,7 @@ const uploadProfilePhoto = async (profileId, file) => {
formData.append('file', file) formData.append('file', file)
const response = await axios.post( const response = await axios.post(
`${CONFIG.BASE_URL}/profile_photos/profiles/${profileId}/upload`, `${CONFIG.BASE_URL}/profile_photos/profiles/${profileId}/upload/`,
formData, formData,
{ {
withCredentials: true, withCredentials: true,

View File

@ -4,7 +4,7 @@ import CONFIG from "@/core/config.js";
const fetchProjects = async () => { const fetchProjects = async () => {
try { try {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
const response = await axios.get(`${CONFIG.BASE_URL}/projects`, { const response = await axios.get(`${CONFIG.BASE_URL}/projects/`, {
headers: { headers: {
Authorization: `Bearer ${token}/`, Authorization: `Bearer ${token}/`,
}, },

View File

@ -1,43 +1,82 @@
import axios from 'axios'; import axios from 'axios';
import CONFIG from '@/core/config.js'; import CONFIG from '@/core/config.js';
const downloadProjectFile = async (fileId) => { // Добавляем параметры suggestedFileName и suggestedFileFormat
const downloadProjectFile = async (fileId, suggestedFileName, suggestedFileFormat) => {
try { try {
const response = await axios.get( const response = await axios.get(
`${CONFIG.BASE_URL}/project_files/${fileId}/download/`, `${CONFIG.BASE_URL}/project_files/${fileId}/file/`, // Убедитесь, что это правильный URL для скачивания самого файла
{ {
responseType: 'blob', responseType: 'blob', // Важно для бинарных данных
withCredentials: true, withCredentials: true, // Важно для аутентификации
} }
); );
const url = window.URL.createObjectURL(new Blob([response.data])); // Получаем MIME-тип файла из заголовков ответа
const contentType = response.headers['content-type'] || 'application/octet-stream';
// Создаем Blob с правильным MIME-типом
const blob = new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
let filename = `project_file_${fileId}`; // Запасное имя файла
// 1. Попытка получить имя файла из заголовка Content-Disposition
const contentDisposition = response.headers['content-disposition']; const contentDisposition = response.headers['content-disposition'];
let filename = `project_file_${fileId}`;
if (contentDisposition) { if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/); // Расширенное регулярное выражение для извлечения filename или filename* (с поддержкой UTF-8)
if (filenameMatch && filenameMatch[1]) { const filenameMatch = contentDisposition.match(/filename\*=(?:UTF-8'')?([^;]+)|filename="([^"]+)"/i);
if (filenameMatch) {
if (filenameMatch[1]) { // filename* (RFC 5987)
try {
filename = decodeURIComponent(filenameMatch[1]); filename = decodeURIComponent(filenameMatch[1]);
} catch (e) {
console.warn("Ошибка декодирования filename* из Content-Disposition:", e);
filename = filenameMatch[1]; // Используем как есть, если декодирование не удалось
}
} else if (filenameMatch[2]) { // Обычный filename
filename = filenameMatch[2];
} }
} }
}
// 2. Если имя файла всё ещё не идеально или не содержит расширения,
// используем переданные suggestedFileName и suggestedFileFormat как запасной вариант
if (!filename || filename === `project_file_${fileId}` || !filename.includes('.')) {
let finalName = suggestedFileName || `project_file_${fileId}`;
if (suggestedFileFormat && !finalName.toLowerCase().endsWith(`.${suggestedFileFormat.toLowerCase()}`)) {
finalName = `${finalName}.${suggestedFileFormat.toLowerCase()}`;
}
filename = finalName;
}
link.setAttribute('download', filename); link.setAttribute('download', filename);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.remove(); link.remove();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url); // Освобождаем память Blob URL
return filename; return filename;
} catch (error) { } catch (error) {
if (error.response?.status === 401) { // Улучшенная обработка ошибок для более информативных сообщений
throw new Error("Недостаточно прав для скачивания файла (401)"); if (error.response) {
if (error.response.status === 401) {
throw new Error("Недостаточно прав для скачивания файла (401). Пожалуйста, авторизуйтесь.");
} }
if (error.response?.status === 404) { if (error.response.status === 404) {
throw new Error("Файл не найден (404)"); throw new Error(`Файл с ID ${fileId} не найден (404). Возможно, он был удалён.`);
}
// Попробуем получить сообщение об ошибке из данных ответа, если это JSON
if (error.response.data && typeof error.response.data === 'object' && error.response.data.detail) {
throw new Error(`Ошибка сервера при скачивании файла: ${error.response.data.detail}`);
}
throw new Error(`Ошибка сети или сервера при скачивании файла: Статус ${error.response.status}`);
} else if (error.request) {
throw new Error("Не удалось отправить запрос на скачивание файла. Проверьте ваше интернет-соединение.");
} else {
throw new Error(`Неизвестная ошибка при скачивании файла: ${error.message}`);
} }
throw new Error(error.message);
} }
}; };

View File

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

View File

@ -183,6 +183,8 @@
@update:model-value="handleTeamIsActiveToggle" @update:model-value="handleTeamIsActiveToggle"
/> />
<template v-if="dialogType === 'teams'">
<div v-if="dialogData.id">
<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>
@ -212,7 +214,6 @@
accept="image/*" accept="image/*"
@update:model-value="handleNewTeamLogoSelected" @update:model-value="handleNewTeamLogoSelected"
class="q-mt-sm" class="q-mt-sm"
:rules="dialogData.id ? [] : [val => !!val || 'Логотип обязателен']"
> >
<template v-slot:append> <template v-slot:append>
<q-icon v-if="newTeamLogoFile" name="check" color="positive"/> <q-icon v-if="newTeamLogoFile" name="check" color="positive"/>
@ -227,6 +228,8 @@
@click="uploadNewTeamLogo" @click="uploadNewTeamLogo"
:loading="uploadingLogo" :loading="uploadingLogo"
/> />
</div>
</template>
</template> </template>
<template v-else-if="dialogType === 'profiles'"> <template v-else-if="dialogType === 'profiles'">
@ -234,8 +237,12 @@
<q-input v-model="dialogData.last_name" label="Фамилия" dense clearable class="q-mt-sm"/> <q-input v-model="dialogData.last_name" label="Фамилия" dense clearable class="q-mt-sm"/>
<q-input v-model="dialogData.patronymic" label="Отчество" dense clearable class="q-mt-sm"/> <q-input v-model="dialogData.patronymic" label="Отчество" dense clearable class="q-mt-sm"/>
<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" type="email"/>
<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"
mask="+7 (###) ###-##-##" unmask-value/>
<q-input v-model="dialogData.repository_url" label="URL репозитория" dense clearable class="q-mt-sm"
type="url"
/>
<q-select <q-select
v-model="dialogData.role_id" v-model="dialogData.role_id"
label="Роль" label="Роль"
@ -258,6 +265,8 @@
map-options map-options
/> />
<template v-if="dialogType === 'profiles'">
<div v-if="dialogData.id">
<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>
@ -269,7 +278,8 @@
Пока нет фотографий. Пока нет фотографий.
</div> </div>
<div v-else class="q-gutter-md q-mb-md row wrap justify-center"> <div v-else class="q-gutter-md q-mb-md row wrap justify-center">
<q-card v-for="photo in profilePhotos" :key="photo.id" class="col-auto" style="width: 120px; height: 120px; position: relative;"> <q-card v-for="photo in profilePhotos" :key="photo.id" class="col-auto"
style="width: 120px; height: 120px; position: relative;">
<q-img <q-img
:src="getPhotoUrl(photo.id, 'profile')" :src="getPhotoUrl(photo.id, 'profile')"
alt="Profile Photo" alt="Profile Photo"
@ -312,13 +322,18 @@
@click="uploadNewProfilePhoto" @click="uploadNewProfilePhoto"
:loading="uploadingPhoto" :loading="uploadingPhoto"
/> />
</div>
</template>
</template> </template>
<template v-else-if="dialogType === 'projects'"> <template v-else-if="dialogType === 'projects'">
<q-input v-model="dialogData.title" label="Название проекта" dense autofocus clearable/> <q-input v-model="dialogData.title" label="Название проекта" dense autofocus clearable/>
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm"/> <q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm"/>
<q-input v-model="dialogData.repository_url" label="URL репозитория" dense clearable class="q-mt-sm" /> <q-input v-model="dialogData.repository_url" label="URL репозитория" dense clearable class="q-mt-sm"
type="url"/>
<template v-if="dialogType === 'projects'">
<div v-if="dialogData.id">
<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>
@ -380,12 +395,14 @@
@click="uploadNewProjectFile" @click="uploadNewProjectFile"
:loading="uploadingFile" :loading="uploadingFile"
/> />
</div>
</template>
</template> </template>
<template v-else-if="dialogType === 'contests'"> <template v-else-if="dialogType === 'contests'">
<q-input v-model="dialogData.title" label="Название конкурса" dense autofocus clearable/> <q-input v-model="dialogData.title" label="Название конкурса" dense autofocus clearable/>
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm"/> <q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm"/>
<q-input v-model="dialogData.web_url" label="URL сайта" dense clearable class="q-mt-sm" /> <q-input v-model="dialogData.web_url" label="URL сайта" dense clearable class="q-mt-sm" type="url"/>
<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-select <q-select
@ -406,10 +423,12 @@
dense dense
clearable clearable
class="q-mt-sm" class="q-mt-sm"
:options="[{ label: 'Завершен', value: 1 }, { label: 'В процессе', value: 2 }, { label: 'Ожидает начала', value: 3 }]" emit-value :options="[{ label: 'Завершен', value: 1 }, { label: 'В процессе', value: 2 }, { label: 'Ожидает начала', value: 3 }]"
emit-value
map-options map-options
/> />
<div v-if="dialogData.id">
<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>
@ -421,7 +440,8 @@
Пока нет фотографий. Пока нет фотографий.
</div> </div>
<div v-else class="q-gutter-md q-mb-md row wrap justify-center"> <div v-else class="q-gutter-md q-mb-md row wrap justify-center">
<q-card v-for="photo in contestPhotos" :key="photo.id" class="col-auto" style="width: 120px; height: 120px; position: relative;"> <q-card v-for="photo in contestPhotos" :key="photo.id" class="col-auto"
style="width: 120px; height: 120px; position: relative;">
<q-img <q-img
:src="getPhotoUrl(photo.id, 'contest')" :src="getPhotoUrl(photo.id, 'contest')"
alt="Contest Photo" alt="Contest Photo"
@ -464,7 +484,9 @@
@click="uploadNewContestPhoto" @click="uploadNewContestPhoto"
:loading="uploadingPhoto" :loading="uploadingPhoto"
/> />
</div>
<div v-if="dialogData.id">
<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>
@ -526,6 +548,7 @@
@click="uploadNewContestFile" @click="uploadNewContestFile"
:loading="uploadingFile" :loading="uploadingFile"
/> />
</div>
</template> </template>
<template v-else-if="dialogType === 'project_members'"> <template v-else-if="dialogType === 'project_members'">
@ -625,8 +648,12 @@
<q-input v-model="newUserData.last_name" label="Фамилия" dense clearable class="q-mt-sm"/> <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.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.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.email" label="Почта" dense clearable class="q-mt-sm" type="email"/>
<q-input v-model="newUserData.phone" label="Телефон" dense clearable class="q-mt-sm" /> <q-input v-model="newUserData.phone" label="Телефон" dense clearable class="q-mt-sm" mask="+7 (###) ###-##-##"
unmask-value/>
<q-input v-model="dialogData.repository_url" label="URL репозитория" dense clearable class="q-mt-sm"
type="url"
/>
<q-select <q-select
v-model="newUserData.role_id" v-model="newUserData.role_id"
@ -726,6 +753,15 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-btn
:icon="'home'"
class="fixed-bottom-right q-mr-md"
style="bottom: 80px;" size="20px"
color="indigo-10"
round
@click="goToHomePage"
/>
<q-btn <q-btn
:icon="'logout'" :icon="'logout'"
class="fixed-bottom-right q-ma-md" class="fixed-bottom-right q-ma-md"
@ -769,12 +805,10 @@ import createContest from '@/api/contests/createContest.js'
import getProfilePhotosByProfileId from '@/api/profiles/profile_photos/getPhotoFileById.js' import getProfilePhotosByProfileId from '@/api/profiles/profile_photos/getPhotoFileById.js'
import uploadProfilePhoto from '@/api/profiles/profile_photos/uploadProfilePhoto.js' import uploadProfilePhoto from '@/api/profiles/profile_photos/uploadProfilePhoto.js'
import deleteProfilePhoto from '@/api/profiles/profile_photos/deletePhoto.js' import deleteProfilePhoto from '@/api/profiles/profile_photos/deletePhoto.js'
import downloadProfilePhotoFile from '@/api/profiles/profile_photos/downloadPhotoFile.js'
import getContestCarouselPhotosByContestId from '@/api/contests/contest_carousel_photos/getContestPhotoFileById.js' import getContestCarouselPhotosByContestId from '@/api/contests/contest_carousel_photos/getContestPhotoFileById.js'
import uploadContestCarouselPhoto from '@/api/contests/contest_carousel_photos/uploadContestProfilePhoto.js' import uploadContestCarouselPhoto from '@/api/contests/contest_carousel_photos/uploadContestProfilePhoto.js'
import deleteContestCarouselPhoto from '@/api/contests/contest_carousel_photos/deleteContestPhoto.js' import deleteContestCarouselPhoto from '@/api/contests/contest_carousel_photos/deleteContestPhoto.js'
import downloadContestCarouselPhotoFile from '@/api/contests/contest_carousel_photos/downloadContestPhotoFile.js'
import getContestFiles from '@/api/contests/contest_files/getContestFiles.js' import getContestFiles from '@/api/contests/contest_files/getContestFiles.js'
import uploadContestFile from '@/api/contests/contest_files/uploadContestFile.js' import uploadContestFile from '@/api/contests/contest_files/uploadContestFile.js'
@ -789,7 +823,6 @@ import downloadProjectFile from '@/api/projects/project_files/downloadProjectFil
import getProjectMemberByProject from "@/api/project_members/getProjectMemberByProject.js"; import getProjectMemberByProject from "@/api/project_members/getProjectMemberByProject.js";
import getProjectMemberByProfile from "@/api/project_members/getProjectMembersByProfile.js"; import getProjectMemberByProfile from "@/api/project_members/getProjectMembersByProfile.js";
import createProjectMember from "@/api/project_members/createProjectMember.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 updateProjectMember from "@/api/project_members/updateProjectMember.js";
import getAllProjectMembers from "@/api/project_members/getAllProjectMembers.js"; import getAllProjectMembers from "@/api/project_members/getAllProjectMembers.js";
import deleteSingleProjectMember from "@/api/project_members/deleteSingleProjectMember.js"; // НОВЫЙ ИМПОРТ import deleteSingleProjectMember from "@/api/project_members/deleteSingleProjectMember.js"; // НОВЫЙ ИМПОРТ
@ -810,6 +843,7 @@ const newUserData = ref({ // Модель для новых данных пол
birthday: null, // Используйте null для даты birthday: null, // Используйте null для даты
email: '', email: '',
phone: '', phone: '',
repository_url: '',
role_id: null, // null для q-select, если по умолчанию не выбрано role_id: null, // null для q-select, если по умолчанию не выбрано
team_id: null, // null для q-select team_id: null, // null для q-select
login: '', login: '',
@ -819,6 +853,11 @@ const confirmNewUserPassword = ref('');
const profiles = ref([]) const profiles = ref([])
const loadingProfiles = ref(false) const loadingProfiles = ref(false)
const rolesMap = {
1: 'Администратор',
2: 'Участник'
};
const profileColumns = [ const profileColumns = [
{name: 'first_name', label: 'Имя', field: 'first_name', sortable: true}, {name: 'first_name', label: 'Имя', field: 'first_name', sortable: true},
{name: 'last_name', label: 'Фамилия', field: 'last_name', sortable: true}, {name: 'last_name', label: 'Фамилия', field: 'last_name', sortable: true},
@ -826,8 +865,24 @@ const profileColumns = [
{name: 'birthday', label: 'День рождения', field: 'birthday', sortable: true}, {name: 'birthday', label: 'День рождения', field: 'birthday', sortable: true},
{name: 'email', label: 'Почта', field: 'email', sortable: true}, {name: 'email', label: 'Почта', field: 'email', sortable: true},
{name: 'phone', label: 'Телефон', field: 'phone', sortable: true}, {name: 'phone', label: 'Телефон', field: 'phone', sortable: true},
{ name: 'role_id', label: 'Роль', field: 'role_id', sortable: true }, {name: 'repository_url', label: 'URL репозитория', field: 'repository_url', sortable: true}, // ИСПРАВЛЕНО: Теперь ссылается на 'repository_url'
{ name: 'team_id', label: 'Команда', field: 'team_id', sortable: true }, {
name: 'role_id',
label: 'Роль',
field: 'role_id',
sortable: true,
format: val => rolesMap[val] || 'Неизвестно'
},
{
name: 'team_id',
label: 'Команда',
field: 'team_id',
sortable: true,
format: val => {
const team = teams.value.find(t => t.id === val);
return team ? team.title : 'Неизвестно';
}
},
] ]
const teams = ref([]) const teams = ref([])
@ -835,9 +890,14 @@ const loadingTeams = ref(false)
const teamColumns = [ const teamColumns = [
{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: 'logo', label: 'Логотип', field: 'logo', sortable: true },
{name: 'git_url', label: 'Git URL', field: 'git_url', sortable: true}, {name: 'git_url', label: 'Git URL', field: 'git_url', sortable: true},
{ name: 'is_active', label: 'Активна', field: 'is_active', sortable: true }, {
name: 'is_active',
label: 'Активна',
field: 'is_active',
sortable: true,
format: val => val ? 'Да' : 'Нет' // <-- Добавлено форматирование
},
] ]
const newTeamLogoFile = ref(null); const newTeamLogoFile = ref(null);
@ -854,14 +914,41 @@ const projectColumns = [
const contests = ref([]) const contests = ref([])
const loadingContests = ref(false) const loadingContests = ref(false)
const contestStatusesMap = {
1: 'Завершен',
2: 'В процессе',
3: 'Ожидает начала'
};
const contestColumns = [ 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: 'results', label: 'Результаты', field: 'results', 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: 'is_win',
{ name: 'status_id', label: 'Статус', field: 'status_id', sortable: true }, label: 'Победа',
field: 'is_win',
sortable: true,
format: val => val ? 'Да' : 'Нет' // <-- Добавлено форматирование
},
{
name: 'project_id',
label: 'Проект',
field: 'project_id',
sortable: true,
format: val => {
const project = allProjects.value.find(p => p.id === val);
return project ? project.title : 'Неизвестно'; // <-- Добавлено форматирование
}
},
{
name: 'status_id',
label: 'Статус',
field: 'status_id',
sortable: true,
format: val => contestStatusesMap[val] || 'Неизвестно' // <-- Добавлено форматирование
},
] ]
const projectMembers = ref([]) const projectMembers = ref([])
@ -923,15 +1010,12 @@ 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 '';
} }
@ -1061,11 +1145,11 @@ async function uploadNewTeamLogo() {
return; return;
} }
uploadingLogo.value = true; // Используем существующий индикатор uploadingLogo.value = true;
try { try {
const uploadedLogo = await uploadTeamLogo(dialogData.value.id, newTeamLogoFile.value); const uploadedLogo = await uploadTeamLogo(dialogData.value.id, newTeamLogoFile.value);
dialogData.value.logoUrl = `${CONFIG.BASE_URL}/teams/${dialogData.value.id}/logo`; dialogData.value.logoUrl = `${CONFIG.BASE_URL}/teams/${dialogData.value.id}/file/`;
newTeamLogoFile.value = null; // Очищаем выбранный файл newTeamLogoFile.value = null; // Очищаем выбранный файл
Notify.create({ Notify.create({
@ -1340,7 +1424,7 @@ function openEdit(type, row) {
if (row) { if (row) {
dialogData.value = JSON.parse(JSON.stringify(row)); dialogData.value = JSON.parse(JSON.stringify(row));
if (type === 'teams' && dialogData.value.logo) { if (type === 'teams' && dialogData.value.logo) {
dialogData.value.logoUrl = `${CONFIG.BASE_URL}/teams/${dialogData.value.id}/logo`; dialogData.value.logoUrl = `${CONFIG.BASE_URL}/teams/${dialogData.value.id}/file/`;
} else if (type === 'teams') { } else if (type === 'teams') {
dialogData.value.logoUrl = null; dialogData.value.logoUrl = null;
} }
@ -1358,10 +1442,29 @@ function openEdit(type, row) {
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: '',
repository_url: '',
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') { } else if (type === 'project_members') {
@ -1392,9 +1495,7 @@ function openEdit(type, row) {
Notify.create({type: 'negative', message: `Ошибка загрузки профилей для списка: ${error.message}`}); Notify.create({type: 'negative', message: `Ошибка загрузки профилей для списка: ${error.message}`});
}); });
} }
} } else {
else {
profilePhotos.value = []; profilePhotos.value = [];
contestPhotos.value = []; contestPhotos.value = [];
contestFiles.value = []; contestFiles.value = [];
@ -1423,6 +1524,16 @@ function closeDialog() {
async function saveChanges() { async function saveChanges() {
try { try {
if (dialogType.value === 'teams') { if (dialogType.value === 'teams') {
if (!dialogData.value.title || dialogData.value.title.trim() === '') {
Notify.create({type: 'negative', message: 'Название команды обязательно.', icon: 'error'});
return;
}
// Валидация Git URL, только если поле заполнено
if (dialogData.value.git_url && !/^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/.test(dialogData.value.git_url)) {
Notify.create({type: 'negative', message: 'Некорректный формат Git URL.', icon: 'error'});
return;
}
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);
@ -1432,33 +1543,107 @@ async function saveChanges() {
const newTeam = await createTeam(newTeamData); const newTeam = await createTeam(newTeamData);
teams.value.push(newTeam); teams.value.push(newTeam);
} }
closeDialog();
} else if (dialogType.value === 'projects') { } else if (dialogType.value === 'projects') {
if (dialogData.value.id) { if (!dialogData.value.title || dialogData.value.title.trim() === '') {
await updateProject(dialogData.value) Notify.create({type: 'negative', message: 'Название проекта обязательно.', icon: 'error'});
const idx = projects.value.findIndex(p => p.id === dialogData.value.id) return;
if (idx !== -1) projects.value[idx] = JSON.parse(JSON.stringify(dialogData.value))
} else {
const newProject = await createProject(dialogData.value)
projects.value.push(newProject)
} }
if (!dialogData.value.description || dialogData.value.description.trim() === '') {
Notify.create({type: 'negative', message: 'Описание проекта обязательно.', icon: 'error'});
return;
}
// Валидация URL репозитория, только если поле заполнено
if (dialogData.value.repository_url && !/^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/.test(dialogData.value.repository_url)) {
Notify.create({type: 'negative', message: 'Некорректный формат URL репозитория.', icon: 'error'});
return;
}
if (dialogData.value.id) {
await updateProject(dialogData.value);
const idx = projects.value.findIndex(p => p.id === dialogData.value.id);
if (idx !== -1) projects.value[idx] = JSON.parse(JSON.stringify(dialogData.value));
} else {
const newProject = await createProject(dialogData.value);
projects.value.push(newProject);
}
closeDialog();
} else if (dialogType.value === 'profiles') { } else if (dialogType.value === 'profiles') {
if (dialogData.value.id) { if (!dialogData.value.first_name || dialogData.value.first_name.trim() === '') {
await updateProfile(dialogData.value) Notify.create({type: 'negative', message: 'Имя пользователя обязательно.', icon: 'error'});
const idx = profiles.value.findIndex(p => p.id === dialogData.value.id) return;
if (idx !== -1) profiles.value[idx] = JSON.parse(JSON.stringify(dialogData.value))
} else {
const newProfile = await createProfile(dialogData.value)
profiles.value.push(newProfile)
} }
if (!dialogData.value.last_name || dialogData.value.last_name.trim() === '') {
Notify.create({type: 'negative', message: 'Фамилия пользователя обязательна.', icon: 'error'});
return;
}
if (!dialogData.value.birthday) {
Notify.create({type: 'negative', message: 'День рождения пользователя обязателен.', icon: 'error'});
return;
}
// Валидация email, только если поле заполнено
if (dialogData.value.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(dialogData.value.email)) {
Notify.create({type: 'negative', message: 'Некорректный формат электронной почты.', icon: 'error'});
return;
}
// Валидация номера телефона, только если поле заполнено (дополнительно, если хотите проверять формат помимо маски)
// Маска +7 (###) ###-##-## уже должна обеспечивать базовую валидацию
if (dialogData.value.phone && !/^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$/.test(dialogData.value.phone)) {
Notify.create({type: 'negative', message: 'Некорректный формат номера телефона.', icon: 'error'});
return;
}
// Валидация URL репозитория: поле необязательное, но если оно заполнено, то должно быть корректным URL.
// Если оно пустое, эта проверка пропускается, и значение null будет отправлено.
if (dialogData.value.repository_url && dialogData.value.repository_url.trim() !== '' && !/^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/.test(dialogData.value.repository_url)) {
Notify.create({type: 'negative', message: 'Некорректный формат URL репозитория.', icon: 'error'});
return;
} else if (dialogData.value.repository_url && dialogData.value.repository_url.trim() === '') {
// Если поле заполнено, но содержит только пробелы, делаем его null
dialogData.value.repository_url = null;
}
if (dialogData.value.id) {
// Вызов функции для обновления профиля. Убедитесь, что updateProfile принимает repository_url
await updateProfile(dialogData.value);
const idx = profiles.value.findIndex(p => p.id === dialogData.value.id);
if (idx !== -1) {
profiles.value[idx] = JSON.parse(JSON.stringify(dialogData.value));
}
} else {
// Вызов функции для создания профиля. Убедитесь, что createProfile принимает repository_url
const newProfile = await createProfile(dialogData.value);
profiles.value.push(newProfile);
}
closeDialog();
} else if (dialogType.value === 'contests') { } else if (dialogType.value === 'contests') {
if (dialogData.value.id) { if (!dialogData.value.title || dialogData.value.title.trim() === '') {
await updateContest(dialogData.value) Notify.create({type: 'negative', message: 'Название конкурса обязательно.', icon: 'error'});
const idx = contests.value.findIndex(c => c.id === dialogData.value.id) return;
if (idx !== -1) contests.value[idx] = JSON.parse(JSON.stringify(dialogData.value))
} else {
const newContest = await createContest(dialogData.value)
contests.value.push(newContest)
} }
// Валидация URL сайта, только если поле заполнено (обязательное поле, но проверка формата все равно нужна)
if (dialogData.value.web_url && !/^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/.test(dialogData.value.web_url)) {
Notify.create({type: 'negative', message: 'URL сайта конкурса должен быть корректной ссылкой.', icon: 'error'});
return;
}
if (!dialogData.value.project_id) {
Notify.create({type: 'negative', message: 'Проект конкурса обязателен.', icon: 'error'});
return;
}
if (!dialogData.value.status_id) {
Notify.create({type: 'negative', message: 'Статус конкурса обязателен.', icon: 'error'});
return;
}
if (dialogData.value.id) {
await updateContest(dialogData.value);
const idx = contests.value.findIndex(c => c.id === dialogData.value.id);
if (idx !== -1) contests.value[idx] = JSON.parse(JSON.stringify(dialogData.value));
} else {
const newContest = await createContest(dialogData.value);
contests.value.push(newContest);
}
closeDialog();
} else if (dialogType.value === 'project_members') { } else if (dialogType.value === 'project_members') {
const memberData = { const memberData = {
id: dialogData.value.id, id: dialogData.value.id,
@ -1467,13 +1652,19 @@ async function saveChanges() {
profile_id: parseInt(dialogData.value.profile_id) profile_id: parseInt(dialogData.value.profile_id)
}; };
const isDuplicate = projectMembers.value.some(pm => { if (!memberData.project_id) {
if (pm.id === memberData.id) { Notify.create({type: 'negative', message: 'Проект для участника проекта обязателен.', icon: 'error'});
return false; return;
} }
return pm.project_id === memberData.project_id && pm.profile_id === memberData.profile_id; if (!memberData.profile_id) {
Notify.create({type: 'negative', message: 'Профиль участника проекта обязателен.', icon: 'error'});
return;
}
const isDuplicate = projectMembers.value.some(pm => {
return pm.id !== memberData.id &&
pm.project_id === memberData.project_id &&
pm.profile_id === memberData.profile_id;
}); });
if (isDuplicate) { if (isDuplicate) {
Notify.create({ Notify.create({
type: 'warning', type: 'warning',
@ -1483,79 +1674,105 @@ async function saveChanges() {
return; return;
} }
if (dialogData.value.id) { if (dialogData.value.id) {
if (!memberData.project_id) { const originalMemberInList = projectMembers.value.find(pm => pm.id === dialogData.value.id);
Notify.create({ type: 'negative', message: 'Невозможно обновить: ID проекта для участника отсутствует.', icon: 'error' }); const originalProjectId = originalMemberInList ? originalMemberInList.project_id : null;
return; if (originalProjectId !== null && memberData.project_id !== originalProjectId) {
} const oldProjectMembers = await getProjectMemberByProject(originalProjectId) || [];
const updatedOldProjectMembers = oldProjectMembers.filter(pm => pm.id !== memberData.id);
await updateProjectMember(originalProjectId, updatedOldProjectMembers);
const newMemberPayload = {
description: memberData.description,
project_id: memberData.project_id,
profile_id: memberData.profile_id // Старый profile_id переносится
};
const newProjectMemberResponse = await createProjectMember(newMemberPayload);
await loadData('project_members');
} else {
const currentMembersForProject = await getProjectMemberByProject(memberData.project_id) || []; const currentMembersForProject = await getProjectMemberByProject(memberData.project_id) || [];
const updatedMembersList = currentMembersForProject.map(member => { const updatedMembersList = currentMembersForProject.map(member => {
if (member.id === memberData.id) { if (member.id === memberData.id) {
// Если ID совпадает, обновляем данные этой записи
return {...member, ...memberData}; return {...member, ...memberData};
} }
return member; return member;
}); });
await updateProjectMember(memberData.project_id, updatedMembersList); await updateProjectMember(memberData.project_id, updatedMembersList);
await loadData('project_members'); const idx = projectMembers.value.findIndex(pm => pm.id === memberData.id);
if (idx !== -1) {
projectMembers.value[idx] = JSON.parse(JSON.stringify(memberData));
}
}
} else { } else {
const newMemberPayload = { const newMemberPayload = {
description: memberData.description, description: memberData.description,
project_id: memberData.project_id, project_id: memberData.project_id,
profile_id: memberData.profile_id profile_id: memberData.profile_id
}; };
const newProjectMemberResponse = await createProjectMember(newMemberPayload); const newProjectMemberResponse = await createProjectMember(newMemberPayload);
projectMembers.value.push(newProjectMemberResponse[0] || newProjectMemberResponse); projectMembers.value.push(newProjectMemberResponse[0] || newProjectMemberResponse);
} }
closeDialog();
} }
closeDialog()
Notify.create({
type: 'positive',
message: 'Изменения успешно сохранены!',
icon: 'check_circle',
});
} catch (error) { } catch (error) {
console.error('Ошибка при сохранении:', error.message) console.error('Ошибка сохранения изменений:', error);
Notify.create({ Notify.create({
type: 'negative', type: 'negative',
message: `Ошибка при сохранении: ${error.message}`, message: `Ошибка сохранения: ${error.message || 'Неизвестная ошибка'}`,
icon: 'error', icon: 'error'
}); });
} }
} }
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() { async function saveNewUser() {
// Валидация входных данных const {first_name, last_name, birthday, email, phone, repository_url, role_id, team_id, login, password} = newUserData.value;
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', if (!first_name || first_name.trim() === '') {
message: 'Пожалуйста, заполните все обязательные поля (Имя, Фамилия, Почта, Логин, Пароль, Роль).', $q.notify({type: 'negative', message: 'Имя пользователя обязательно.', icon: 'error'});
icon: 'error', return;
}); }
if (!last_name || last_name.trim() === '') {
$q.notify({type: 'negative', message: 'Фамилия пользователя обязательна.', icon: 'error'});
return;
}
if (!birthday) { // День рождения теперь обязателен
$q.notify({type: 'negative', message: 'День рождения пользователя обязателен.', icon: 'error'});
return;
}
if (!login || login.trim() === '') {
$q.notify({type: 'negative', message: 'Логин пользователя обязателен.', icon: 'error'});
return;
}
if (!password) { // Пароль теперь обязателен
$q.notify({type: 'negative', message: 'Пароль пользователя обязателен.', icon: 'error'});
return;
}
if (!role_id) { // Роль теперь обязательна
$q.notify({type: 'negative', message: 'Роль пользователя обязательна.', icon: 'error'});
return;
}
if (!team_id) {
$q.notify({type: 'negative', message: 'Команда пользователя обязательна.', icon: 'error'});
return; return;
} }
// --- Валидация формата полей (если заполнено) ---
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
$q.notify({type: 'negative', message: 'Некорректный формат электронной почты.', icon: 'error'});
return;
}
// Валидация номера телефона, только если поле заполнено (дополнительно, если хотите проверять формат помимо маски)
if (phone && !/^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$/.test(phone)) {
$q.notify({type: 'negative', message: 'Некорректный формат номера телефона.', icon: 'error'});
return;
}
if (repository_url && !/^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/.test(dialogData.value.repository_url)) {
$q.notify({type: 'negative', message: 'Некорректный формат URL репозитория.', icon: 'error'});
return;
}
// --- Валидация пароля и его подтверждения ---
const validationError = validatePassword(newUserData.value.password); const validationError = validatePassword(newUserData.value.password);
if (validationError) { if (validationError) {
$q.notify({ $q.notify({
@ -1575,6 +1792,7 @@ async function saveNewUser() {
return; return;
} }
// --- Отправка данных ---
try { try {
const dataToSend = {...newUserData.value}; const dataToSend = {...newUserData.value};
if (dataToSend.birthday) { if (dataToSend.birthday) {
@ -1595,6 +1813,21 @@ async function saveNewUser() {
icon: 'check_circle', icon: 'check_circle',
}); });
createUserDialogVisible.value = false; createUserDialogVisible.value = false;
// Сброс полей формы после успешного создания
newUserData.value = {
first_name: '',
last_name: '',
patronymic: '',
birthday: null,
email: '',
phone: '',
repository_url: '',
role_id: null,
team_id: null,
login: '',
password: '',
};
confirmNewUserPassword.value = '';
await loadData('profiles'); // Обновляем список пользователей await loadData('profiles'); // Обновляем список пользователей
} catch (error) { } catch (error) {
console.error('Ошибка при создании пользователя:', error.message); console.error('Ошибка при создании пользователя:', error.message);
@ -1689,39 +1922,64 @@ async function saveNewPassword() {
} }
async function loadData(name) { async function loadData(name) {
if (name === 'teams') { if (allProjects.value.length === 0 && (name === 'contests' || name === 'project_members' || name === 'profiles')) {
loadingTeams.value = true
try { try {
teams.value = await fetchTeams() || [] allProjects.value = await fetchProjects() || [];
} catch (error) {
Notify.create({type: 'negative', message: `Ошибка загрузки всех проектов: ${error.message}`, icon: 'error'});
allProjects.value = [];
}
}
if (allProfiles.value.length === 0 && (name === 'profiles' || name === 'project_members')) {
try {
allProfiles.value = await fetchProfiles() || [];
} catch (error) {
Notify.create({type: 'negative', message: `Ошибка загрузки всех профилей: ${error.message}`, icon: 'error'});
allProfiles.value = [];
}
}
if (teams.value.length === 0 && (name === 'profiles' || name === 'teams')) {
try {
teams.value = await fetchTeams() || [];
} catch (error) {
Notify.create({type: 'negative', message: `Ошибка загрузки всех команд: ${error.message}`, icon: 'error'});
teams.value = [];
}
}
if (name === 'teams') {
loadingTeams.value = true;
try {
teams.value = await fetchTeams() || [];
} catch (error) { } catch (error) {
teams.value = []
Notify.create({ Notify.create({
type: 'negative', type: 'negative',
message: `Ошибка загрузки команд: ${error.message}`, message: `Ошибка загрузки команд: ${error.message}`,
icon: 'error', icon: 'error',
}); });
teams.value = [];
} finally { } finally {
loadingTeams.value = false loadingTeams.value = false;
} }
} else if (name === 'projects') { } else if (name === 'projects') {
loadingProjects.value = true loadingProjects.value = true;
try { try {
projects.value = await fetchProjects() || [] projects.value = await fetchProjects() || [];
} catch (error) { } catch (error) {
projects.value = [] projects.value = [];
Notify.create({ Notify.create({
type: 'negative', type: 'negative',
message: `Ошибка загрузки проектов: ${error.message}`, message: `Ошибка загрузки проектов: ${error.message}`,
icon: 'error', icon: 'error',
}); });
} finally { } finally {
loadingProjects.value = false loadingProjects.value = false;
} }
} else if (name === 'profiles') { }
else if (name === 'profiles') {
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({
@ -1750,11 +2008,6 @@ async function loadData(name) {
loadingProjectMembers.value = true; loadingProjectMembers.value = true;
try { try {
projectMembers.value = await getAllProjectMembers() || []; projectMembers.value = await getAllProjectMembers() || [];
// Эти запросы нужны для корректного отображения названий в таблице и q-select
allProjects.value = await fetchProjects() || [];
allProfiles.value = await fetchProfiles() || [];
} catch (error) { } catch (error) {
projectMembers.value = []; projectMembers.value = [];
allProjects.value = []; allProjects.value = [];
@ -1833,6 +2086,7 @@ async function createNewUserHandler() {
birthday: null, birthday: null,
email: '', email: '',
phone: '', phone: '',
repository_url: '',
role_id: null, role_id: null,
team_id: null, team_id: null,
login: '', login: '',
@ -1862,17 +2116,38 @@ function createHandler() {
openEdit(tab.value, null) openEdit(tab.value, null)
} }
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;
}
}
}
onMounted(() => { onMounted(() => {
loadData(tab.value) loadData(tab.value);
const interval = setInterval(() => { const interval = setInterval(() => {
loadData(tab.value) loadData(tab.value);
}, 5000) }, 5000);
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(interval) clearInterval(interval);
}) });
}) });
watch(tab, (newTab) => { watch(tab, (newTab) => {
loadData(newTab) loadData(newTab)
@ -1894,6 +2169,10 @@ const handleAuthAction = () => {
router.push('/') router.push('/')
} }
} }
const goToHomePage = () => {
router.push('/')
}
</script> </script>
<style scoped> <style scoped>

View File

@ -15,12 +15,6 @@
</div> </div>
<div v-else-if="contest" class="q-gutter-y-xl"> <div v-else-if="contest" class="q-gutter-y-xl">
<div class="flex justify-center q-mb-md">
<q-avatar size="140px" class="contest-logo shadow-12">
<img :src="contest.photo || 'https://cdn.quasar.dev/logo-v2/svg/logo.svg'" alt="Логотип конкурса"/>
</q-avatar>
</div>
<div class="flex justify-center q-mb-xl"> <div class="flex justify-center q-mb-xl">
<q-card class="contest-name-card"> <q-card class="contest-name-card">
<q-card-section class="text-h4 text-center text-indigo-10 q-pa-md"> <q-card-section class="text-h4 text-center text-indigo-10 q-pa-md">
@ -53,7 +47,7 @@
</q-card> </q-card>
</div> </div>
<div v-if="contest.carousel_photos && contest.carousel_photos.length > 0" class="flex justify-center q-mb-xl"> <div v-if="contestCarouselPhotos && contestCarouselPhotos.length > 0" class="flex justify-center q-mb-xl">
<q-card class="violet-card" style="max-width: 940px; width: 100%;"> <q-card class="violet-card" style="max-width: 940px; width: 100%;">
<q-card-section> <q-card-section>
<div class="text-h6 text-indigo-10 q-mb-md">Галерея</div> <div class="text-h6 text-indigo-10 q-mb-md">Галерея</div>
@ -71,24 +65,31 @@
class="rounded-borders" class="rounded-borders"
height="300px" height="300px"
> >
<q-carousel-slide v-for="(photo, index) in contest.carousel_photos" :key="index" :name="index + 1" :img-src="photo.url" /> <q-carousel-slide v-for="(photo, index) in contestCarouselPhotos" :key="index" :name="index + 1" :img-src="photo.url" />
</q-carousel> </q-carousel>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<div v-else-if="contest && !loading" class="flex justify-center q-mb-xl">
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
<q-card-section class="text-h6 text-indigo-10 text-center">
Для этого конкурса пока нет фотографий в галерее.
</q-card-section>
</q-card>
</div>
<div v-if="contest.files && contest.files.length > 0" class="flex justify-center q-mb-xl"> <div v-if="contestFiles && contestFiles.length > 0" class="flex justify-center q-mb-xl">
<q-card class="violet-card" style="max-width: 940px; width: 100%;"> <q-card class="violet-card" style="max-width: 940px; width: 100%;">
<q-card-section> <q-card-section>
<div class="text-h6 text-indigo-10 q-mb-md">Файлы конкурса</div> <div class="text-h6 text-indigo-10 q-mb-md">Файлы конкурса</div>
<q-list separator bordered class="rounded-borders"> <q-list separator bordered class="rounded-borders">
<q-item v-for="file in contest.files" :key="file.id" clickable v-ripple :href="file.url" target="_blank"> <q-item v-for="file in contestFiles" :key="file.id" clickable v-ripple @click="handleDownloadContestFile(file.id)">
<q-item-section avatar> <q-item-section avatar>
<q-icon name="folder_open" color="indigo-8" /> <q-icon name="folder_open" color="indigo-8" />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label>{{ file.name }}</q-item-label> <q-item-label>{{ file.name || `Файл ${file.id}` }}</q-item-label>
<q-item-label caption>{{ file.description }}</q-item-label> <q-item-label caption>{{ file.description || 'Нет описания' }}</q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
<q-icon name="download" color="indigo-6" /> <q-icon name="download" color="indigo-6" />
@ -98,42 +99,81 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<div v-else-if="contest && !loading" class="flex justify-center q-mb-xl">
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
<q-card-section class="text-h6 text-indigo-10 text-center">
Для этого конкурса пока нет файлов.
</q-card-section>
</q-card>
</div>
<div v-if="contestParticipants.length > 0" class="flex justify-center flex-wrap q-gutter-md q-mb-xl"> <div v-if="contestParticipants.length > 0" class="flex justify-center q-mb-md" style="width: 100%;">
<div class="flex justify-center q-mb-md" style="width: 100%;"></div> <q-card class="violet-card" style="max-width: 940px; width: 100%;">
<q-card-section>
<div class="text-h6 text-indigo-10 q-mb-md text-center">Участники конкурса</div>
<div class="flex justify-center flex-wrap q-gutter-md">
<q-card
v-for="member in contestParticipants"
:key="member.id"
class="member-card violet-card"
@click="router.push({ name: 'profile-detail', params: { id: member.id } })"
>
<q-card-section class="q-pa-md flex flex-center column">
<q-avatar v-if="member.avatar" size="70px" class="contest-logo shadow-6">
<img :src="member.avatar" :alt="member.name"/>
</q-avatar>
<q-avatar v-else size="70px" class="contest-logo shadow-6 bg-indigo-2 text-indigo-9">
{{ member.name.charAt(0) }}
</q-avatar>
<div class="text-subtitle1 text-center text-indigo-10 q-mt-sm">{{ member.name }}</div>
<div v-if="member.role" class="text-caption text-center text-indigo-8">
{{ member.role }}
</div>
</q-card-section>
</q-card>
</div>
</q-card-section>
</q-card>
</div>
<div v-else-if="contest && !loading" class="flex justify-center q-mb-xl">
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
<q-card-section class="text-h6 text-indigo-10 text-center">
У этого конкурса пока нет участников.
</q-card-section>
</q-card>
</div> </div>
<q-separator class="q-my-lg" color="indigo-4" style="width: 80%; margin: 0 auto;"/> <q-separator class="q-my-lg" color="indigo-4" style="width: 80%; margin: 0 auto;"/>
<div class="q-mt-md"></div> <div class="q-mt-md"></div>
<div v-if="contest.repository_url || contest.project_description || (contest.project_files && contest.project_files.length > 0)" class="flex justify-center q-mb-xl"> <div v-if="projectDetails.id" class="flex justify-center q-mb-xl">
<q-card class="violet-card" style="max-width: 940px; width: 100%;"> <q-card class="violet-card" style="max-width: 940px; width: 100%;">
<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 v-if="contest.project_description" class="q-mb-md text-indigo-9"> <div v-if="projectDetails.description" class="q-mb-md text-indigo-9">
<div class="text-subtitle1 text-indigo-10 q-mb-sm">Описание проекта</div> <div class="text-subtitle1 text-indigo-10 q-mb-sm">Описание проекта</div>
<div class="text-body1">{{ contest.project_description }}</div> <div class="text-body1">{{ projectDetails.description }}</div>
</div> </div>
<div v-if="contest.repository_url" class="q-mb-md text-indigo-9"> <div v-if="projectDetails.repository_url" class="q-mb-md text-indigo-9">
<div class="text-subtitle1 text-indigo-10 q-mb-sm">Репозиторий проекта</div> <div class="text-subtitle1 text-indigo-10 q-mb-sm">Репозиторий проекта</div>
<q-icon name="code" size="sm" class="q-mr-xs" /> <q-icon name="code" size="sm" class="q-mr-xs" />
<a :href="contest.repository_url" target="_blank" class="text-indigo-9" style="text-decoration: none; word-break: break-all;"> <a :href="projectDetails.repository_url" target="_blank" class="text-indigo-9" style="text-decoration: none; word-break: break-all;">
{{ contest.repository_url }} {{ projectDetails.repository_url }}
</a> </a>
</div> </div>
<div v-if="contest.project_files && contest.project_files.length > 0" class="q-mt-md"> <div v-if="projectFiles && projectFiles.length > 0" class="q-mt-md">
<div class="text-subtitle1 text-indigo-10 q-mb-sm">Файлы проекта</div> <div class="text-subtitle1 text-indigo-10 q-mb-sm">Файлы проекта</div>
<q-list separator bordered class="rounded-borders"> <q-list separator bordered class="rounded-borders">
<q-item v-for="file in contest.project_files" :key="file.id" clickable v-ripple :href="file.url" target="_blank"> <q-item v-for="file in projectFiles" :key="file.id" clickable v-ripple @click="handleDownloadProjectFile(file.id, file.name, file.file_format)">
<q-item-section avatar> <q-item-section avatar>
<q-icon name="folder_open" color="indigo-8" /> <q-icon name="folder_open" color="indigo-8" />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label>{{ file.name }}</q-item-label> <q-item-label>{{ file.name || `Файл ${file.id}` }}</q-item-label>
<q-item-label caption>{{ file.description }}</q-item-label> <q-item-label caption>{{ file.description || 'Нет описания' }}</q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
<q-icon name="download" color="indigo-6" /> <q-icon name="download" color="indigo-6" />
@ -141,10 +181,20 @@
</q-item> </q-item>
</q-list> </q-list>
</div> </div>
<div v-else-if="projectDetails.id && !projectDetails.description && !projectDetails.repository_url" class="text-body1 text-indigo-9 text-center">
Для этого проекта не указана информация и нет файлов.
</div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<div v-else-if="contest && !loading && !projectDetails.id" class="flex justify-center q-mb-xl">
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
<q-card-section class="text-h6 text-indigo-10 text-center">
Информация о проекте отсутствует.
</q-card-section>
</q-card>
</div>
</div> </div>
@ -156,11 +206,33 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Ripple, Notify } from 'quasar'; import { Ripple, Notify } from 'quasar';
import axios from 'axios';
import CONFIG from "@/core/config.js"; // --- Импорт API функций ---
import fetchContests from '@/api/contests/getContests.js';
// Для галереи конкурса
import getContestCarouselPhotosByContestId from '@/api/contests/contest_carousel_photos/getContestPhotoFileById.js';
import downloadContestCarouselPhotoFile from '@/api/contests/contest_carousel_photos/downloadContestPhotoFile.js'; // Убедитесь, что этот файл есть
// Для файлов конкурса
import getContestFilesByContestId from '@/api/contests/contest_files/getContestFiles.js';
import downloadContestFile from '@/api/contests/contest_files/downloadContestFile.js';
// Для связанного проекта
import getProjects from '@/api/projects/getProjects.js';
import getProjectFilesByProjectId from '@/api/projects/project_files/getProjectFiles.js'; // Ваш существующий API для файлов проекта
import downloadProjectFile from '@/api/projects/project_files/downloadProjectFile.js'; // API для скачивания файлов проекта
// Для участников проекта
import getProjectMembersByProject from '@/api/project_members/getProjectMemberByProject.js';
import fetchProfiles from '@/api/profiles/getProfiles.js';
import getPhotoFileById from '@/api/profiles/profile_photos/getPhotoFileById.js';
import downloadPhotoFile from '@/api/profiles/profile_photos/downloadPhotoFile.js';
import CONFIG from '@/core/config.js'; // Убедитесь, что импортирован CONFIG
// Директивы // Директивы
defineExpose({ directives: { ripple: Ripple } }); defineExpose({ directives: { ripple: Ripple } });
@ -175,97 +247,182 @@ const contestId = computed(() => route.params.id);
// --- Карусель фото --- // --- Карусель фото ---
const slide = ref(1); const slide = ref(1);
const contestCarouselPhotos = ref([]); // Содержит объекты { id, url }
// --- Участники конкурса (моковые данные) --- // --- Файлы конкурса ---
const contestParticipants = ref([ const contestFiles = ref([]); // Содержит список объектов файлов конкурса
{ id: 1, name: 'Иван Иванов', role: 'Team Lead', avatar: 'https://randomuser.me/api/portraits/men/32.jpg' },
{ id: 2, name: 'Мария Петрова', role: 'Frontend', avatar: 'https://randomuser.me/api/portraits/women/44.jpg' }, // --- Участники конкурса ---
{ id: 3, name: 'Алексей Смирнов', role: 'Backend', avatar: 'https://randomuser.me/api/portraits/men/65.jpg' }, const contestParticipants = ref([]);
// Добавьте больше участников или динамически загружайте их
]); // --- Информация о связанном проекте ---
const projectDetails = ref({}); // Будет хранить { id, description, repository_url, ... }
const projectFiles = ref([]); // Будет хранить список файлов проекта
// --- Загрузка данных конкурса --- // --- Загрузка данных конкурса ---
async function fetchContestDetails(id) { async function fetchContestDetails(id) {
loading.value = true; loading.value = true;
try { contest.value = null;
// В реальном приложении здесь будет API-запрос:
// const response = await axios.get(`${CONFIG.BASE_URL}/contests/${id}`);
// contest.value = response.data;
// Моковые данные для примера (замените на реальный fetch) // Очистка предыдущих данных и URL-ов Blob
const mockContests = [ contestCarouselPhotos.value.forEach(photo => {
{ if (photo.url && photo.url.startsWith('blob:')) {
id: 1, URL.revokeObjectURL(photo.url);
title: 'Hackathon 2024', }
description: 'Ежегодный хакатон для стартапов, где команды соревнуются в создании инновационных решений за короткий период времени. Фокус на Web3 и AI технологиях.',
web_url: 'https://example.com/hackathon2024',
repository_url: 'https://github.com/my-team/hackathon2024-solution',
project_description: 'Проект представляет собой децентрализованное приложение для управления задачами, использующее блокчейн для обеспечения прозрачности и искусственный интеллект для автоматического распределения задач между участниками команды.',
project_files: [
{ id: 101, name: 'Техническое задание.pdf', description: 'Полное описание требований', url: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' },
{ id: 102, name: 'Архитектура системы.pptx', description: 'Схема взаимодействия модулей', url: 'https://file-examples.com/wp-content/uploads/2017/02/file-example-PPT_10MB.ppt' },
],
photo: 'https://cdn.quasar.dev/img/parallax2.jpg',
results: '1 место в категории "Лучшее AI-решение"',
is_win: true,
carousel_photos: [
{ id: 1, url: 'https://images.unsplash.com/photo-1668796319088-214d6a82d54b?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
{ id: 2, url: 'https://cdn.quasar.dev/img/quasar.jpg' },
{ id: 3, url: 'https://cdn.quasar.dev/img/parallax1.jpg' },
{ id: 4, url: 'https://cdn.quasar.dev/img/donuts.png' },
{ id: 5, url: 'https://cdn.quasar.dev/img/parallax2.jpg' },
],
files: [
{ id: 1, name: 'Презентация конкурса.pdf', description: 'Финальная презентация', url: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' },
{ id: 2, name: 'Код проекта.zip', description: 'Исходный код', url: 'https://www.learningcontainer.com/wp-content/uploads/2020/07/Example-Zip-File.zip' },
],
project_id: 1,
status_id: 1,
},
{
id: 2,
title: 'CodeFest',
description: 'Масштабное соревнование по спортивному программированию, где участники решают алгоритмические задачи. Отличная возможность проверить свои навыки.',
web_url: 'https://codefest.org',
repository_url: 'https://gitlab.com/awesome-devs/codefest-challenge',
project_description: null, // No project description for this one
project_files: [], // No project files for this one
photo: 'https://cdn.quasar.dev/img/material.png',
results: null,
is_win: false,
carousel_photos: [
{ id: 10, url: 'https://images.unsplash.com/photo-1584291378203-674a462de8bc?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
{ id: 11, url: 'https://cdn.quasar.dev/img/chicken-salad.jpg' },
],
files: [],
project_id: 2,
status_id: 2,
},
// Добавьте другие моковые конкурсы по мере необходимости
];
contest.value = mockContests.find(c => c.id === parseInt(id));
if (!contest.value) {
Notify.create({
type: 'negative',
message: 'Конкурс с таким ID не найден.',
icon: 'warning',
}); });
contestCarouselPhotos.value = [];
contestFiles.value = [];
contestParticipants.value = [];
projectDetails.value = {};
projectFiles.value = [];
if (!id) {
Notify.create({ type: 'negative', message: 'ID конкурса не указан в маршруте.', icon: 'error' });
loading.value = false;
return;
}
try {
// 1. Получаем базовую информацию о конкурсе
const allContests = await fetchContests();
const foundContest = allContests.find(c => String(c.id) === String(id));
if (foundContest) {
contest.value = foundContest;
// 2. Загружаем фотографии карусели
try {
const photosMeta = await getContestCarouselPhotosByContestId(id);
if (photosMeta && photosMeta.length > 0) {
contestCarouselPhotos.value = await Promise.all(
photosMeta.map(async photo => {
try {
const photoBlob = await downloadContestCarouselPhotoFile(photo.id);
if (photoBlob instanceof Blob) {
const url = URL.createObjectURL(photoBlob);
return { id: photo.id, url: url };
}
} catch (innerError) {
console.warn(`Не удалось загрузить BLOB для фото карусели ${photo.id}:`, innerError);
}
return null;
})
).then(results => results.filter(p => p !== null));
}
} catch (photoError) {
console.warn(`Ошибка загрузки метаданных фотографий карусели для конкурса ${id}:`, photoError);
Notify.create({ type: 'warning', message: `Не удалось загрузить галерею: ${photoError.message}`, icon: 'warning' });
}
// 3. Загружаем файлы конкурса
try {
const files = await getContestFilesByContestId(id);
contestFiles.value = files;
} catch (filesError) {
console.warn(`Ошибка загрузки файлов конкурса для конкурса ${id}:`, filesError);
Notify.create({ type: 'warning', message: `Не удалось загрузить файлы конкурса: ${filesError.message}`, icon: 'warning' });
}
// 4. Загружаем информацию о связанном проекте и его участниках/файлах
if (contest.value.project_id) {
try {
const allProjects = await getProjects();
const projectData = allProjects.find(p => String(p.id) === String(contest.value.project_id));
if (projectData) {
projectDetails.value = projectData;
// Загружаем файлы проекта
try {
const pFiles = await getProjectFilesByProjectId(contest.value.project_id);
projectFiles.value = pFiles;
} catch (pFilesError) {
console.warn(`Ошибка загрузки файлов проекта для проекта ${contest.value.project_id}:`, pFilesError);
Notify.create({ type: 'warning', message: `Не удалось загрузить файлы проекта: ${pFilesError.message}`, icon: 'warning' });
}
// Загружаем участников проекта (которые и есть участники конкурса)
const projectMembersRaw = await getProjectMembersByProject(contest.value.project_id);
if (projectMembersRaw.length > 0) {
const allProfiles = await fetchProfiles();
const profilesMap = new Map(allProfiles.map(p => [p.id, p]));
contestParticipants.value = await Promise.all(
projectMembersRaw.map(async pm => {
const profile = profilesMap.get(pm.profile_id);
if (!profile) return null;
let avatarUrl = null;
try {
const photoData = await getPhotoFileById(profile.id);
if (photoData && photoData.length > 0) {
const firstPhotoId = photoData[0].id;
const photoBlob = await downloadPhotoFile(firstPhotoId);
if (photoBlob instanceof Blob) {
avatarUrl = URL.createObjectURL(photoBlob);
}
}
} catch (photoError) {
console.warn(`Ошибка загрузки фото профиля для участника ${profile.id}:`, photoError);
}
return {
id: profile.id,
name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Без имени',
role: pm.description || 'Участник проекта',
avatar: avatarUrl,
};
})
);
contestParticipants.value = contestParticipants.value.filter(p => p !== null);
}
} else {
console.warn(`Проект с ID ${contest.value.project_id} не найден.`);
Notify.create({ type: 'warning', message: `Связанный проект с ID ${contest.value.project_id} не найден.`, icon: 'warning' });
}
} catch (projectError) {
console.error(`Ошибка загрузки деталей проекта или участников для конкурса ${id} (project_id: ${contest.value.project_id}):`, projectError);
Notify.create({ type: 'negative', message: `Не удалось загрузить информацию о проекте или его участниках: ${projectError.message}`, icon: 'error' });
projectDetails.value = {};
projectFiles.value = [];
}
} else {
console.info(`Конкурс ${id} не имеет связанного project_id. Информация о проекте и его участниках не будет загружена.`);
}
} else {
Notify.create({ type: 'warning', message: 'Конкурс с таким ID не найден.', icon: 'warning' });
contest.value = null;
} }
} catch (error) { } catch (error) {
console.error('Ошибка загрузки деталей конкурса:', error); console.error('Ошибка загрузки деталей конкурса:', error);
Notify.create({ Notify.create({ type: 'negative', message: `Не удалось загрузить информацию о конкурсе: ${error.message}`, icon: 'error' });
type: 'negative', contest.value = null;
message: 'Не удалось загрузить информацию о конкурсе.',
icon: 'error',
});
contest.value = null; // Сброс, если ошибка
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
// Функция для обработки скачивания файлов конкурса
async function handleDownloadContestFile(fileId) { // Удалены fileName и fileFormat, так как они не используются в downloadContestFile
try {
await downloadContestFile(fileId);
Notify.create({ type: 'positive', message: 'Файл конкурса успешно загружен!', icon: 'check_circle' });
} catch (error) {
Notify.create({ type: 'negative', message: `Ошибка при скачивании файла конкурса: ${error.message}`, icon: 'error' });
}
}
// Функция для обработки скачивания файлов проекта
async function handleDownloadProjectFile(fileId, fileName, fileFormat) {
try {
await downloadProjectFile(fileId, fileName, fileFormat); // Если downloadProjectFile принимает эти параметры
Notify.create({ type: 'positive', message: 'Файл проекта успешно загружен!', icon: 'check_circle' });
} catch (error) {
Notify.create({ type: 'negative', message: `Ошибка при скачивании файла проекта: ${error.message}`, icon: 'error' });
}
}
onMounted(async () => { onMounted(async () => {
await fetchContestDetails(contestId.value); await fetchContestDetails(contestId.value);
}); });
@ -275,12 +432,33 @@ watch(contestId, async (newId) => {
await fetchContestDetails(newId); await fetchContestDetails(newId);
} }
}); });
// Очистка URL-ов Blob при размонтировании компонента
onUnmounted(() => {
// Очистка URL-ов Blob для аватаров участников
contestParticipants.value.forEach(member => {
if (member.avatar && member.avatar.startsWith('blob:')) {
URL.revokeObjectURL(member.avatar);
}
});
// Очистка URL-ов Blob для фотографий карусели конкурса
contestCarouselPhotos.value.forEach(photo => {
if (photo.url && photo.url.startsWith('blob:')) {
URL.revokeObjectURL(photo.url);
}
});
});
</script> </script>
<style scoped> <style scoped>
.contest-detail-page { .contest-detail-page {
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%); background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
min-height: 100vh; min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #3e2465;
overflow-x: hidden;
padding-bottom: 50px; /* Добавлен отступ снизу */
} }
.contest-logo { .contest-logo {
@ -300,10 +478,37 @@ watch(contestId, async (newId) => {
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10); box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
} }
.description-card {
max-width: 940px;
width: 100%;
}
.member-card { .member-card {
transition: transform 0.2s ease-in-out; cursor: pointer;
border-radius: 20px;
background: #fff;
box-shadow: 0 6px 20px rgba(124, 58, 237, 0.15), 0 2px 8px rgba(124, 58, 237, 0.1);
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
width: 180px; /* Фиксированная ширина */
min-height: 160px; /* Минимальная высота */
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
text-align: center;
} }
.member-card:hover { .member-card:hover {
transform: translateY(-5px); transform: translateY(-5px);
box-shadow: 0 12px 30px rgba(124, 58, 237, 0.25), 0 5px 15px rgba(124, 58, 237, 0.15);
}
.q-gutter-md > .member-card {
margin-bottom: 16px; /* Для равномерного отступа, если нужно */
}
/* Добавлены/скорректированы стили для лучшего отображения */
.text-h6.text-indigo-10.text-center {
color: #4f046f;
font-weight: bold;
} }
</style> </style>

View File

@ -26,28 +26,76 @@
@click="router.push({ name: 'profile-detail', params: { id: member.id } })" @click="router.push({ name: 'profile-detail', params: { id: member.id } })"
> >
<q-card-section class="q-pa-md flex flex-center"> <q-card-section class="q-pa-md flex flex-center">
<q-avatar size="64px" class="shadow-6"> <q-avatar v-if="member.avatar" size="80px" class="shadow-6 member-avatar-fix">
<img :src="member.avatar" :alt="member.name"/> <img :src="member.avatar" :alt="member.name"/>
</q-avatar> </q-avatar>
<q-avatar v-else size="80px" class="shadow-6 member-avatar-fix bg-indigo-2 text-indigo-9">
{{ member.name.charAt(0) }}
</q-avatar>
</q-card-section> </q-card-section>
<q-card-section class="q-pt-none"> <q-card-section class="q-pt-none">
<div class="text-subtitle1 text-center text-indigo-11">{{ member.name }}</div> <div class="text-subtitle1 text-center text-indigo-11">{{ member.name }}</div>
<div class="text-caption text-center text-indigo-9">{{ member.role }}</div> <div v-if="member.descriptions && member.descriptions.length">
<div
v-for="(desc, index) in member.descriptions"
:key="index"
class="text-caption text-center text-indigo-9"
>
{{ desc }}
</div>
</div>
</q-card-section>
</q-card>
<q-card
v-if="!members.length && teamName"
class="team-name-card flex flex-center"
style="max-width: 400px; padding: 20px;"
>
<q-card-section class="text-h6 text-indigo-10 text-center">
В этой команде пока нет участников.
</q-card-section>
</q-card>
<q-card
v-else-if="!teamName"
class="team-name-card flex flex-center"
style="max-width: 400px; padding: 20px;"
>
<q-card-section class="text-h6 text-indigo-10 text-center">
Выберите активную команду, чтобы увидеть участников.
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<div class="flex justify-center flex-wrap q-gutter-md q-mb-xl"> <div class="flex justify-center flex-wrap q-gutter-md q-mb-xl">
<q-card <q-card
v-for="contest in contests" v-for="project in projects"
:key="contest.id" :key="project.id"
class="contest-card violet-card" class="contest-card violet-card"
bordered bordered
style="width: 220px; cursor: pointer;" v-ripple style="width: 220px; cursor: pointer;" v-ripple
@click="router.push({ name: 'contest-detail', params: { id: contest.id } })" > @click="router.push({ name: 'project-detail', params: { id: project.id } })" >
<q-card-section class="q-pa-md"> <q-card-section class="q-pa-md">
<div class="text-h6">{{ contest.title }}</div> <div class="text-h6">{{ project.title }}</div>
<div class="text-subtitle2 text-indigo-8">{{ contest.description }}</div> <div class="text-subtitle2 text-indigo-8">{{ project.description }}</div>
<div v-if="project.memberCount" class="text-caption text-indigo-7">Участников: {{ project.memberCount }}</div>
</q-card-section>
</q-card>
<q-card
v-if="!projects.length && teamName"
class="team-name-card flex flex-center"
style="max-width: 400px; padding: 20px;"
>
<q-card-section class="text-h6 text-indigo-10 text-center">
В этой команде пока нет проектов.
</q-card-section>
</q-card>
<q-card
v-else-if="!teamName"
class="team-name-card flex flex-center"
style="max-width: 400px; padding: 20px;"
>
<q-card-section class="text-h6 text-indigo-10 text-center">
Выберите активную команду, чтобы увидеть проекты.
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
@ -122,14 +170,19 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Ripple, Notify } from 'quasar' import { Ripple, Notify } from 'quasar'
import axios from 'axios' import axios from 'axios'
import CONFIG from '@/core/config.js' import CONFIG from '@/core/config.js'
import fetchTeams from '@/api/teams/getTeams.js'
// --- Импорт API функций ---
import getActiveTeam from '@/api/teams/getActiveTeam.js'
import fetchProfiles from '@/api/profiles/getProfiles.js' import fetchProfiles from '@/api/profiles/getProfiles.js'
import fetchContests from '@/api/contests/getContests.js' import getPhotoFileById from '@/api/profiles/profile_photos/getPhotoFileById.js';
import downloadPhotoFile from '@/api/profiles/profile_photos/downloadPhotoFile.js';
import getProjectMembersByProfile from '@/api/project_members/getProjectMembersByProfile.js';
import getProjects from '@/api/projects/getProjects.js';
defineExpose({ directives: { ripple: Ripple } }) defineExpose({ directives: { ripple: Ripple } })
@ -148,6 +201,7 @@ const handleAuthAction = () => {
message: 'Выход успешно осуществлен', message: 'Выход успешно осуществлен',
icon: 'check_circle', icon: 'check_circle',
}) })
router.push('/login')
} else { } else {
router.push('/login') router.push('/login')
} }
@ -156,12 +210,15 @@ const handleAuthAction = () => {
// --- Данные команды --- // --- Данные команды ---
const teamLogo = ref('') const teamLogo = ref('')
const teamName = ref('') const teamName = ref('')
const activeTeamId = ref(null);
const teamRepositoryUrl = ref(null); // Новая переменная для URL репозитория команды
// --- Участники --- // --- Участники ---
const members = ref([]) const members = ref([])
// --- Конкурсы --- // --- Проекты ---
const contests = ref([]) const projects = ref([])
const allProjects = ref([]);
// --- Активность --- // --- Активность ---
const activityData = ref([]); const activityData = ref([]);
@ -175,146 +232,326 @@ function getDynamicMonthLabelsShort() {
]; ];
const currentMonthIndex = new Date().getMonth(); const currentMonthIndex = new Date().getMonth();
// Создаем массив из 12 элементов, затем используем map для получения названий месяцев const labels = [];
return Array.from({ length: 12 }, (_, i) => { for (let i = 0; i < 12; i++) {
const monthIndex = (currentMonthIndex + i) % 12; const monthIndex = (currentMonthIndex - (11 - i) + 12) % 12;
return monthNames[monthIndex]; labels.push(monthNames[monthIndex]);
}); }
return labels;
} }
const monthLabels = getDynamicMonthLabelsShort(); const monthLabels = getDynamicMonthLabelsShort();
// Дни недели (пн, ср, пт, как в Gitea)
const weekDays = ['пн', 'ср', 'пт']; const weekDays = ['пн', 'ср', 'пт'];
// Вычисляемая сетка активности (группировка по неделям)
const activityGrid = computed(() => { const activityGrid = computed(() => {
const weeks = []; const weeks = [];
let week = []; let week = [];
const firstDay const today = new Date();
const startDate = new Date(today);
startDate.setDate(today.getDate() - 364);
= new Date(); const firstDayOfWeekIndex = startDate.getDay();
firstDay.setDate(firstDay.getDate() - 364); // Год назад от текущей даты const offset = firstDayOfWeekIndex === 0 ? 6 : firstDayOfWeekIndex - 1;
const dayOfWeek = firstDay.getDay();
const offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Смещение для выравнивания по понедельнику
// Добавляем пустые ячейки в начало
for (let i = 0; i < offset; i++) { for (let i = 0; i < offset; i++) {
week.push({ date: '', count: 0 }); week.push({ date: '', count: 0 });
} }
for (let i = 0; i < 365; i++) { for (let i = 0; i < 365; i++) {
const date = new Date(firstDay); const date = new Date(startDate);
date.setDate(firstDay.getDate() + i); date.setDate(startDate.getDate() + i);
const dateStr = date.toISOString().slice(0, 10); const dateStr = date.toISOString().slice(0, 10);
const dayData = activityData.value.find(d => d.date === dateStr) || { date: dateStr, count: 0 }; const dayData = activityData.value.find(d => d.date === dateStr) || { date: dateStr, count: 0 };
week.push(dayData); week.push(dayData);
if (week.length === 7 || i === 364) { if (week.length === 7) {
weeks.push(week); weeks.push(week);
week = []; week = [];
} }
} }
if (week.length > 0) {
while (week.length < 7) {
week.push({ date: '', count: 0 });
}
weeks.push(week);
}
return weeks; return weeks;
}); });
// Цвета активности (как в Gitea)
function getActivityColor(count) { function getActivityColor(count) {
if (count === 0) return '#ede9fe'; // Светлый фон карточек if (count === 0) return '#ede9fe';
if (count <= 2) return '#d8cff9'; // Светлый сиреневый if (count <= 2) return '#d8cff9';
if (count <= 4) return '#a287ff'; // Светлый фиолетовый if (count <= 4) return '#a287ff';
if (count <= 6) return '#7c3aed'; // Яркий фиолетовый if (count <= 6) return '#7c3aed';
return '#4f046f'; // Темно-фиолетовый return '#4f046f';
} }
// Позиционирование подписей месяцев
function getMonthMargin(idx) { function getMonthMargin(idx) {
const daysInMonth = [30, 31, 31, 30, 31, 30, 31, 31, 28, 31, 30, 31]; // Дни в месяцах с июня 2024 const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const daysBeforeMonth = daysInMonth.slice(0, idx).reduce((sum, days) => sum + days, 0); const daysBeforeMonth = daysInMonth.slice(0, idx).reduce((sum, days) => sum + days, 0);
const weekIndex = Math.floor(daysBeforeMonth / 7); const weekIndex = Math.floor(daysBeforeMonth / 7);
return weekIndex * (squareSize.value + 4); // 4 = margin (2px + 2px) return weekIndex * (squareSize.value + 4);
} }
// Загрузка данных команды // Загрузка данных команды
async function loadTeamData() { async function loadTeamData() {
try { try {
const teams = await fetchTeams(); const activeTeam = await getActiveTeam();
const activeTeam = teams.find(team => team.is_active === true);
if (activeTeam) { if (activeTeam) {
teamName.value = activeTeam.title || 'Название не указано'; teamName.value = activeTeam.title || 'Название не указано';
teamLogo.value = activeTeam.logo || ''; teamLogo.value = activeTeam.logo ? `${CONFIG.BASE_URL}/teams/${activeTeam.id}/file/` : '';
activeTeamId.value = activeTeam.id;
// Вы также можете сохранить другие данные активной команды, если они нужны: // Сохраняем URL репозитория команды
// teamDescription.value = activeTeam.description || ''; teamRepositoryUrl.value = activeTeam.git_url || null;
// teamGitUrl.value = activeTeam.git_url || ''; console.log('loadTeamData: Active team ID:', activeTeamId.value);
console.log('loadTeamData: Team Repository URL:', teamRepositoryUrl.value);
} else { } else {
Notify.create({ Notify.create({
type: 'warning', type: 'warning',
message: 'Активная команда не найдена', message: 'Активная команда не найдена. Пожалуйста, создайте команду и сделайте её активной в разделе "Команды".',
icon: 'warning', icon: 'warning',
}); });
teamName.value = '';
teamLogo.value = '';
activeTeamId.value = null;
teamRepositoryUrl.value = null; // Сбрасываем URL
console.log('loadTeamData: No active team found.');
} }
} catch (error) { } catch (error) {
console.error('Ошибка загрузки данных команды:', error); console.error('Ошибка загрузки данных команды:', error);
Notify.create({ Notify.create({
type: 'negative', type: 'negative',
message: 'Ошибка загрузки данных команды', message: `Ошибка загрузки данных команды: ${error.message}`,
icon: 'error', icon: 'error',
}); });
teamName.value = '';
teamLogo.value = '';
activeTeamId.value = null;
teamRepositoryUrl.value = null;
} }
} }
// Загрузка участников // Загрузка участников и их проектов (привязанных к активной команде)
async function loadMembers() { async function loadMembersAndProjects() {
members.value.forEach(member => {
if (member.avatar && member.avatar.startsWith('blob:')) {
URL.revokeObjectURL(member.avatar);
}
});
members.value = [];
projects.value = [];
if (!activeTeamId.value) {
console.warn('activeTeamId не установлен. Не могу загрузить участников и проекты.');
return;
}
console.log('loadMembersAndProjects: Starting data fetch for team ID:', activeTeamId.value);
try { try {
const profiles = await fetchProfiles(); const fetchedProfiles = await fetchProfiles();
members.value = profiles.map(profile => ({ console.log('loadMembersAndProjects: Fetched all profiles:', fetchedProfiles);
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', // Аватар остался прежним
// Добавляем остальные поля, которые могут быть полезны const teamMembers = fetchedProfiles.filter(profile => profile.team_id === activeTeamId.value);
patronymic: profile.patronymic || '', console.log('loadMembersAndProjects: Filtered team members:', teamMembers);
birthday: profile.birthday || '',
email: profile.email || '', // Добавляем логирование содержимого профилей перед обработкой аватаров
phone: profile.phone || '', teamMembers.forEach(profile => {
team_id: profile.team_id || null, console.log(`loadMembersAndProjects: Профиль для обработки аватара - ID: ${profile.id}, Имя: ${profile.first_name}, repository_url: ${profile.repository_url}`);
})); });
} catch (error) {
console.error('Ошибка загрузки участников:', error); const uniqueProjects = new Map();
const fetchedProjects = await getProjects();
allProjects.value = fetchedProjects;
console.log('loadMembersAndProjects: Fetched all projects:', fetchedProjects);
members.value = await Promise.all(teamMembers.map(async profile => {
let avatarUrl = null;
let memberDescriptions = [];
console.log(`Processing profile: ${profile.id} (${profile.first_name} ${profile.last_name})`);
console.log(` Полные данные профиля:`, profile); // Логируем весь объект профиля
if (profile.id) {
// --- Логика загрузки аватара ---
try {
console.log(` Попытка загрузки фото для profile.id: ${profile.id}`);
const photoData = await getPhotoFileById(profile.id);
console.log(` Получены photoData для профиля ${profile.id}:`, photoData);
if (photoData && photoData.length > 0) {
const firstPhotoId = photoData[0].id;
console.log(` Найден firstPhotoId: ${firstPhotoId} для профиля ${profile.id}`);
const photoBlob = await downloadPhotoFile(firstPhotoId);
console.log(` Получен photoBlob для firstPhotoId ${firstPhotoId}:`, photoBlob);
if (photoBlob instanceof Blob) {
avatarUrl = URL.createObjectURL(photoBlob);
console.log(` Создан avatarUrl: ${avatarUrl}`);
} else {
console.warn(` Ошибка: downloadPhotoFile вернул не Blob для firstPhotoId ${firstPhotoId}. Тип: ${typeof photoBlob}`);
}
} else {
console.log(` Для профиля ${profile.id} нет photoData.`);
}
} catch (photoError) {
console.error(` Критическая ошибка при загрузке фото для профиля ${profile.id}:`, photoError);
Notify.create({ Notify.create({
type: 'negative', type: 'negative',
message: error.message || 'Ошибка загрузки участников', message: `Ошибка загрузки фото для ${profile.first_name || ''} ${profile.last_name || ''}: ${photoError.message}`,
icon: 'error', icon: 'error',
}); });
} }
// --- Логика получения описаний из project_members ---
try {
const projectMemberships = await getProjectMembersByProfile(profile.id);
console.log(` Profile ${profile.id}: Raw project_memberships:`, projectMemberships);
const relevantProjectMemberships = projectMemberships.filter(pm => {
const isProfileMatch = pm.profile_id === profile.id;
let isProjectRelevant = false;
let foundProject = null;
if (pm.project_id) {
foundProject = allProjects.value.find(p => p.id === pm.project_id);
if (foundProject) {
isProjectRelevant = (foundProject.team_id === activeTeamId.value || foundProject.team_id === null || foundProject.team_id === undefined);
console.log(` PM ${pm.id} (Project ${pm.project_id}): Found project. Project Team ID: ${foundProject.team_id}, Active Team ID: ${activeTeamId.value}. Is project relevant? ${isProjectRelevant}`);
} else {
console.log(` PM ${pm.id} (Project ${pm.project_id}): Project NOT found in allProjects. Considering profile's team instead for relevance.`);
isProjectRelevant = (profile.team_id === activeTeamId.value);
}
} else {
console.log(` PM ${pm.id}: No project_id on project_member. Considering profile's team for relevance.`);
isProjectRelevant = (profile.team_id === activeTeamId.value);
} }
// Загрузка конкурсов const result = isProfileMatch && isProjectRelevant;
async function loadContests() { console.log(` PM ${pm.id}: Profile match (${isProfileMatch}) && Project relevant (${isProjectRelevant}). ===> Filter result: ${result}`);
return result;
});
console.log(` Profile ${profile.id}: Relevant project_memberships (after detailed filter):`, relevantProjectMemberships);
// Добавляем только непустые описания из relevant project_members
relevantProjectMemberships.forEach(pm => {
console.log(` Processing project_member ID ${pm.id} for profile ${profile.id}. Description: "${pm.description}"`);
if (pm.description) {
const trimmedDesc = String(pm.description).trim();
if (trimmedDesc !== '') {
memberDescriptions.push(trimmedDesc);
console.log(` Added non-empty description from project_member: "${trimmedDesc}"`);
} else {
console.log(` Skipped empty description from project_member (trimmed empty): "${pm.description}"`);
}
} else {
console.log(` Skipped undefined/null description from project_member ID ${pm.id}`);
}
});
console.log(` Profile ${profile.id}: memberDescriptions from project_members after processing:`, memberDescriptions);
// Если нет описаний из project_members, используем profile.description, если оно непустое
if (memberDescriptions.length === 0 && profile.description) {
console.log(` Profile ${profile.id}: No descriptions from project_members. Checking profile.description: "${profile.description}"`);
const trimmedProfileDesc = String(profile.description).trim();
if (trimmedProfileDesc !== '') {
memberDescriptions.push(trimmedProfileDesc);
console.log(` Added non-empty description from profile: "${trimmedProfileDesc}"`);
} else {
console.log(` Skipped empty profile.description (trimmed empty): "${profile.description}"`);
}
}
console.log(` Profile ${profile.id}: Final memberDescriptions:`, memberDescriptions);
} catch (projectError) {
console.warn(`Не удалось загрузить проекты или описание для профиля ${profile.id}: ${projectError.message}`);
}
// --- Собираем проекты для команды ---
try { try {
const fetchedContests = await fetchContests(); const projectMembershipsForProjects = await getProjectMembersByProfile(profile.id);
contests.value = fetchedContests.map(contest => ({ projectMembershipsForProjects.forEach(pm => {
id: contest.id, const project = fetchedProjects.find(p => p.id === pm.project_id);
title: contest.title || 'Без названия', if (project && (project.team_id === activeTeamId.value || project.team_id === null || project.team_id === undefined)) {
description: contest.description || 'Описание отсутствует', if (!uniqueProjects.has(project.id)) {
uniqueProjects.set(project.id, { ...project, memberCount: 1 });
} else {
uniqueProjects.get(project.id).memberCount++;
}
}
});
} catch (projAggError) {
console.warn(`Ошибка при агрегации проектов для профиля ${profile.id}: ${projAggError.message}`);
}
}
return {
id: profile.id,
name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Без имени',
descriptions: memberDescriptions,
avatar: avatarUrl,
};
})); }));
console.log('loadMembersAndProjects: Final members array:', members.value);
projects.value = Array.from(uniqueProjects.values());
console.log('loadMembersAndProjects: Final projects array:', projects.value);
} catch (error) { } catch (error) {
console.error('Ошибка загрузки конкурсов:', error); console.error('Ошибка загрузки участников или проектов:', error);
Notify.create({ Notify.create({
type: 'negative', type: 'negative',
message: error.message || 'Ошибка загрузки конкурсов', message: error.message || 'Ошибка загрузки участников или проектов',
icon: 'error', icon: 'error',
}); });
} }
} }
// Загрузка активности // Загрузка активности команды
const username = 'archibald';
async function loadActivity() { async function loadActivity() {
activityData.value = [];
if (!activeTeamId.value) {
console.warn('loadActivity: activeTeamId не установлен. Не могу загрузить активность.');
// Заполнение активности нулями, если команда не выбрана
fillActivityWithZeros();
return;
}
let username = null;
if (teamRepositoryUrl.value) {
try {
const url = new URL(teamRepositoryUrl.value);
// Разбиваем путь на части и берем последнюю непустую
const pathParts = url.pathname.split('/').filter(part => part !== '');
username = pathParts[pathParts.length - 1]; // Берем последнюю часть
} catch (e) {
console.error('loadActivity: Ошибка парсинга URL репозитория:', e);
Notify.create({
type: 'negative',
message: 'Некорректный URL репозитория для активности команды.',
icon: 'error',
});
fillActivityWithZeros();
return;
}
}
if (!username) {
console.warn('loadActivity: URL репозитория команды отсутствует или не удалось извлечь username. Загрузка активности невозможна.');
Notify.create({
type: 'info',
message: 'URL репозитория для активной команды не указан. Активность не будет показана.',
icon: 'info',
});
fillActivityWithZeros();
return;
}
console.log(`loadActivity: Попытка загрузить активность для username: "${username}"`);
try { try {
const response = await axios.get(`${CONFIG.BASE_URL}/rss/${username}/`); const response = await axios.get(`${CONFIG.BASE_URL}/rss/${username}/`);
const fetchedData = response.data.map(item => ({ const fetchedData = response.data.map(item => ({
@ -334,15 +571,29 @@ async function loadActivity() {
const count = dataMap.get(dateStr) || 0; const count = dataMap.get(dateStr) || 0;
activityData.value.push({ date: dateStr, count }); activityData.value.push({ date: dateStr, count });
} }
console.log('loadActivity: Активность успешно загружена.');
} catch (error) { } catch (error) {
console.error('Ошибка загрузки активности:', error); console.error('loadActivity: Ошибка загрузки активности:', error);
let errorMessage = 'Ошибка загрузки данных активности.';
if (error.response && error.response.status === 404) {
errorMessage = `Активность для репозитория "${username}" не найдена. Возможно, указан неверный URL или репозиторий не существует.`;
} else {
errorMessage = `Ошибка загрузки данных активности: ${error.message || error.response?.data?.detail || 'Неизвестная ошибка'}`;
}
Notify.create({ Notify.create({
type: 'negative', type: 'negative',
message: 'Ошибка загрузки данных активности', message: errorMessage,
icon: 'error', icon: 'error',
timeout: 5000 // Увеличим время отображения для ошибки
}); });
// Заполняем пустыми данными в случае ошибки fillActivityWithZeros(); // Заполнение нулями в случае ошибки
}
}
// Вспомогательная функция для заполнения activityData нулями
function fillActivityWithZeros() {
const lastDate = new Date(); const lastDate = new Date();
const startDate = new Date(lastDate); const startDate = new Date(lastDate);
startDate.setDate(lastDate.getDate() - 364); startDate.setDate(lastDate.getDate() - 364);
@ -354,13 +605,31 @@ async function loadActivity() {
activityData.value.push({ date: dateStr, count: 0 }); activityData.value.push({ date: dateStr, count: 0 });
} }
} }
}
onMounted(async () => { onMounted(async () => {
await Promise.all([loadTeamData(), loadMembers(), loadContests(), loadActivity()]); await loadTeamData(); // Сначала загружаем данные команды, чтобы получить teamRepositoryUrl
// Затем загружаем активность, используя полученный URL
await Promise.allSettled([
loadMembersAndProjects(),
loadActivity()
]).then(results => {
results.forEach((result, index) => {
if (result.status === 'rejected') {
// Ошибки уже логируются внутри loadMembersAndProjects и loadActivity
}
});
});
});
onUnmounted(() => {
members.value.forEach(member => {
if (member.avatar && member.avatar.startsWith('blob:')) {
URL.revokeObjectURL(member.avatar);
}
});
}); });
// Масштабирование
function increaseScale() { function increaseScale() {
if (squareSize.value < 24) squareSize.value += 2; if (squareSize.value < 24) squareSize.value += 2;
} }
@ -371,6 +640,10 @@ function decreaseScale() {
</script> </script>
<style scoped> <style scoped>
.member-avatar-fix img {
object-fit: cover;
}
/* Остальные стили без изменений */
.activity-grid { .activity-grid {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -451,7 +724,7 @@ function decreaseScale() {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
margin-left: 40px; /* Синхронизация с .weekdays-column */ margin-left: 40px;
margin-bottom: 24px !important; margin-bottom: 24px !important;
position: relative; position: relative;
} }
@ -460,9 +733,9 @@ function decreaseScale() {
text-align: left; text-align: left;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
position: absolute; /* Для точного позиционирования */ position: absolute;
} }
.weekdays-column { .weekdays-column {
margin-top: -15px; /* Попробуйте разные значения, например, -10px, -30px, пока не найдете оптимальное */ margin-top: -15px;
} }
</style> </style>

View File

@ -0,0 +1,498 @@
<template>
<q-page class="project-detail-page bg-violet-strong q-pa-lg">
<q-btn
icon="arrow_back"
label="Вернуться назад"
flat
color="white"
@click="router.back()"
class="q-mb-xl back-btn"
/>
<div v-if="loading" class="flex flex-center q-pt-xl" style="min-height: 50vh;">
<q-spinner-dots color="white" size="6em" />
</div>
<div v-else-if="project" class="q-gutter-y-xl page-content">
<div class="flex justify-center q-mb-md">
<q-card class="section-card project-title-card">
<q-card-section class="text-h3 text-center text-indigo-10 q-pa-lg">
{{ project.title }}
</q-card-section>
</q-card>
</div>
<div class="flex justify-center q-mb-xl">
<q-card class="section-card violet-card">
<q-card-section>
<div class="text-h5 text-indigo-10 q-mb-md section-heading">Описание проекта</div>
<div class="text-body1 text-indigo-9 q-mb-md">{{ project.description }}</div>
<div v-if="project.web_url" class="q-mt-md text-indigo-9">
<q-icon name="link" size="sm" class="q-mr-xs" />
<a :href="project.web_url" target="_blank" class="text-indigo-9 link-style">
Перейти на сайт проекта
</a>
</div>
</q-card-section>
</q-card>
</div>
<div v-if="project.repository_url" class="flex justify-center q-mb-xl">
<q-card class="section-card violet-card">
<q-card-section class="q-pa-md">
<div class="text-h5 text-indigo-10 q-mb-md section-heading">Репозиторий проекта</div>
<q-icon name="code" size="sm" class="q-mr-xs" />
<a :href="project.repository_url" target="_blank" class="text-indigo-9 link-style" style="word-break: break-all;">
{{ project.repository_url }}
</a>
</q-card-section>
</q-card>
</div>
<div v-if="projectFiles.length > 0" class="flex justify-center q-mb-xl">
<q-card class="section-card violet-card">
<q-card-section>
<div class="text-h5 text-indigo-10 q-mb-md section-heading">Файлы проекта</div>
<div class="row q-col-gutter-md">
<div
v-for="file in projectFiles"
:key="file.id"
class="col-xs-12 col-sm-6 col-md-4"
>
<q-card class="file-card violet-card q-hoverable">
<q-card-section class="q-pa-md flex items-center no-wrap">
<q-icon name="description" color="indigo-8" size="md" class="q-mr-md" />
<div class="col ellipsis-2-lines">
<div class="text-subtitle1 text-indigo-10 file-title">{{ file.title || `Файл ${file.id}` }}</div>
<div class="text-caption text-indigo-8">{{ file.file_format ? `.${file.file_format}` : 'Неизвестный формат' }}</div>
</div>
<q-btn flat round icon="download" color="indigo-6" size="sm" class="q-ml-sm" @click.stop="handleDownloadFile(file.id, file.title, file.file_format)" />
</q-card-section>
</q-card>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div v-else-if="project && !loading" class="flex justify-center q-mb-xl">
<q-card class="section-card violet-card text-center no-data-card">
<q-card-section class="text-h6 text-indigo-10">
Для этого проекта пока нет файлов.
</q-card-section>
</q-card>
</div>
<div class="flex justify-center q-mb-md">
<q-card class="section-card section-heading-card">
<q-card-section class="text-h5 text-center text-indigo-10 q-pa-md">
Участники проекта
</q-card-section>
</q-card>
</div>
<div v-if="projectParticipants.length > 0" class="flex justify-center flex-wrap q-gutter-lg q-mb-xl">
<q-card
v-for="member in projectParticipants"
:key="member.id"
class="member-card violet-card"
@click="router.push({ name: 'profile-detail', params: { id: member.id } })"
>
<q-card-section class="q-pa-md flex flex-center column">
<q-avatar v-if="member.avatar" size="70px" class="member-avatar">
<img :src="member.avatar" :alt="member.name"/>
</q-avatar>
<q-avatar v-else size="70px" class="member-avatar bg-indigo-2 text-indigo-9">
{{ member.name.charAt(0) }}
</q-avatar>
<div class="text-subtitle1 text-center text-indigo-11 q-mt-sm ellipsis-2-lines member-name">{{ member.name }}</div>
<div v-if="member.role" class="text-caption text-center text-indigo-9 ellipsis-1-line member-role">
{{ member.role }}
</div>
</q-card-section>
</q-card>
</div>
<div v-else-if="project && !loading" class="flex justify-center q-mb-xl">
<q-card class="section-card violet-card text-center no-data-card">
<q-card-section class="text-h6 text-indigo-10">
У этого проекта пока нет участников.
</q-card-section>
</q-card>
</div>
<div class="flex justify-center q-mb-md">
<q-card class="section-card section-heading-card">
<q-card-section class="text-h5 text-center text-indigo-10 q-pa-md">
Конкурсы проекта
</q-card-section>
</q-card>
</div>
<div v-if="projectContests.length > 0" class="flex justify-center flex-wrap q-gutter-lg q-mb-xl">
<q-card
v-for="contest in projectContests"
:key="contest.id"
class="contest-card violet-card"
@click="router.push({ name: 'contest-detail', params: { id: contest.id } })"
>
<q-card-section class="q-pa-md">
<div class="text-h6 text-indigo-10 q-mb-sm ellipsis-2-lines contest-title">{{ contest.title }}</div>
<div class="text-subtitle2 text-indigo-8 ellipsis-3-lines q-mb-sm contest-description">{{ contest.description }}</div>
<div v-if="contest.results" class="text-caption text-indigo-7 q-mt-sm">
<q-icon name="emoji_events" size="xs" class="q-mr-xs" />
Результаты: <span class="text-weight-bold">{{ contest.results }}</span>
</div>
<div v-if="contest.is_win" class="text-caption text-positive q-mt-xs">
<q-icon name="celebration" size="xs" class="q-mr-xs" />
<span class="text-weight-bold">Победа!</span>
</div>
</q-card-section>
</q-card>
</div>
<div v-else-if="project && !loading" class="flex justify-center q-mb-xl">
<q-card class="section-card violet-card text-center no-data-card">
<q-card-section class="text-h6 text-indigo-10">
Этот проект пока не участвовал в конкурсах.
</q-card-section>
</q-card>
</div>
</div>
<div v-else class="flex flex-center q-pt-xl text-white text-h5" style="min-height: 50vh;">
Проект не найден :(
</div>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Ripple, Notify } from 'quasar';
// --- Импорт API функций ---
import getProjects from '@/api/projects/getProjects.js';
import getProjectMembersByProject from '@/api/project_members/getProjectMemberByProject.js';
import fetchProfiles from '@/api/profiles/getProfiles.js';
import getPhotoFileById from '@/api/profiles/profile_photos/getPhotoFileById.js';
import downloadPhotoFile from '@/api/profiles/profile_photos/downloadPhotoFile.js';
import getProjectFiles from '@/api/projects/project_files/getProjectFiles.js';
import downloadProjectFile from '@/api/projects/project_files/downloadProjectFile.js';
import fetchContests from '@/api/contests/getContests.js';
// Директивы
defineExpose({ directives: { ripple: Ripple } });
const route = useRoute();
const router = useRouter();
// --- Состояние проекта ---
const project = ref(null);
const loading = ref(true);
const projectId = computed(() => route.params.id);
// --- Участники проекта ---
const projectParticipants = ref([]);
// --- Файлы проекта ---
const projectFiles = ref([]);
// --- Конкурсы проекта ---
const projectContests = ref([]);
// --- Загрузка данных проекта ---
async function fetchProjectDetails(id) {
loading.value = true;
project.value = null;
projectParticipants.value = [];
projectFiles.value = [];
projectContests.value = [];
if (!id) {
Notify.create({ type: 'negative', message: 'ID проекта не указан в маршруте.', icon: 'error' });
loading.value = false;
return;
}
try {
const allProjects = await getProjects();
const foundProject = allProjects.find(p => String(p.id) === String(id));
if (foundProject) {
project.value = foundProject;
const membersRaw = await getProjectMembersByProject(id);
if (membersRaw.length > 0) {
const allProfiles = await fetchProfiles();
const profilesMap = new Map(allProfiles.map(p => [p.id, p]));
projectParticipants.value = await Promise.all(
membersRaw.map(async pm => {
const profile = profilesMap.get(pm.profile_id);
if (!profile) return null;
let avatarUrl = null;
try {
const photoData = await getPhotoFileById(profile.id);
if (photoData && photoData.length > 0) {
const firstPhotoId = photoData[0].id;
const photoBlob = await downloadPhotoFile(firstPhotoId);
if (photoBlob instanceof Blob) {
avatarUrl = URL.createObjectURL(photoBlob);
}
}
} catch (photoError) {
console.warn(`Ошибка загрузки фото для профиля ${profile.id}:`, photoError);
}
return {
id: profile.id,
name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Без имени',
role: pm.description || 'Участник',
avatar: avatarUrl,
};
})
);
projectParticipants.value = projectParticipants.value.filter(p => p !== null);
}
try {
projectFiles.value = await getProjectFiles(id);
// --- !!! ВАЖНО ДЛЯ ОТЛАДКИ !!! ---
console.log("Загруженные файлы проекта:", projectFiles.value);
// --- !!! ВАЖНО ДЛЯ ОТЛАДКИ !!! ---
} catch (fileError) {
Notify.create({ type: 'negative', message: `Ошибка при загрузке файлов проекта: ${fileError.message}`, icon: 'error' });
}
try {
const allContests = await fetchContests();
projectContests.value = allContests.filter(contest =>
contest.project_id && String(contest.project_id) === String(id)
);
} catch (contestError) {
Notify.create({ type: 'negative', message: `Ошибка при загрузке конкурсов: ${contestError.message}`, icon: 'error' });
}
} else {
Notify.create({ type: 'warning', message: 'Проект с таким ID не найден.', icon: 'warning' });
project.value = null;
}
} catch (error) {
console.error('Ошибка загрузки деталей проекта:', error);
Notify.create({ type: 'negative', message: `Не удалось загрузить информацию о проекте: ${error.message}`, icon: 'error' });
project.value = null;
} finally {
loading.value = false;
}
}
async function handleDownloadFile(fileId, fileName, fileFormat) {
try {
await downloadProjectFile(fileId, fileName, fileFormat);
Notify.create({ type: 'positive', message: 'Файл успешно загружен!', icon: 'check_circle' });
} catch (error) {
Notify.create({ type: 'negative', message: `Ошибка при скачивании файла: ${error.message}`, icon: 'error' });
}
}
onMounted(async () => {
await fetchProjectDetails(projectId.value);
});
watch(projectId, async (newId) => {
if (newId) {
await fetchProjectDetails(newId);
}
});
onUnmounted(() => {
projectParticipants.value.forEach(member => {
if (member.avatar && member.avatar.startsWith('blob:')) {
URL.revokeObjectURL(member.avatar);
}
});
});
</script>
<style scoped>
.project-detail-page {
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #3e2465;
overflow-x: hidden;
padding-bottom: 50px;
}
.back-btn {
font-size: 1.1rem;
font-weight: 500;
transition: transform 0.2s ease-in-out;
}
.back-btn:hover {
transform: translateX(-5px);
}
.page-content {
max-width: 1200px;
margin: 0 auto;
}
.section-card {
border-radius: 25px;
background: #ede9fe;
box-shadow: 0 10px 30px rgba(124, 58, 237, 0.25), 0 4px 12px rgba(124, 58, 237, 0.15);
padding: 15px;
width: 100%;
max-width: 900px;
transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
}
.section-card:hover {
transform: translateY(-8px);
box-shadow: 0 18px 45px rgba(124, 58, 237, 0.35), 0 8px 20px rgba(124, 58, 237, 0.25);
}
.project-title-card {
background: #ede9fe;
border-radius: 30px;
box-shadow: 0 12px 35px rgba(124, 58, 237, 0.3), 0 6px 15px rgba(124, 58, 237, 0.2);
}
.section-heading-card {
background: #ede9fe;
border-radius: 25px;
}
.section-heading {
font-weight: 700;
border-bottom: 2px solid rgba(124, 58, 237, 0.3);
padding-bottom: 10px;
margin-bottom: 20px;
}
.link-style {
color: #4f046f !important;
text-decoration: none;
font-weight: 600;
transition: color 0.2s ease-in-out, text-decoration 0.2s ease-in-out;
}
.link-style:hover {
color: #7c3aed !important;
text-decoration: underline;
}
.file-card {
border-radius: 15px;
background: #fff;
box-shadow: 0 4px 15px rgba(124, 58, 237, 0.1), 0 1px 5px rgba(124, 58, 237, 0.08);
cursor: pointer;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
display: flex;
align-items: center;
min-height: 80px;
}
.file-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(124, 58, 237, 0.2), 0 3px 10px rgba(124, 58, 237, 0.12);
}
.file-title {
font-weight: 600;
color: #4f046f;
}
.member-card, .contest-card {
cursor: pointer;
border-radius: 20px;
background: #fff;
box-shadow: 0 6px 20px rgba(124, 58, 237, 0.15), 0 2px 8px rgba(124, 58, 237, 0.1);
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
flex-basis: calc(33.33% - 24px);
max-width: 280px;
}
.member-card:hover, .contest-card:hover {
transform: translateY(-7px);
box-shadow: 0 12px 30px rgba(124, 58, 237, 0.25), 0 5px 15px rgba(124, 58, 237, 0.15);
}
.member-card {
width: 180px;
min-height: 160px;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
text-align: center;
}
.member-avatar {
border: none;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s ease-in-out;
}
.member-avatar:hover {
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.15);
}
.member-name {
font-weight: 600;
color: #4f046f;
max-width: 100%;
}
.member-role {
color: #7c3aed;
font-size: 0.85rem;
max-width: 100%;
}
.contest-card {
width: 320px;
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.contest-title {
font-weight: 700;
color: #4f046f;
}
.contest-description {
color: #7c3aed;
font-size: 0.9rem;
}
.no-data-card {
background: #ede9fe;
border-radius: 20px;
padding: 20px;
max-width: 500px;
width: 100%;
box-shadow: none;
color: #7c3aed;
}
.ellipsis-1-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ellipsis-2-lines {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.ellipsis-3-lines {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -0,0 +1,707 @@
<template>
<q-page class="team-detail-page bg-violet-strong q-pa-md">
<q-btn
icon="arrow_back"
label="Обратно на страницу"
flat
color="white"
@click="router.back()"
class="q-mb-lg"
/>
<div class="flex justify-center q-mb-md">
<q-avatar v-if="teamLogo" size="140px" class="team-logo shadow-12">
<img :src="teamLogo" alt="Логотип команды"/>
</q-avatar>
</div>
<div class="flex justify-center q-mb-xl">
<q-card v-if="teamName" class="team-name-card">
<q-card-section class="text-h4 text-center text-indigo-10 q-pa-md">
{{ teamName }}
</q-card-section>
</q-card>
</div>
<div class="flex justify-center flex-wrap q-gutter-md q-mb-xl">
<q-card
v-for="member in members"
:key="member.id"
class="member-card violet-card"
bordered
style="width: 180px; cursor: pointer;"
v-ripple
@click="router.push({ name: 'profile-detail', params: { id: member.id } })"
>
<q-card-section class="q-pa-md flex flex-center">
<q-avatar v-if="member.avatar" size="80px" class="shadow-6 member-avatar-fix">
<img :src="member.avatar" :alt="member.name"/>
</q-avatar>
<q-avatar v-else size="80px" class="shadow-6 member-avatar-fix bg-indigo-2 text-indigo-9">
{{ member.name.charAt(0) }}
</q-avatar>
</q-card-section>
<q-card-section class="q-pt-none">
<div class="text-subtitle1 text-center text-indigo-11">{{ member.name }}</div>
<div v-if="member.descriptions && member.descriptions.length">
<div
v-for="(desc, index) in member.descriptions"
:key="index"
class="text-caption text-center text-indigo-9"
>
{{ desc }}
</div>
</div>
</q-card-section>
</q-card>
<q-card
v-if="!members.length && teamName"
class="team-name-card flex flex-center"
style="max-width: 400px; padding: 20px;"
>
<q-card-section class="text-h6 text-indigo-10 text-center">
В этой команде пока нет участников.
</q-card-section>
</q-card>
<q-card
v-else-if="!teamName"
class="team-name-card flex flex-center"
style="max-width: 400px; padding: 20px;"
>
<q-card-section class="text-h6 text-indigo-10 text-center">
Загрузка данных команды...
</q-card-section>
</q-card>
</div>
<div class="flex justify-center flex-wrap q-gutter-md q-mb-xl">
<q-card
v-for="project in projects"
:key="project.id"
class="contest-card violet-card"
bordered
style="width: 220px; cursor: pointer;" v-ripple
@click="router.push({ name: 'project-detail', params: { id: project.id } })" >
<q-card-section class="q-pa-md">
<div class="text-h6">{{ project.title }}</div>
<div class="text-subtitle2 text-indigo-8">{{ project.description }}</div>
<div v-if="project.memberCount" class="text-caption text-indigo-7">Участников: {{ project.memberCount }}</div>
</q-card-section>
</q-card>
<q-card
v-if="!projects.length && teamName"
class="team-name-card flex flex-center"
style="max-width: 400px; padding: 20px;"
>
<q-card-section class="text-h6 text-indigo-10 text-center">
В этой команде пока нет проектов.
</q-card-section>
</q-card>
<q-card
v-else-if="!teamName"
class="team-name-card flex flex-center"
style="max-width: 400px; padding: 20px;"
>
<q-card-section class="text-h6 text-indigo-10 text-center">
Загрузка данных команды...
</q-card-section>
</q-card>
</div>
<q-separator class="q-my-lg" color="indigo-4" style="width: 80%; margin: 0 auto;"/>
<div class="q-mt-md"></div>
<div class="flex justify-center">
<q-card class="activity-card violet-card" style="max-width: 940px; width: 100%;">
<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: 60px; margin-bottom: 4px; user-select: none;">
<div
v-for="(monthLabel, idx) in monthLabels"
:key="monthLabel"
:style="{ marginLeft: getMonthMargin(idx) + 'px' }"
class="month-label text-caption text-indigo-9"
>
{{ monthLabel }}
</div>
</div>
<div class="activity-grid-row row no-wrap">
<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"
class="weekday-label text-caption text-indigo-9"
:style="{ height: dayHeight + 'px', lineHeight: dayHeight + 'px' }"
>
{{ day }}
</div>
</div>
<div class="activity-grid" :style="{ height: (dayHeight * 7) + 'px' }">
<div
v-for="week in activityGrid"
:key="week[0]?.date"
class="activity-week"
:style="{ width: (squareSize + 4) + 'px' }"
>
<div
v-for="day in week"
:key="day.date"
class="activity-square"
:title="`Дата: ${day.date}, активность: ${day.count}`"
:style="{ backgroundColor: getActivityColor(day.count), width: squareSize + 'px', height: squareSize + 'px', margin: '2px 0' }"
></div>
</div>
</div>
</div>
<div class="scale-labels row justify-end q-mt-sm text-caption text-indigo-9" style="user-select: none;">
<span class="q-mr-md" style="cursor: pointer;" @click="decreaseScale">Меньше</span>
<span style="cursor: pointer;" @click="increaseScale">Больше</span>
</div>
</q-card-section>
</q-card>
</div>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' // Добавлен watch
import { useRoute, useRouter } from 'vue-router' // Добавлен useRoute
import { Ripple, Notify } from 'quasar'
import axios from 'axios'
import CONFIG from '@/core/config.js'
// --- Импорт API функций ---
import fetchTeams from '@/api/teams/getTeams.js' // Используем fetchTeams для получения всех команд
import fetchProfiles from '@/api/profiles/getProfiles.js'
import getPhotoFileById from '@/api/profiles/profile_photos/getPhotoFileById.js';
import downloadPhotoFile from '@/api/profiles/profile_photos/downloadPhotoFile.js';
import getProjectMembersByProfile from '@/api/project_members/getProjectMembersByProfile.js';
import getProjects from '@/api/projects/getProjects.js';
defineExpose({ directives: { ripple: Ripple } })
const route = useRoute() // Получаем доступ к текущему маршруту
const router = useRouter()
const isAuthenticated = ref(!!localStorage.getItem('access_token'))
// --- Данные команды ---
const teamLogo = ref('')
const teamName = ref('')
const currentTeamId = ref(null); // Изменено на currentTeamId для ясности
const teamRepositoryUrl = ref(null);
// --- Участники ---
const members = ref([])
// --- Проекты ---
const projects = ref([])
const allProjects = ref([]);
// --- Активность ---
const activityData = ref([]);
const dayHeight = 14;
const squareSize = ref(12);
function getDynamicMonthLabelsShort() {
const monthNames = [
'янв.', 'февр.', 'март', 'апр.', 'май', 'июн.',
'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.'
];
const currentMonthIndex = new Date().getMonth();
const labels = [];
for (let i = 0; i < 12; i++) {
const monthIndex = (currentMonthIndex - (11 - i) + 12) % 12;
labels.push(monthNames[monthIndex]);
}
return labels;
}
const monthLabels = getDynamicMonthLabelsShort();
const weekDays = ['пн', 'ср', 'пт'];
const activityGrid = computed(() => {
const weeks = [];
let week = [];
const today = new Date();
const startDate = new Date(today);
startDate.setDate(today.getDate() - 364);
const firstDayOfWeekIndex = startDate.getDay();
const offset = firstDayOfWeekIndex === 0 ? 6 : firstDayOfWeekIndex - 1;
for (let i = 0; i < offset; i++) {
week.push({ date: '', count: 0 });
}
for (let i = 0; i < 365; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
const dateStr = date.toISOString().slice(0, 10);
const dayData = activityData.value.find(d => d.date === dateStr) || { date: dateStr, count: 0 };
week.push(dayData);
if (week.length === 7) {
weeks.push(week);
week = [];
}
}
if (week.length > 0) {
while (week.length < 7) {
week.push({ date: '', count: 0 });
}
weeks.push(week);
}
return weeks;
});
function getActivityColor(count) {
if (count === 0) return '#ede9fe';
if (count <= 2) return '#d8cff9';
if (count <= 4) return '#a287ff';
if (count <= 6) return '#7c3aed';
return '#4f046f';
}
function getMonthMargin(idx) {
const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const daysBeforeMonth = daysInMonth.slice(0, idx).reduce((sum, days) => sum + days, 0);
const weekIndex = Math.floor(daysBeforeMonth / 7);
return weekIndex * (squareSize.value + 4);
}
// Загрузка данных команды по ID из маршрута
async function loadTeamData() {
const teamId = route.params.id;
if (!teamId) {
Notify.create({
type: 'negative',
message: 'ID команды не указан в маршруте.',
icon: 'error',
});
teamName.value = '';
teamLogo.value = '';
currentTeamId.value = null;
teamRepositoryUrl.value = null;
return;
}
console.log('loadTeamData: Загрузка данных для команды ID:', teamId);
try {
const allTeams = await fetchTeams(); // Получаем все команды
const team = allTeams.find(t => String(t.id) === String(teamId)); // Ищем команду по ID
if (team) {
teamName.value = team.title || 'Название не указано';
teamLogo.value = team.logo ? `${CONFIG.BASE_URL}/teams/${team.id}/file/` : '';
currentTeamId.value = team.id;
teamRepositoryUrl.value = team.git_url || null;
console.log('loadTeamData: Team found. Name:', teamName.value, 'URL:', teamRepositoryUrl.value);
} else {
Notify.create({
type: 'warning',
message: `Команда с ID ${teamId} не найдена.`,
icon: 'warning',
});
teamName.value = '';
teamLogo.value = '';
currentTeamId.value = null;
teamRepositoryUrl.value = null;
console.log('loadTeamData: Team not found for ID:', teamId);
}
} catch (error) {
console.error('Ошибка загрузки данных команды:', error);
Notify.create({
type: 'negative',
message: `Ошибка загрузки данных команды: ${error.message}`,
icon: 'error',
});
teamName.value = '';
teamLogo.value = '';
currentTeamId.value = null;
teamRepositoryUrl.value = null;
}
}
// Загрузка участников и их проектов (привязанных к текущей команде)
async function loadMembersAndProjects() {
// Очистка предыдущих аватаров
members.value.forEach(member => {
if (member.avatar && member.avatar.startsWith('blob:')) {
URL.revokeObjectURL(member.avatar);
}
});
members.value = [];
projects.value = [];
if (!currentTeamId.value) { // Используем currentTeamId
console.warn('currentTeamId не установлен. Не могу загрузить участников и проекты.');
return;
}
console.log('loadMembersAndProjects: Starting data fetch for team ID:', currentTeamId.value);
try {
const fetchedProfiles = await fetchProfiles();
console.log('loadMembersAndProjects: Fetched all profiles:', fetchedProfiles);
// Фильтруем профили по currentTeamId
const teamMembers = fetchedProfiles.filter(profile => profile.team_id === currentTeamId.value);
console.log('loadMembersAndProjects: Filtered team members:', teamMembers);
const uniqueProjects = new Map();
const fetchedProjects = await getProjects();
allProjects.value = fetchedProjects;
console.log('loadMembersAndProjects: Fetched all projects:', fetchedProjects);
members.value = await Promise.all(teamMembers.map(async profile => {
let avatarUrl = null;
let memberDescriptions = [];
if (profile.id) {
// --- Логика загрузки аватара ---
try {
const photoData = await getPhotoFileById(profile.id);
if (photoData && photoData.length > 0) {
const firstPhotoId = photoData[0].id;
const photoBlob = await downloadPhotoFile(firstPhotoId);
if (photoBlob instanceof Blob) {
avatarUrl = URL.createObjectURL(photoBlob);
}
}
} catch (photoError) {
console.error(`Ошибка при загрузке фото для профиля ${profile.id}:`, photoError);
Notify.create({
type: 'negative',
message: `Ошибка загрузки фото для ${profile.first_name || ''} ${profile.last_name || ''}: ${photoError.message}`,
icon: 'error',
});
}
// --- Логика получения описаний из project_members ---
try {
const projectMemberships = await getProjectMembersByProfile(profile.id);
const relevantProjectMemberships = projectMemberships.filter(pm => {
const isProfileMatch = pm.profile_id === profile.id;
let isProjectRelevant = false;
let foundProject = null;
if (pm.project_id) {
foundProject = allProjects.value.find(p => p.id === pm.project_id);
if (foundProject) {
isProjectRelevant = (foundProject.team_id === currentTeamId.value || foundProject.team_id === null || foundProject.team_id === undefined);
} else {
isProjectRelevant = (profile.team_id === currentTeamId.value);
}
} else {
isProjectRelevant = (profile.team_id === currentTeamId.value);
}
return isProfileMatch && isProjectRelevant;
});
relevantProjectMemberships.forEach(pm => {
if (pm.description) {
const trimmedDesc = String(pm.description).trim();
if (trimmedDesc !== '') {
memberDescriptions.push(trimmedDesc);
}
}
});
if (memberDescriptions.length === 0 && profile.description) {
const trimmedProfileDesc = String(profile.description).trim();
if (trimmedProfileDesc !== '') {
memberDescriptions.push(trimmedProfileDesc);
}
}
} catch (projectError) {
console.warn(`Не удалось загрузить проекты или описание для профиля ${profile.id}: ${projectError.message}`);
}
// --- Собираем проекты для команды ---
try {
const projectMembershipsForProjects = await getProjectMembersByProfile(profile.id);
projectMembershipsForProjects.forEach(pm => {
const project = fetchedProjects.find(p => p.id === pm.project_id);
if (project && (project.team_id === currentTeamId.value || project.team_id === null || project.team_id === undefined)) {
if (!uniqueProjects.has(project.id)) {
uniqueProjects.set(project.id, { ...project, memberCount: 1 });
} else {
uniqueProjects.get(project.id).memberCount++;
}
}
});
} catch (projAggError) {
console.warn(`Ошибка при агрегации проектов для профиля ${profile.id}: ${projAggError.message}`);
}
}
return {
id: profile.id,
name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Без имени',
descriptions: memberDescriptions,
avatar: avatarUrl,
};
}));
console.log('loadMembersAndProjects: Final members array:', members.value);
projects.value = Array.from(uniqueProjects.values());
console.log('loadMembersAndProjects: Final projects array:', projects.value);
} catch (error) {
console.error('Ошибка загрузки участников или проектов:', error);
Notify.create({
type: 'negative',
message: error.message || 'Ошибка загрузки участников или проектов',
icon: 'error',
});
}
}
// Загрузка активности команды (без изменений, т.к. зависит от teamRepositoryUrl)
async function loadActivity() {
activityData.value = [];
if (!currentTeamId.value) {
console.warn('loadActivity: currentTeamId не установлен. Не могу загрузить активность.');
fillActivityWithZeros();
return;
}
let username = null;
if (teamRepositoryUrl.value) {
try {
const url = new URL(teamRepositoryUrl.value);
const pathParts = url.pathname.split('/').filter(part => part !== '');
username = pathParts[pathParts.length - 1];
} catch (e) {
console.error('loadActivity: Ошибка парсинга URL репозитория:', e);
Notify.create({
type: 'negative',
message: 'Некорректный URL репозитория для активности команды.',
icon: 'error',
});
fillActivityWithZeros();
return;
}
}
if (!username) {
console.warn('loadActivity: URL репозитория команды отсутствует или не удалось извлечь username. Загрузка активности невозможна.');
Notify.create({
type: 'info',
message: 'URL репозитория для этой команды не указан. Активность не будет показана.', // Обновлено сообщение
icon: 'info',
});
fillActivityWithZeros();
return;
}
console.log(`loadActivity: Попытка загрузить активность для username: "${username}"`);
try {
const response = await axios.get(`${CONFIG.BASE_URL}/rss/${username}/`);
const fetchedData = response.data.map(item => ({
date: item.date,
count: parseInt(item.count, 10) || 0
}));
const dataMap = new Map(fetchedData.map(d => [d.date, d.count]));
const lastDate = new Date();
const startDate = new Date(lastDate);
startDate.setDate(lastDate.getDate() - 364);
activityData.value = [];
for (let i = 0; i < 365; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
const dateStr = date.toISOString().slice(0, 10);
const count = dataMap.get(dateStr) || 0;
activityData.value.push({ date: dateStr, count });
}
console.log('loadActivity: Активность успешно загружена.');
} catch (error) {
console.error('loadActivity: Ошибка загрузки активности:', error);
let errorMessage = 'Ошибка загрузки данных активности.';
if (error.response && error.response.status === 404) {
errorMessage = `Активность для репозитория "${username}" не найдена. Возможно, указан неверный URL или репозиторий не существует.`;
} else {
errorMessage = `Ошибка загрузки данных активности: ${error.message || error.response?.data?.detail || 'Неизвестная ошибка'}`;
}
Notify.create({
type: 'negative',
message: errorMessage,
icon: 'error',
timeout: 5000
});
fillActivityWithZeros();
}
}
// Вспомогательная функция для заполнения activityData нулями
function fillActivityWithZeros() {
const lastDate = new Date();
const startDate = new Date(lastDate);
startDate.setDate(lastDate.getDate() - 364);
activityData.value = [];
for (let i = 0; i < 365; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
const dateStr = date.toISOString().slice(0, 10);
activityData.value.push({ date: dateStr, count: 0 });
}
}
// Перезагрузка данных при изменении ID команды в маршруте
watch(() => route.params.id, async (newId, oldId) => {
if (newId !== oldId) {
console.log(`Route ID changed from ${oldId} to ${newId}. Reloading data.`);
await initializeData();
}
});
async function initializeData() {
await loadTeamData();
await Promise.allSettled([
loadMembersAndProjects(),
loadActivity()
]).then(results => {
results.forEach((result, index) => {
if (result.status === 'rejected') {
// Ошибки уже логируются внутри loadMembersAndProjects и loadActivity
}
});
});
}
onMounted(async () => {
await initializeData();
});
onUnmounted(() => {
members.value.forEach(member => {
if (member.avatar && member.avatar.startsWith('blob:')) {
URL.revokeObjectURL(member.avatar);
}
});
});
function increaseScale() {
if (squareSize.value < 24) squareSize.value += 2;
}
function decreaseScale() {
if (squareSize.value > 8) squareSize.value -= 2;
}
</script>
<style scoped>
.member-avatar-fix img {
object-fit: cover;
}
/* Остальные стили без изменений */
.activity-grid {
display: flex;
flex-direction: row;
overflow-x: auto;
}
.activity-card {
max-width: 100%;
overflow-x: auto;
}
.activity-card .activity-square {
border-radius: 4px !important;
}
.bg-violet-strong {
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #3e2465;
overflow-x: hidden;
}
.team-logo {
background: #fff;
border-radius: 50%;
width: 140px;
height: 140px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.team-logo:hover {
transform: scale(1.1);
box-shadow: 0 0 14px #a287ffaa;
}
.team-name-card {
border-radius: 20px;
background: #ede9fe;
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.18), 0 2px 8px rgba(124, 58, 237, 0.12);
max-width: 900px;
}
.violet-card {
border-radius: 22px;
background: #ede9fe;
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.18), 0 2px 8px rgba(124, 58, 237, 0.12);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.violet-card:hover {
box-shadow: 0 14px 40px rgba(124, 58, 237, 0.30), 0 6px 16px rgba(124, 58, 237, 0.20);
transform: translateY(-6px);
cursor: pointer;
}
.member-card, .contest-card {
min-height: 140px;
padding: 8px 12px;
display: flex;
flex-direction: column;
justify-content: center;
}
.activity-card {
max-width: 920px;
border-radius: 20px;
padding: 16px;
}
.activity-grid {
display: flex;
flex-direction: row;
}
.activity-week {
display: flex;
flex-direction: column;
}
.activity-square {
border-radius: 4px;
box-shadow: 0 0 3px rgba(124, 58, 237, 0.3);
cursor: default;
transition: background-color 0.3s ease;
}
.months-row {
display: flex;
flex-direction: row;
justify-content: flex-start;
margin-left: 40px;
margin-bottom: 24px !important;
position: relative;
}
.month-label {
width: auto;
text-align: left;
white-space: nowrap;
flex-shrink: 0;
position: absolute;
}
.weekdays-column {
margin-top: -15px;
}
</style>

View File

@ -57,27 +57,21 @@
<div class="text-h6 text-indigo-10 q-mb-md">Основная информация</div> <div class="text-h6 text-indigo-10 q-mb-md">Основная информация</div>
<div class="text-body1 text-indigo-9 q-mb-sm"> <div class="text-body1 text-indigo-9 q-mb-sm">
<q-icon name="cake" size="xs" class="q-mr-xs" /> <q-icon name="cake" size="xs" class="q-mr-xs" />
День рождения: <span class="text-weight-bold">{{ profile.birth_date }}</span> День рождения: <span class="text-weight-bold">{{ formattedBirthday }}</span>
</div> </div>
<div class="text-body1 text-indigo-9"> <q-separator class="q-my-md" />
<q-icon name="person" size="xs" class="q-mr-xs" />
Пол: <span class="text-weight-bold">{{ profile.gender }}</span>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12">
<q-card class="info-card violet-card">
<q-card-section>
<div class="text-h6 text-indigo-10 q-mb-md">Контакты</div>
<div class="text-body1 text-indigo-9 q-mb-sm"> <div class="text-body1 text-indigo-9 q-mb-sm">
<q-icon name="email" size="xs" class="q-mr-xs" /> <q-icon name="email" size="xs" class="q-mr-xs" />
Email: <a :href="'mailto:' + profile.email" class="text-indigo-9" style="text-decoration: none;">{{ profile.email }}</a> Email: <a :href="'mailto:' + profile.email" class="text-indigo-9" style="text-decoration: none;">{{ profile.email }}</a>
</div> </div>
<div v-if="profile.phone_number" class="text-body1 text-indigo-9"> <div v-if="profile.phone" class="text-body1 text-indigo-9">
<q-icon name="phone" size="xs" class="q-mr-xs" /> <q-icon name="phone" size="xs" class="q-mr-xs" />
Телефон: <a :href="'tel:' + profile.phone_number" class="text-indigo-9" style="text-decoration: none;">{{ profile.phone_number }}</a> Телефон: <a :href="'tel:' + profile.phone" class="text-indigo-9" style="text-decoration: none;">{{ profile.phone }}</a>
</div>
<q-separator class="q-my-md" />
<div v-if="profile.repository_url" class="text-body1 text-indigo-9">
<q-icon name="code" size="xs" class="q-mr-xs" />
Репозиторий: <a :href="profile.repository_url" target="_blank" rel="noopener noreferrer" class="text-indigo-9" style="text-decoration: none;">{{ displayRepositoryUrl }}</a>
</div> </div>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -87,20 +81,20 @@
</div> </div>
<div class="row q-col-gutter-md justify-center q-mt-xl"> <div class="row q-col-gutter-md justify-center q-mt-xl">
<div class="col-xs-12 col-md-6" v-if="profile.teams && profile.teams.length > 0"> <div class="col-xs-12 col-md-6" v-if="profile.team">
<q-card class="violet-card"> <q-card class="violet-card">
<q-card-section> <q-card-section>
<div class="text-h6 text-indigo-10 q-mb-md">Команды</div> <div class="text-h6 text-indigo-10 q-mb-md">Команда</div>
<q-list separator bordered class="rounded-borders"> <q-list separator bordered class="rounded-borders">
<q-item v-for="team in profile.teams" :key="team.id" clickable v-ripple @click="goToTeamDetail(team.id)"> <q-item clickable v-ripple @click="goToTeamDetail(profile.team.id)">
<q-item-section avatar> <q-item-section avatar>
<q-avatar size="md"> <q-avatar size="md">
<img :src="team.logo || 'https://cdn.quasar.dev/logo-v2/svg/logo.svg'" alt="Логотип команды"/> <img :src="profile.team.logo || 'https://cdn.quasar.dev/logo-v2/svg/logo.svg'" alt="Логотип команды"/>
</q-avatar> </q-avatar>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label>{{ team.name }}</q-item-label> <q-item-label>{{ profile.team.name }}</q-item-label>
<q-item-label caption>{{ team.role }}</q-item-label> <q-item-label caption v-if="profile.role_name">{{ profile.role_name }}</q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
<q-icon name="chevron_right" color="indigo-6" /> <q-icon name="chevron_right" color="indigo-6" />
@ -111,17 +105,12 @@
</q-card> </q-card>
</div> </div>
<div class="col-xs-12 col-md-6" v-if="profile.projects && profile.projects.length > 0"> <div :class="{'col-xs-12 col-md-6': profile.team, 'col-xs-12 col-md-12': !profile.team}" v-if="profile.projects && profile.projects.length > 0">
<q-card class="violet-card"> <q-card class="violet-card">
<q-card-section> <q-card-section>
<div class="text-h6 text-indigo-10 q-mb-md">Участие в проектах</div> <div class="text-h6 text-indigo-10 q-mb-md">Участие в проектах</div>
<q-list separator bordered class="rounded-borders"> <q-list separator bordered class="rounded-borders">
<q-item v-for="project in profile.projects" :key="project.id" clickable v-ripple @click="goToProjectDetail(project.id)"> <q-item v-for="project in profile.projects" :key="project.id" clickable v-ripple @click="goToProjectDetail(project.id)">
<q-item-section avatar>
<q-avatar size="md">
<img :src="project.photo || 'https://cdn.quasar.dev/logo-v2/svg/logo.svg'" alt="Логотип проекта"/>
</q-avatar>
</q-item-section>
<q-item-section> <q-item-section>
<q-item-label>{{ project.title }}</q-item-label> <q-item-label>{{ project.title }}</q-item-label>
<q-item-label caption>{{ project.role }}</q-item-label> <q-item-label caption>{{ project.role }}</q-item-label>
@ -136,15 +125,15 @@
</div> </div>
</div> </div>
<q-separator class="q-my-lg" color="indigo-4" style="width: 80%; margin: 0 auto;"/>
<div class="q-mt-md"></div> <div class="q-mt-md"></div>
<q-separator class="q-my-lg" color="indigo-4" style="width: 100%; margin: 0 auto;"/>
<div class="flex justify-center"> <div class="flex justify-center">
<q-card class="activity-card violet-card" style="max-width: 940px; width: 100%;"> <q-card class="activity-card violet-card" style="max-width: 940px; width: 100%;">
<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"
@ -156,7 +145,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"
@ -192,7 +181,6 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
</div> </div>
<div v-else class="flex flex-center q-pt-xl text-white text-h5" style="min-height: 50vh;"> <div v-else class="flex flex-center q-pt-xl text-white text-h5" style="min-height: 50vh;">
@ -203,12 +191,21 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Ripple, Notify } from 'quasar'; import { Ripple, Notify } from 'quasar';
import axios from 'axios'; import axios from 'axios';
import CONFIG from "@/core/config.js"; import CONFIG from "@/core/config.js";
// --- Импорт API функций ---
import fetchAllProfiles from '@/api/profiles/getProfiles.js';
import getPhotosByProfileId from '@/api/profiles/profile_photos/getPhotoFileById.js';
import downloadPhotoFile from '@/api/profiles/profile_photos/downloadPhotoFile.js';
import getProjectMembersByProfile from '@/api/project_members/getProjectMembersByProfile.js';
import getTeams from '@/api/teams/getTeams.js';
import getProjects from '@/api/projects/getProjects.js';
defineExpose({ directives: { ripple: Ripple } }); defineExpose({ directives: { ripple: Ripple } });
const route = useRoute(); const route = useRoute();
@ -218,71 +215,114 @@ const profile = ref(null);
const loading = ref(true); const loading = ref(true);
const profileId = computed(() => route.params.id); const profileId = computed(() => route.params.id);
const slide = ref(1); const slide = ref(1); // Для карусели фото
// --- Активность --- // --- Активность (логика скопирована 1:1 из HomePage) ---
const activityData = ref([]); const activityData = ref([]);
const dayHeight = 14; const dayHeight = 14;
const squareSize = ref(12); const squareSize = ref(12);
// Подписи месяцев (с июня 2024 по май 2025, чтобы соответствовать текущему году) const monthLabels = ['янв.', 'февр.', 'март', 'апр.', 'май', 'июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.'];
const monthLabels = ['июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.', 'янв.', 'февр.', 'март', 'апр.', 'май'];
// Дни недели (пн, ср, пт, как в Gitea)
const weekDays = ['пн', 'ср', 'пт']; const weekDays = ['пн', 'ср', 'пт'];
// Вычисляемая сетка активности (группировка по неделям)
const activityGrid = computed(() => { const activityGrid = computed(() => {
const weeks = []; const weeks = [];
let week = []; let week = [];
const firstDay = new Date(); const today = new Date();
firstDay.setDate(firstDay.getDate() - 364); // Год назад от текущей даты const startDate = new Date(today);
const dayOfWeek = firstDay.getDay(); startDate.setDate(today.getDate() - 364);
const offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Смещение для выравнивания по понедельнику
// Добавляем пустые ячейки в начало const firstDayOfWeekIndex = startDate.getDay(); // 0-воскресенье, 1-понедельник
const offset = firstDayOfWeekIndex === 0 ? 6 : firstDayOfWeekIndex - 1; // Смещение для выравнивания по понедельнику (0-пн, 1-вт...)
// Добавляем пустые ячейки в начало для выравнивания первой недели
for (let i = 0; i < offset; i++) { for (let i = 0; i < offset; i++) {
week.push({ date: '', count: 0 }); week.push({ date: '', count: 0 });
} }
for (let i = 0; i < 365; i++) { for (let i = 0; i < 365; i++) {
const date = new Date(firstDay); const date = new Date(startDate);
date.setDate(firstDay.getDate() + i); date.setDate(startDate.getDate() + i);
const dateStr = date.toISOString().slice(0, 10); const dateStr = date.toISOString().slice(0, 10);
const dayData = activityData.value.find(d => d.date === dateStr) || { date: dateStr, count: 0 }; const dayData = activityData.value.find(d => d.date === dateStr) || { date: dateStr, count: 0 };
week.push(dayData); week.push(dayData);
if (week.length === 7 || i === 364) { if (week.length === 7) {
weeks.push(week); weeks.push(week);
week = []; week = [];
} }
} }
// Добавляем оставшиеся дни последней недели
if (week.length > 0) {
while (week.length < 7) {
week.push({ date: '', count: 0 });
}
weeks.push(week);
}
return weeks; return weeks;
}); });
// Цвета активности (как в Gitea)
function getActivityColor(count) { function getActivityColor(count) {
if (count === 0) return '#ede9fe'; // Светлый фон карточек if (count === 0) return '#ede9fe';
if (count <= 2) return '#d8cff9'; // Светлый сиреневый if (count <= 2) return '#d8cff9';
if (count <= 4) return '#a287ff'; // Светлый фиолетовый if (count <= 4) return '#a287ff';
if (count <= 6) return '#7c3aed'; // Яркий фиолетовый if (count <= 6) return '#7c3aed';
return '#4f046f'; // Темно-фиолетовый return '#4f046f';
} }
// Позиционирование подписей месяцев
function getMonthMargin(idx) { function getMonthMargin(idx) {
const daysInMonth = [30, 31, 31, 30, 31, 30, 31, 31, 28, 31, 30, 31]; // Дни в месяцах с июня 2024 const now = new Date();
const daysBeforeMonth = daysInMonth.slice(0, idx).reduce((sum, days) => sum + days, 0); const currentMonth = now.getMonth(); // 0-11
const daysInMonthArray = [];
for (let i = 0; i < 12; i++) {
const month = (currentMonth - (11 - i) + 12) % 12;
const year = now.getFullYear();
daysInMonthArray.push(new Date(year, month + 1, 0).getDate()); // Последний день месяца даёт количество дней
}
const daysBeforeMonth = daysInMonthArray.slice(0, idx).reduce((sum, days) => sum + days, 0);
const weekIndex = Math.floor(daysBeforeMonth / 7); const weekIndex = Math.floor(daysBeforeMonth / 7);
return weekIndex * (squareSize.value + 4); // 4 = margin (2px + 2px) return weekIndex * (squareSize.value + 4);
} }
// Загрузка активности из API // Загрузка активности из API
const usernameForActivity = 'archibald'; // Фиксированный username для активности async function loadActivity(profileRepositoryUrl) {
activityData.value = []; // Очищаем данные перед загрузкой
async function loadActivity() { let username = null;
if (profileRepositoryUrl) {
try { try {
const response = await axios.get(`${CONFIG.BASE_URL}/rss/${usernameForActivity}/`); const url = new URL(profileRepositoryUrl);
const pathParts = url.pathname.split('/').filter(part => part !== '');
username = pathParts[pathParts.length - 1]; // Берем последнюю часть как username
} catch (e) {
console.error('loadActivity: Ошибка парсинга URL репозитория:', e);
Notify.create({
type: 'negative',
message: 'Некорректный URL репозитория для активности профиля.',
icon: 'error',
});
fillActivityWithZeros();
return;
}
}
if (!username) {
console.warn('loadActivity: URL репозитория профиля отсутствует или не удалось извлечь username. Загрузка активности невозможна.');
Notify.create({
type: 'info',
message: 'URL репозитория для профиля не указан. Активность не будет показана.',
icon: 'info',
timeout: 3000
});
fillActivityWithZeros();
return;
}
try {
const response = await axios.get(`${CONFIG.BASE_URL}/rss/${username}/`);
const fetchedData = response.data.map(item => ({ const fetchedData = response.data.map(item => ({
date: item.date, date: item.date,
count: parseInt(item.count, 10) || 0 count: parseInt(item.count, 10) || 0
@ -301,14 +341,25 @@ async function loadActivity() {
activityData.value.push({date: dateStr, count}); activityData.value.push({date: dateStr, count});
} }
} catch (error) { } catch (error) {
console.error('Ошибка загрузки активности:', error); console.error(`Ошибка загрузки активности для ${username}:`, error);
let errorMessage = `Ошибка загрузки данных активности для ${username}.`;
if (error.response && error.response.status === 404) {
errorMessage = `Активность для репозитория "${username}" не найдена. Возможно, указан неверный URL или репозиторий не существует.`;
} else {
errorMessage = `Ошибка загрузки данных активности: ${error.message || error.response?.data?.detail || 'Неизвестная ошибка'}`;
}
Notify.create({ Notify.create({
type: 'negative', type: 'negative',
message: 'Ошибка загрузки данных активности', message: errorMessage,
icon: 'error', icon: 'error',
timeout: 5000
}); });
fillActivityWithZeros();
}
}
// Заполняем пустыми данными в случае ошибки function fillActivityWithZeros() {
const lastDate = new Date(); const lastDate = new Date();
const startDate = new Date(lastDate); const startDate = new Date(lastDate);
startDate.setDate(lastDate.getDate() - 364); startDate.setDate(lastDate.getDate() - 364);
@ -320,9 +371,7 @@ async function loadActivity() {
activityData.value.push({ date: dateStr, count: 0 }); activityData.value.push({ date: dateStr, count: 0 });
} }
} }
}
// Масштабирование
function increaseScale() { function increaseScale() {
if (squareSize.value < 24) squareSize.value += 2; if (squareSize.value < 24) squareSize.value += 2;
} }
@ -331,130 +380,136 @@ function decreaseScale() {
if (squareSize.value > 8) squareSize.value -= 2; if (squareSize.value > 8) squareSize.value -= 2;
} }
// Форматирование даты рождения
const formattedBirthday = computed(() => {
if (profile.value && profile.value.birthday) {
const dateParts = profile.value.birthday.split('-');
if (dateParts.length === 3) {
return `${dateParts[2]}.${dateParts[1]}.${dateParts[0]}`;
}
}
return 'Не указано';
});
const displayRepositoryUrl = computed(() => {
if (profile.value && profile.value.repository_url) {
try {
const url = new URL(profile.value.repository_url);
const parts = url.pathname.split('/').filter(p => p);
if (parts.length >= 2) {
return `${parts[0]}/${parts[1]}`;
}
return url.hostname;
} catch {
return profile.value.repository_url;
}
}
return 'Не указан';
});
const blobUrls = ref([]);
async function fetchProfileDetails(id) { async function fetchProfileDetails(id) {
loading.value = true; loading.value = true;
profile.value = null;
blobUrls.value.forEach(url => URL.revokeObjectURL(url));
blobUrls.value = [];
try { try {
const mockProfiles = [ // *** ИЗМЕНЕНИЯ ЗДЕСЬ ***
{ const allProfiles = await fetchAllProfiles(); // Получаем все профили
id: 1, console.log('Fetched all profiles:', allProfiles);
first_name: 'Иван',
last_name: 'Иванов', const fetchedProfile = allProfiles.find(p => String(p.id) === String(id)); // Ищем профиль по ID
patronymic: 'Иванович', if (!fetchedProfile) {
birth_date: '10.01.1990', throw new Error(`Профиль с ID ${id} не найден.`); // Если профиль не найден, генерируем ошибку
gender: 'Мужской',
email: 'ivan.ivanov@example.com',
phone_number: '+79011234567',
main_photo: 'https://randomuser.me/api/portraits/men/32.jpg',
carousel_photos: [
{ id: 1, url: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
{ id: 2, url: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
{ id: 3, url: 'https://images.unsplash.com/photo-1522075469751-3a6694fa2a86?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
],
teams: [
{ id: 101, name: 'Digital Dream Team', role: 'Team Lead', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
{ id: 102, name: 'Growth Hackers', role: 'Mentor', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 201, title: 'CRM System Dev', role: 'Backend Lead', photo: 'https://cdn.quasar.dev/img/material.png' },
{ id: 202, title: 'Mobile App Redesign', role: 'Consultant', photo: 'https://cdn.quasar.dev/img/donuts.png' },
]
},
{
id: 2,
first_name: 'Мария',
last_name: 'Петрова',
patronymic: 'Александровна',
birth_date: '22.03.1993',
gender: 'Женский',
email: 'maria.petrova@example.com',
phone_number: '+79209876543',
main_photo: 'https://randomuser.me/api/portraits/women/44.jpg',
carousel_photos: [
{ id: 4, url: 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
{ id: 5, url: 'https://images.unsplash.com/photo-1580489944761-15ad79929219?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
],
teams: [
{ id: 101, name: 'Digital Dream Team', role: 'Frontend', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 204, title: 'Marketing Website', role: 'Lead Frontend', photo: 'https://cdn.quasar.dev/img/parallax2.jpg' },
]
},
{
id: 3,
first_name: 'Алексей',
last_name: 'Смирнов',
patronymic: 'Сергеевич',
birth_date: '05.09.1988',
gender: 'Мужской',
email: 'alex.smirnov@example.com',
phone_number: null,
main_photo: 'https://randomuser.me/api/portraits/men/65.jpg',
carousel_photos: [],
teams: [
{ id: 101, name: 'Digital Dream Team', role: 'Backend', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 201, title: 'CRM System Dev', role: 'Backend Dev', photo: 'https://cdn.quasar.dev/img/material.png' },
]
},
{
id: 4,
first_name: 'Анна',
last_name: 'Кузнецова',
patronymic: 'Викторовна',
birth_date: '30.11.1996',
gender: 'Женский',
email: 'anna.kuznetsova@example.com',
phone_number: '+79151112233',
main_photo: 'https://randomuser.me/api/portraits/women/56.jpg',
carousel_photos: [
{ id: 6, url: 'https://images.unsplash.com/photo-1522075469751-3a6694fa2a86?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
],
teams: [
{ id: 104, name: 'Digital Dream Team', role: 'Designer', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 205, title: 'Brand Identity', role: 'Lead Designer', photo: 'https://cdn.quasar.dev/img/parallax1.jpg' },
]
},
{
id: 5,
first_name: 'Дмитрий',
last_name: 'Орлов',
patronymic: 'Васильевич',
birth_date: '18.07.1991',
gender: 'Мужской',
email: 'dmitry.orlov@example.com',
phone_number: '+79304445566',
main_photo: 'https://randomuser.me/api/portraits/men/78.jpg',
carousel_photos: [],
teams: [
{ id: 105, name: 'Digital Dream Team', role: 'QA Engineer', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 206, title: 'Testing Automation Framework', role: 'QA Lead', photo: 'https://cdn.quasar.dev/img/quasar.jpg' },
]
} }
]; console.log('Found profile:', fetchedProfile);
profile.value = mockProfiles.find(p => p.id === parseInt(id)); let profileTeam = null;
let profileRoleName = null;
if (fetchedProfile.team_id) {
try {
const allTeams = await getTeams();
const foundTeam = allTeams.find(t => t.id === fetchedProfile.team_id);
if (foundTeam) {
profileTeam = {
id: foundTeam.id,
name: foundTeam.title,
logo: foundTeam.logo ? `${CONFIG.BASE_URL}/teams/${foundTeam.id}/file/` : null,
};
const rolesMap = { 1: 'Администратор', 2: 'Участник', 3: 'Менеджер' };
profileRoleName = rolesMap[fetchedProfile.role_id] || 'Участник команды';
}
} catch (teamError) {
console.warn('Ошибка загрузки команд для профиля:', teamError);
}
}
if (!profile.value) { const profileProjects = [];
Notify.create({ try {
type: 'negative', const projectMemberships = await getProjectMembersByProfile(id);
message: 'Профиль с таким ID не найден.', const allProjects = await getProjects();
icon: 'warning',
for (const pm of projectMemberships) {
const project = allProjects.find(p => p.id === pm.project_id);
if (project) {
profileProjects.push({
id: project.id,
title: project.title,
role: pm.description || 'Участник',
photo: project.photo ? `${CONFIG.BASE_URL}/projects/${project.id}/file/` : null,
}); });
} }
}
} catch (projectMemberError) {
console.warn('Ошибка загрузки участия в проектах для профиля:', projectMemberError);
}
const carouselPhotos = [];
try {
const photosData = await getPhotosByProfileId(id);
for (const photoInfo of photosData) {
if (photoInfo.id) {
const photoBlob = await downloadPhotoFile(photoInfo.id);
if (photoBlob instanceof Blob) {
const url = URL.createObjectURL(photoBlob);
blobUrls.value.push(url);
carouselPhotos.push({ id: photoInfo.id, url: url });
} else {
console.warn(`downloadPhotoFile для фото ${photoInfo.id} вернул не Blob.`);
}
}
}
} catch (photoLoadingError) {
console.error('Ошибка загрузки фотографий карусели:', photoLoadingError);
Notify.create({
type: 'warning',
message: 'Не удалось загрузить некоторые фотографии профиля.',
icon: 'image_not_supported',
});
}
profile.value = {
...fetchedProfile,
team: profileTeam,
role_name: profileRoleName,
projects: profileProjects,
carousel_photos: carouselPhotos
};
await loadActivity(fetchedProfile.repository_url);
} catch (error) { } catch (error) {
console.error('Ошибка загрузки деталей профиля:', error); console.error('Ошибка загрузки деталей профиля:', error);
Notify.create({ Notify.create({
type: 'negative', type: 'negative',
message: 'Не удалось загрузить информацию о профиле.', message: error.message || 'Не удалось загрузить информацию о профиле.',
icon: 'error', icon: 'error',
}); });
profile.value = null; profile.value = null;
fillActivityWithZeros();
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -462,113 +517,125 @@ async function fetchProfileDetails(id) {
function goToTeamDetail(teamId) { function goToTeamDetail(teamId) {
console.log(`Переход на страницу команды: ${teamId}`); console.log(`Переход на страницу команды: ${teamId}`);
// router.push({ name: 'team-detail', params: { id: teamId } }); router.push({ name: 'team-detail', params: { id: teamId } });
} }
function goToProjectDetail(projectId) { function goToProjectDetail(projectId) {
console.log(`Переход на страницу проекта: ${projectId}`); console.log(`Переход на страницу проекта: ${projectId}`);
// router.push({ name: 'project-detail', params: { id: projectId } }); router.push({ name: 'project-detail', params: { id: projectId } });
} }
onMounted(async () => { onMounted(async () => {
await fetchProfileDetails(profileId.value); await fetchProfileDetails(profileId.value);
await loadActivity(); // Загрузка активности при монтировании компонента
}); });
watch(profileId, async (newId) => { watch(profileId, async (newId) => {
if (newId) { if (newId) {
await fetchProfileDetails(newId); await fetchProfileDetails(newId);
await loadActivity(); // Обновление активности при изменении ID профиля
} }
}); });
onUnmounted(() => {
blobUrls.value.forEach(url => URL.revokeObjectURL(url));
});
</script> </script>
<style scoped> <style scoped>
.member-avatar-fix img {
object-fit: cover;
}
/* Остальные стили без изменений */
.activity-grid {
display: flex;
flex-direction: row;
overflow-x: auto;
}
.activity-card {
max-width: 100%;
overflow-x: auto;
}
.activity-card .activity-square {
border-radius: 4px !important;
}
.bg-violet-strong {
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #3e2465;
overflow-x: hidden;
}
.team-logo {
background: #fff;
border-radius: 50%;
width: 140px;
height: 140px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.violet-card {
border-radius: 22px;
background: #ede9fe;
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.18), 0 2px 8px rgba(124, 58, 237, 0.12);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.violet-card:hover {
box-shadow: 0 14px 40px rgba(124, 58, 237, 0.30), 0 6px 16px rgba(124, 58, 237, 0.20);
transform: translateY(-6px);
cursor: pointer;
}
.activity-card {
max-width: 920px;
border-radius: 20px;
padding: 16px;
}
.activity-grid {
display: flex;
flex-direction: row;
}
.activity-week {
display: flex;
flex-direction: column;
}
.activity-square {
border-radius: 4px;
box-shadow: 0 0 3px rgba(124, 58, 237, 0.3);
cursor: default;
transition: background-color 0.3s ease;
}
.months-row {
display: flex;
flex-direction: row;
justify-content: flex-start;
margin-left: 40px;
margin-bottom: 24px !important;
position: relative;
}
.month-label {
width: auto;
text-align: left;
white-space: nowrap;
flex-shrink: 0;
position: absolute;
}
.weekdays-column {
margin-top: -15px;
}
.profile-detail-page { .profile-detail-page {
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%); background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
min-height: 100vh; min-height: 100vh;
} }
.profile-name-card { .profile-name-card {
border-radius: 22px; border-radius: 22px;
background: #ede9fe; background: #ede9fe;
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10); box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
padding: 10px 20px; padding: 10px 20px;
} }
.violet-card { .violet-card {
border-radius: 22px; border-radius: 22px;
background: #ede9fe; background: #ede9fe;
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10); box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
} }
.carousel-card {
/* No specific width/height here, handled by Quasar's carousel and grid system */
}
/* Optional: Adjust for smaller screens if needed */
@media (max-width: 991px) { /* Breakpoint for md in Quasar grid */
.carousel-card {
margin-bottom: 24px; /* Добавить отступ, когда колонки становятся в ряд */
}
}
/* Стили для активности (перенесены из HomePage) */
.activity-grid-row {
align-items: flex-start;
}
.weekdays-column {
display: flex;
flex-direction: column;
justify-content: space-around;
height: calc(14px * 7 + 12px); /* dayHeight * 7 + (2px margin * 6 days) */
}
.activity-grid {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 8px;
scrollbar-width: thin;
scrollbar-color: #a287ff #ede9fe;
}
.activity-grid::-webkit-scrollbar {
height: 8px;
}
.activity-grid::-webkit-scrollbar-track {
background: #ede9fe;
border-radius: 10px;
}
.activity-grid::-webkit-scrollbar-thumb {
background-color: #a287ff;
border-radius: 10px;
border: 2px solid #ede9fe;
}
.activity-week {
display: flex;
flex-direction: column;
flex-shrink: 0;
margin-right: 4px;
}
.activity-square {
border-radius: 3px;
margin: 2px 0;
}
.months-row {
position: relative;
margin-bottom: 24px !important; /* Увеличил отступ, чтобы месяцы не налезали на сетку */
}
.month-label {
position: absolute;
top: -20px; /* Поднимаем метки месяцев над сеткой */
white-space: nowrap;
}
</style> </style>

View File

@ -1,14 +1,29 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router';
import LoginPage from "../pages/LoginPage.vue" import LoginPage from "../pages/LoginPage.vue";
import HomePage from "../pages/HomePage.vue" import HomePage from "../pages/HomePage.vue";
import AdminPage from "../pages/AdminPage.vue" import AdminPage from "../pages/AdminPage.vue";
import ContestDetailPage from "@/pages/ContestDetailPage.vue" import ContestDetailPage from "@/pages/ContestDetailPage.vue";
import ProfileDetailPage from "@/pages/UserDetailPage.vue"; import ProfileDetailPage from "@/pages/UserDetailPage.vue";
import TeamDetailPage from "@/pages/TeamDetailPage.vue";
import ProjectDetailPage from "@/pages/ProjectDetailPage.vue"; // <-- Добавляем импорт ProjectDetailPage
const routes = [ const routes = [
{ path: '/', component: HomePage }, {
{ path: '/login', component: LoginPage }, path: '/',
{ path: '/admin', component: AdminPage }, name: 'home', // Добавим имя для главной страницы
component: HomePage
},
{
path: '/login',
name: 'login', // Добавим имя для страницы логина
component: LoginPage
},
{
path: '/admin',
name: 'admin', // Добавим имя для страницы админа
component: AdminPage,
meta: { requiresAuth: true, requiresAdmin: true } // Страница требует аутентификации и прав админа
},
{ {
path: '/contests/:id', path: '/contests/:id',
name: 'contest-detail', name: 'contest-detail',
@ -17,30 +32,61 @@ const routes = [
{ {
path: '/profile/:id', path: '/profile/:id',
name: 'profile-detail', name: 'profile-detail',
component: ProfileDetailPage component: ProfileDetailPage,
},
{
path: '/teams/:id',
name: 'team-detail', // Даем маршруту имя
component: TeamDetailPage,
},
{ // <-- Новый маршрут для страницы проекта
path: '/projects/:id',
name: 'project-detail', // Даем маршруту имя
component: ProjectDetailPage,
} }
] ];
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes routes
}) });
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
const isAuthenticated = !!localStorage.getItem('access_token') const isAuthenticated = !!localStorage.getItem('access_token');
const userId = localStorage.getItem('user_id') const userId = localStorage.getItem('user_id'); // userId из localStorage всегда будет строкой
if (to.path === '/login' && isAuthenticated) { const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
next('/') const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin);
} else if (to.path === '/admin') {
if (isAuthenticated && userId === '1') {
next()
} else {
next('/')
}
} else {
next()
}
})
export default router // Если пользователь уже аутентифицирован и пытается перейти на страницу логина
if (to.name === 'login' && isAuthenticated) {
// Заменяем текущую запись в истории, чтобы нельзя было вернуться назад на логин
next({ name: 'home', replace: true });
}
// Если маршрут требует аутентификации
else if (requiresAuth && !isAuthenticated) {
// Перенаправляем на страницу логина.
// Передаем fullPath в query для возможного редиректа после успешного логина.
// Используем replace: true, чтобы пользователь не мог нажать "назад" и попасть на защищенную страницу.
next({ name: 'login', query: { redirectFrom: to.fullPath }, replace: true });
}
// Если маршрут требует прав администратора
else if (requiresAdmin) {
// Проверяем, аутентифицирован ли пользователь И является ли он админом
if (isAuthenticated && userId === '1') { // Предполагаем, что admin имеет userId '1'
next(); // Разрешаем доступ
} else {
// Если не админ или не аутентифицирован, перенаправляем на главную
// и заменяем текущую запись в истории
next({ name: 'home', replace: true });
}
}
// Для всех остальных случаев (общедоступные страницы или аутентифицированные пользователи на разрешенных страницах)
else {
next(); // Просто продолжаем навигацию
}
});
export default router;