все готово

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) {
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}`);
} }
if (error.response?.status === 404) {
throw new Error("Файл не найден (404)");
}
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);
filename = decodeURIComponent(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 === `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) {
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}`);
} }
if (error.response?.status === 404) {
throw new Error("Файл не найден (404)");
}
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

File diff suppressed because it is too large Load Diff

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;
contest.value = null;
// Очистка предыдущих данных и URL-ов Blob
contestCarouselPhotos.value.forEach(photo => {
if (photo.url && photo.url.startsWith('blob:')) {
URL.revokeObjectURL(photo.url);
}
});
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 { try {
// В реальном приложении здесь будет API-запрос: // 1. Получаем базовую информацию о конкурсе
// const response = await axios.get(`${CONFIG.BASE_URL}/contests/${id}`); const allContests = await fetchContests();
// contest.value = response.data; const foundContest = allContests.find(c => String(c.id) === String(id));
// Моковые данные для примера (замените на реальный fetch) if (foundContest) {
const mockContests = [ contest.value = foundContest;
{
id: 1,
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)); // 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' });
}
if (!contest.value) { // 3. Загружаем файлы конкурса
Notify.create({ try {
type: 'negative', const files = await getContestFilesByContestId(id);
message: 'Конкурс с таким ID не найден.', contestFiles.value = files;
icon: 'warning', } 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() {
try { members.value.forEach(member => {
const profiles = await fetchProfiles(); if (member.avatar && member.avatar.startsWith('blob:')) {
members.value = profiles.map(profile => ({ URL.revokeObjectURL(member.avatar);
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', // Аватар остался прежним
// Добавляем остальные поля, которые могут быть полезны members.value = [];
patronymic: profile.patronymic || '', projects.value = [];
birthday: profile.birthday || '',
email: profile.email || '', if (!activeTeamId.value) {
phone: profile.phone || '', console.warn('activeTeamId не установлен. Не могу загрузить участников и проекты.');
team_id: profile.team_id || null, return;
}
console.log('loadMembersAndProjects: Starting data fetch for team ID:', activeTeamId.value);
try {
const fetchedProfiles = await fetchProfiles();
console.log('loadMembersAndProjects: Fetched all profiles:', fetchedProfiles);
const teamMembers = fetchedProfiles.filter(profile => profile.team_id === activeTeamId.value);
console.log('loadMembersAndProjects: Filtered team members:', teamMembers);
// Добавляем логирование содержимого профилей перед обработкой аватаров
teamMembers.forEach(profile => {
console.log(`loadMembersAndProjects: Профиль для обработки аватара - ID: ${profile.id}, Имя: ${profile.first_name}, repository_url: ${profile.repository_url}`);
});
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({
type: 'negative',
message: `Ошибка загрузки фото для ${profile.first_name || ''} ${profile.last_name || ''}: ${photoError.message}`,
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;
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 {
const projectMembershipsForProjects = await getProjectMembersByProfile(profile.id);
projectMembershipsForProjects.forEach(pm => {
const project = fetchedProjects.find(p => p.id === pm.project_id);
if (project && (project.team_id === activeTeamId.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) { } 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',
}); });
} }
} }
// Загрузка конкурсов // Загрузка активности команды
async function loadContests() {
try {
const fetchedContests = await fetchContests();
contests.value = fetchedContests.map(contest => ({
id: contest.id,
title: contest.title || 'Без названия',
description: contest.description || 'Описание отсутствует',
}));
} catch (error) {
console.error('Ошибка загрузки конкурсов:', error);
Notify.create({
type: 'negative',
message: error.message || 'Ошибка загрузки конкурсов',
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,33 +571,65 @@ 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(); // Заполнение нулями в случае ошибки
const lastDate = new Date(); }
const startDate = new Date(lastDate); }
startDate.setDate(lastDate.getDate() - 364);
activityData.value = []; // Вспомогательная функция для заполнения activityData нулями
for (let i = 0; i < 365; i++) { function fillActivityWithZeros() {
const date = new Date(startDate); const lastDate = new Date();
date.setDate(startDate.getDate() + i); const startDate = new Date(lastDate);
const dateStr = date.toISOString().slice(0, 10); startDate.setDate(lastDate.getDate() - 364);
activityData.value.push({ date: dateStr, count: 0 }); 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 });
} }
} }
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 = []; // Очищаем данные перед загрузкой
let username = null;
if (profileRepositoryUrl) {
try {
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;
}
async function loadActivity() {
try { try {
const response = await axios.get(`${CONFIG.BASE_URL}/rss/${usernameForActivity}/`); 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,28 +341,37 @@ 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();
// Заполняем пустыми данными в случае ошибки }
const lastDate = new Date(); }
const startDate = new Date(lastDate);
startDate.setDate(lastDate.getDate() - 364); function fillActivityWithZeros() {
activityData.value = []; const lastDate = new Date();
for (let i = 0; i < 365; i++) { const startDate = new Date(lastDate);
const date = new Date(startDate); startDate.setDate(lastDate.getDate() - 364);
date.setDate(startDate.getDate() + i); activityData.value = [];
const dateStr = date.toISOString().slice(0, 10); for (let i = 0; i < 365; i++) {
activityData.value.push({date: dateStr, count: 0}); const date = new Date(startDate);
} date.setDate(startDate.getDate() + i);
const dateStr = date.toISOString().slice(0, 10);
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', console.log('Found profile:', fetchedProfile);
phone_number: '+79011234567',
main_photo: 'https://randomuser.me/api/portraits/men/32.jpg', let profileTeam = null;
carousel_photos: [ let profileRoleName = null;
{ 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' }, if (fetchedProfile.team_id) {
{ 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' }, try {
{ 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' }, const allTeams = await getTeams();
], const foundTeam = allTeams.find(t => t.id === fetchedProfile.team_id);
teams: [ if (foundTeam) {
{ id: 101, name: 'Digital Dream Team', role: 'Team Lead', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' }, profileTeam = {
{ id: 102, name: 'Growth Hackers', role: 'Mentor', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' }, id: foundTeam.id,
], name: foundTeam.title,
projects: [ logo: foundTeam.logo ? `${CONFIG.BASE_URL}/teams/${foundTeam.id}/file/` : null,
{ 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' }, const rolesMap = { 1: 'Администратор', 2: 'Участник', 3: 'Менеджер' };
] profileRoleName = rolesMap[fetchedProfile.role_id] || 'Участник команды';
}, }
{ } catch (teamError) {
id: 2, console.warn('Ошибка загрузки команд для профиля:', teamError);
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' },
]
} }
]; }
profile.value = mockProfiles.find(p => p.id === parseInt(id)); const profileProjects = [];
try {
const projectMemberships = await getProjectMembersByProfile(id);
const allProjects = await getProjects();
if (!profile.value) { 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({ Notify.create({
type: 'negative', type: 'warning',
message: 'Профиль с таким ID не найден.', message: 'Не удалось загрузить некоторые фотографии профиля.',
icon: 'warning', 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() if (to.name === 'login' && isAuthenticated) {
} else { // Заменяем текущую запись в истории, чтобы нельзя было вернуться назад на логин
next('/') next({ name: 'home', replace: true });
}
} else {
next()
} }
}) // Если маршрут требует аутентификации
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 export default router;