все готово

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,9 +11,10 @@ class BaseProfileEntity(BaseModel):
birthday: datetime.date
email: Optional[str] = None
phone: Optional[str] = None
repository_url: Optional[str] = None
role_id: int
team_id: int
class Config:
abstract = True
abstract = True

View File

@ -20,5 +20,5 @@ class Contest(AdvancedBaseModel):
project = relationship('Project', back_populates='contests')
status = relationship('ContestStatus', back_populates='contests')
carousel_photos = relationship('ContestCarouselPhoto', back_populates='contest')
files = relationship('ContestFile', back_populates='contest')
carousel_photos = relationship('ContestCarouselPhoto', back_populates='contest', cascade='all')
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 app.domain.models.base import AdvancedBaseModel
@ -13,6 +13,7 @@ class Profile(AdvancedBaseModel):
birthday = Column(Date, nullable=False)
email = Column(VARCHAR(150))
phone = Column(VARCHAR(28))
repository_url = Column(String, nullable=True)
role_id = Column(Integer, ForeignKey('roles.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')
user = relationship('User', back_populates='profile', cascade='all')
profile_photos = relationship('ProfilePhoto', back_populates='profile')
projects = relationship('ProjectMember', back_populates='profile')
profile_photos = relationship('ProfilePhoto', back_populates='profile', cascade='all')
projects = relationship('ProjectMember', back_populates='profile')

View File

@ -12,5 +12,5 @@ class Project(AdvancedBaseModel):
repository_url = Column(String, nullable=False)
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")

View File

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

View File

@ -21,7 +21,12 @@ def start_app():
api_app.add_middleware(
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_methods=["*"],
allow_headers=["*"],
@ -47,7 +52,6 @@ def start_app():
app = start_app()
@app.get("/")
async def root():
return {"message": "Hello API"}

View File

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

View File

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

View File

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

View File

@ -1,38 +1,82 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const downloadContestFile = async (fileId) => {
// Добавляем параметры suggestedFileName и suggestedFileFormat для большей надежности
const downloadContestFile = async (fileId, suggestedFileName, suggestedFileFormat) => {
try {
const response = await axios.get(`${CONFIG.BASE_URL}/contest_files/${fileId}/file`, {
responseType: 'blob',
withCredentials: true,
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const response = await axios.get(
`${CONFIG.BASE_URL}/contest_files/${fileId}/file`, // Убедитесь, что это правильный URL для скачивания самого файла
{
responseType: 'blob', // Важно для бинарных данных
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');
link.href = url;
let filename = `contest_file_${fileId}`; // Запасное имя файла
// 1. Попытка получить имя файла из заголовка Content-Disposition
const contentDisposition = response.headers['content-disposition'];
let filename = 'download';
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1];
// Расширенное регулярное выражение для извлечения filename или filename* (с поддержкой UTF-8)
const filenameMatch = contentDisposition.match(/filename\*=(?:UTF-8'')?([^;]+)|filename="([^"]+)"/i);
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);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
window.URL.revokeObjectURL(url); // Освобождаем память Blob URL
return filename;
} 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) => {
try {
const token = localStorage.getItem('access_token')
// Убираем id из тела запроса, он идет в URL
const { id, ...profileData } = profile
console.log('Отправляем на сервер:', profileData)
const response = await axios.put(
`${CONFIG.BASE_URL}/contests/${id}/`,
profileData,
@ -20,8 +15,6 @@ const updateContest = async (profile) => {
}
}
)
console.log('Ответ от сервера:', response.data)
return response.data
} catch (error) {
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 response = await axios.get(
`${CONFIG.BASE_URL}/profile_photos/${photoId}/file`,
`${CONFIG.BASE_URL}/profile_photos/${photoId}/file/`,
{
withCredentials: true,
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)
const response = await axios.post(
`${CONFIG.BASE_URL}/profile_photos/profiles/${profileId}/upload`,
`${CONFIG.BASE_URL}/profile_photos/profiles/${profileId}/upload/`,
formData,
{
withCredentials: true,

View File

@ -4,7 +4,7 @@ import CONFIG from "@/core/config.js";
const fetchProjects = async () => {
try {
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: {
Authorization: `Bearer ${token}/`,
},

View File

@ -1,43 +1,82 @@
import axios from 'axios';
import CONFIG from '@/core/config.js';
const downloadProjectFile = async (fileId) => {
// Добавляем параметры suggestedFileName и suggestedFileFormat
const downloadProjectFile = async (fileId, suggestedFileName, suggestedFileFormat) => {
try {
const response = await axios.get(
`${CONFIG.BASE_URL}/project_files/${fileId}/download/`,
`${CONFIG.BASE_URL}/project_files/${fileId}/file/`, // Убедитесь, что это правильный URL для скачивания самого файла
{
responseType: 'blob',
withCredentials: true,
responseType: 'blob', // Важно для бинарных данных
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');
link.href = url;
let filename = `project_file_${fileId}`; // Запасное имя файла
// 1. Попытка получить имя файла из заголовка Content-Disposition
const contentDisposition = response.headers['content-disposition'];
let filename = `project_file_${fileId}`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1]);
// Расширенное регулярное выражение для извлечения filename или filename* (с поддержкой UTF-8)
const filenameMatch = contentDisposition.match(/filename\*=(?:UTF-8'')?([^;]+)|filename="([^"]+)"/i);
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);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
window.URL.revokeObjectURL(url); // Освобождаем память Blob URL
return filename;
} 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,
QImg,
QFile,
QSelect
QSelect,
QSpinner
} from 'quasar'
@ -61,7 +62,7 @@ app.use(Quasar, {
QSeparator, QCardActions, QDialog, QIcon, QSpace,
QAvatar, QTooltip, QBanner, QSlideTransition, QToggle,
QList, QSpinnerDots, QCarouselSlide, QCarousel,
QItemSection, QItemLabel, QItem, QImg, QFile, QSelect
QItemSection, QItemLabel, QItem, QImg, QFile, QSelect, QSpinner
},
directives: {
Ripple

File diff suppressed because it is too large Load Diff

View File

@ -15,12 +15,6 @@
</div>
<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">
<q-card class="contest-name-card">
<q-card-section class="text-h4 text-center text-indigo-10 q-pa-md">
@ -53,7 +47,7 @@
</q-card>
</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-section>
<div class="text-h6 text-indigo-10 q-mb-md">Галерея</div>
@ -71,24 +65,31 @@
class="rounded-borders"
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-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 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-section>
<div class="text-h6 text-indigo-10 q-mb-md">Файлы конкурса</div>
<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-icon name="folder_open" color="indigo-8" />
</q-item-section>
<q-item-section>
<q-item-label>{{ file.name }}</q-item-label>
<q-item-label caption>{{ file.description }}</q-item-label>
<q-item-label>{{ file.name || `Файл ${file.id}` }}</q-item-label>
<q-item-label caption>{{ file.description || 'Нет описания' }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="download" color="indigo-6" />
@ -98,42 +99,81 @@
</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 v-if="contestParticipants.length > 0" class="flex justify-center flex-wrap q-gutter-md q-mb-xl">
<div class="flex justify-center q-mb-md" style="width: 100%;"></div>
<div v-if="contestParticipants.length > 0" class="flex justify-center q-mb-md" style="width: 100%;">
<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>
<q-separator class="q-my-lg" color="indigo-4" style="width: 80%; margin: 0 auto;"/>
<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-section class="q-pa-md">
<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-body1">{{ contest.project_description }}</div>
<div class="text-body1">{{ projectDetails.description }}</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>
<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;">
{{ contest.repository_url }}
<a :href="projectDetails.repository_url" target="_blank" class="text-indigo-9" style="text-decoration: none; word-break: break-all;">
{{ projectDetails.repository_url }}
</a>
</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>
<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-icon name="folder_open" color="indigo-8" />
</q-item-section>
<q-item-section>
<q-item-label>{{ file.name }}</q-item-label>
<q-item-label caption>{{ file.description }}</q-item-label>
<q-item-label>{{ file.name || `Файл ${file.id}` }}</q-item-label>
<q-item-label caption>{{ file.description || 'Нет описания' }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="download" color="indigo-6" />
@ -141,10 +181,20 @@
</q-item>
</q-list>
</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>
</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>
@ -156,11 +206,33 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
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 } });
@ -175,97 +247,182 @@ const contestId = computed(() => route.params.id);
// --- Карусель фото ---
const slide = ref(1);
const contestCarouselPhotos = ref([]); // Содержит объекты { id, url }
// --- Участники конкурса (моковые данные) ---
const contestParticipants = 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 contestFiles = ref([]); // Содержит список объектов файлов конкурса
// --- Участники конкурса ---
const contestParticipants = ref([]);
// --- Информация о связанном проекте ---
const projectDetails = ref({}); // Будет хранить { id, description, repository_url, ... }
const projectFiles = ref([]); // Будет хранить список файлов проекта
// --- Загрузка данных конкурса ---
async function fetchContestDetails(id) {
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 {
// В реальном приложении здесь будет API-запрос:
// const response = await axios.get(`${CONFIG.BASE_URL}/contests/${id}`);
// contest.value = response.data;
// 1. Получаем базовую информацию о конкурсе
const allContests = await fetchContests();
const foundContest = allContests.find(c => String(c.id) === String(id));
// Моковые данные для примера (замените на реальный fetch)
const mockContests = [
{
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,
},
// Добавьте другие моковые конкурсы по мере необходимости
];
if (foundContest) {
contest.value = foundContest;
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) {
Notify.create({
type: 'negative',
message: 'Конкурс с таким ID не найден.',
icon: 'warning',
});
// 3. Загружаем файлы конкурса
try {
const files = await getContestFilesByContestId(id);
contestFiles.value = files;
} catch (filesError) {
console.warn(`Ошибка загрузки файлов конкурса для конкурса ${id}:`, filesError);
Notify.create({ type: 'warning', message: `Не удалось загрузить файлы конкурса: ${filesError.message}`, icon: 'warning' });
}
// 4. Загружаем информацию о связанном проекте и его участниках/файлах
if (contest.value.project_id) {
try {
const allProjects = await getProjects();
const projectData = allProjects.find(p => String(p.id) === String(contest.value.project_id));
if (projectData) {
projectDetails.value = projectData;
// Загружаем файлы проекта
try {
const pFiles = await getProjectFilesByProjectId(contest.value.project_id);
projectFiles.value = pFiles;
} catch (pFilesError) {
console.warn(`Ошибка загрузки файлов проекта для проекта ${contest.value.project_id}:`, pFilesError);
Notify.create({ type: 'warning', message: `Не удалось загрузить файлы проекта: ${pFilesError.message}`, icon: 'warning' });
}
// Загружаем участников проекта (которые и есть участники конкурса)
const projectMembersRaw = await getProjectMembersByProject(contest.value.project_id);
if (projectMembersRaw.length > 0) {
const allProfiles = await fetchProfiles();
const profilesMap = new Map(allProfiles.map(p => [p.id, p]));
contestParticipants.value = await Promise.all(
projectMembersRaw.map(async pm => {
const profile = profilesMap.get(pm.profile_id);
if (!profile) return null;
let avatarUrl = null;
try {
const photoData = await getPhotoFileById(profile.id);
if (photoData && photoData.length > 0) {
const firstPhotoId = photoData[0].id;
const photoBlob = await downloadPhotoFile(firstPhotoId);
if (photoBlob instanceof Blob) {
avatarUrl = URL.createObjectURL(photoBlob);
}
}
} catch (photoError) {
console.warn(`Ошибка загрузки фото профиля для участника ${profile.id}:`, photoError);
}
return {
id: profile.id,
name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Без имени',
role: pm.description || 'Участник проекта',
avatar: avatarUrl,
};
})
);
contestParticipants.value = contestParticipants.value.filter(p => p !== null);
}
} else {
console.warn(`Проект с ID ${contest.value.project_id} не найден.`);
Notify.create({ type: 'warning', message: `Связанный проект с ID ${contest.value.project_id} не найден.`, icon: 'warning' });
}
} catch (projectError) {
console.error(`Ошибка загрузки деталей проекта или участников для конкурса ${id} (project_id: ${contest.value.project_id}):`, projectError);
Notify.create({ type: 'negative', message: `Не удалось загрузить информацию о проекте или его участниках: ${projectError.message}`, icon: 'error' });
projectDetails.value = {};
projectFiles.value = [];
}
} else {
console.info(`Конкурс ${id} не имеет связанного project_id. Информация о проекте и его участниках не будет загружена.`);
}
} else {
Notify.create({ type: 'warning', message: 'Конкурс с таким ID не найден.', icon: 'warning' });
contest.value = null;
}
} catch (error) {
console.error('Ошибка загрузки деталей конкурса:', error);
Notify.create({
type: 'negative',
message: 'Не удалось загрузить информацию о конкурсе.',
icon: 'error',
});
contest.value = null; // Сброс, если ошибка
Notify.create({ type: 'negative', message: `Не удалось загрузить информацию о конкурсе: ${error.message}`, icon: 'error' });
contest.value = null;
} finally {
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 () => {
await fetchContestDetails(contestId.value);
});
@ -275,12 +432,33 @@ watch(contestId, async (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>
<style scoped>
.contest-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; /* Добавлен отступ снизу */
}
.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);
}
.description-card {
max-width: 940px;
width: 100%;
}
.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 {
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>

View File

@ -26,28 +26,76 @@
@click="router.push({ name: 'profile-detail', params: { id: member.id } })"
>
<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"/>
</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 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>
</div>
<div class="flex justify-center flex-wrap q-gutter-md q-mb-xl">
<q-card
v-for="contest in contests"
:key="contest.id"
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: 'contest-detail', params: { id: contest.id } })" >
@click="router.push({ name: 'project-detail', params: { id: project.id } })" >
<q-card-section class="q-pa-md">
<div class="text-h6">{{ contest.title }}</div>
<div class="text-subtitle2 text-indigo-8">{{ contest.description }}</div>
<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>
@ -122,14 +170,19 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Ripple, Notify } from 'quasar'
import axios from 'axios'
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 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 } })
@ -148,6 +201,7 @@ const handleAuthAction = () => {
message: 'Выход успешно осуществлен',
icon: 'check_circle',
})
router.push('/login')
} else {
router.push('/login')
}
@ -156,12 +210,15 @@ const handleAuthAction = () => {
// --- Данные команды ---
const teamLogo = ref('')
const teamName = ref('')
const activeTeamId = ref(null);
const teamRepositoryUrl = ref(null); // Новая переменная для URL репозитория команды
// --- Участники ---
const members = ref([])
// --- Конкурсы ---
const contests = ref([])
// --- Проекты ---
const projects = ref([])
const allProjects = ref([]);
// --- Активность ---
const activityData = ref([]);
@ -175,146 +232,326 @@ function getDynamicMonthLabelsShort() {
];
const currentMonthIndex = new Date().getMonth();
// Создаем массив из 12 элементов, затем используем map для получения названий месяцев
return Array.from({ length: 12 }, (_, i) => {
const monthIndex = (currentMonthIndex + i) % 12;
return monthNames[monthIndex];
});
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();
// Дни недели (пн, ср, пт, как в Gitea)
const weekDays = ['пн', 'ср', 'пт'];
// Вычисляемая сетка активности (группировка по неделям)
const activityGrid = computed(() => {
const weeks = [];
let week = [];
const firstDay
const today = new Date();
const startDate = new Date(today);
startDate.setDate(today.getDate() - 364);
= new Date();
firstDay.setDate(firstDay.getDate() - 364); // Год назад от текущей даты
const dayOfWeek = firstDay.getDay();
const offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Смещение для выравнивания по понедельнику
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(firstDay);
date.setDate(firstDay.getDate() + 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 || i === 364) {
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;
});
// Цвета активности (как в Gitea)
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'; // Темно-фиолетовый
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 = [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 weekIndex = Math.floor(daysBeforeMonth / 7);
return weekIndex * (squareSize.value + 4); // 4 = margin (2px + 2px)
return weekIndex * (squareSize.value + 4);
}
// Загрузка данных команды
async function loadTeamData() {
try {
const teams = await fetchTeams();
const activeTeam = teams.find(team => team.is_active === true);
const activeTeam = await getActiveTeam();
if (activeTeam) {
teamName.value = activeTeam.title || 'Название не указано';
teamLogo.value = activeTeam.logo || '';
// Вы также можете сохранить другие данные активной команды, если они нужны:
// teamDescription.value = activeTeam.description || '';
// teamGitUrl.value = activeTeam.git_url || '';
teamLogo.value = activeTeam.logo ? `${CONFIG.BASE_URL}/teams/${activeTeam.id}/file/` : '';
activeTeamId.value = activeTeam.id;
// Сохраняем URL репозитория команды
teamRepositoryUrl.value = activeTeam.git_url || null;
console.log('loadTeamData: Active team ID:', activeTeamId.value);
console.log('loadTeamData: Team Repository URL:', teamRepositoryUrl.value);
} else {
Notify.create({
type: 'warning',
message: 'Активная команда не найдена',
message: 'Активная команда не найдена. Пожалуйста, создайте команду и сделайте её активной в разделе "Команды".',
icon: 'warning',
});
teamName.value = '';
teamLogo.value = '';
activeTeamId.value = null;
teamRepositoryUrl.value = null; // Сбрасываем URL
console.log('loadTeamData: No active team found.');
}
} catch (error) {
console.error('Ошибка загрузки данных команды:', error);
Notify.create({
type: 'negative',
message: 'Ошибка загрузки данных команды',
message: `Ошибка загрузки данных команды: ${error.message}`,
icon: 'error',
});
teamName.value = '';
teamLogo.value = '';
activeTeamId.value = null;
teamRepositoryUrl.value = null;
}
}
// Загрузка участников
async function loadMembers() {
try {
const profiles = await fetchProfiles();
members.value = profiles.map(profile => ({
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', // Аватар остался прежним
// Загрузка участников и их проектов (привязанных к активной команде)
async function loadMembersAndProjects() {
members.value.forEach(member => {
if (member.avatar && member.avatar.startsWith('blob:')) {
URL.revokeObjectURL(member.avatar);
}
});
// Добавляем остальные поля, которые могут быть полезны
patronymic: profile.patronymic || '',
birthday: profile.birthday || '',
email: profile.email || '',
phone: profile.phone || '',
team_id: profile.team_id || null,
members.value = [];
projects.value = [];
if (!activeTeamId.value) {
console.warn('activeTeamId не установлен. Не могу загрузить участников и проекты.');
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) {
console.error('Ошибка загрузки участников:', error);
console.error('Ошибка загрузки участников или проектов:', error);
Notify.create({
type: 'negative',
message: error.message || 'Ошибка загрузки участников',
message: error.message || 'Ошибка загрузки участников или проектов',
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() {
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 {
const response = await axios.get(`${CONFIG.BASE_URL}/rss/${username}/`);
const fetchedData = response.data.map(item => ({
@ -334,33 +571,65 @@ async function loadActivity() {
const count = dataMap.get(dateStr) || 0;
activityData.value.push({ date: dateStr, count });
}
console.log('loadActivity: Активность успешно загружена.');
} 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({
type: 'negative',
message: 'Ошибка загрузки данных активности',
message: errorMessage,
icon: 'error',
timeout: 5000 // Увеличим время отображения для ошибки
});
// Заполняем пустыми данными в случае ошибки
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 });
}
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 });
}
}
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() {
if (squareSize.value < 24) squareSize.value += 2;
}
@ -371,6 +640,10 @@ function decreaseScale() {
</script>
<style scoped>
.member-avatar-fix img {
object-fit: cover;
}
/* Остальные стили без изменений */
.activity-grid {
display: flex;
flex-direction: row;
@ -451,7 +724,7 @@ function decreaseScale() {
display: flex;
flex-direction: row;
justify-content: flex-start;
margin-left: 40px; /* Синхронизация с .weekdays-column */
margin-left: 40px;
margin-bottom: 24px !important;
position: relative;
}
@ -460,9 +733,9 @@ function decreaseScale() {
text-align: left;
white-space: nowrap;
flex-shrink: 0;
position: absolute; /* Для точного позиционирования */
position: absolute;
}
.weekdays-column {
margin-top: -15px; /* Попробуйте разные значения, например, -10px, -30px, пока не найдете оптимальное */
margin-top: -15px;
}
</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-body1 text-indigo-9 q-mb-sm">
<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 class="text-body1 text-indigo-9">
<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>
<q-separator class="q-my-md" />
<div class="text-body1 text-indigo-9 q-mb-sm">
<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>
</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" />
Телефон: <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>
</q-card-section>
</q-card>
@ -87,20 +81,20 @@
</div>
<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-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-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-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-item-section>
<q-item-section>
<q-item-label>{{ team.name }}</q-item-label>
<q-item-label caption>{{ team.role }}</q-item-label>
<q-item-label>{{ profile.team.name }}</q-item-label>
<q-item-label caption v-if="profile.role_name">{{ profile.role_name }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" color="indigo-6" />
@ -111,17 +105,12 @@
</q-card>
</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-section>
<div class="text-h6 text-indigo-10 q-mb-md">Участие в проектах</div>
<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-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-label>{{ project.title }}</q-item-label>
<q-item-label caption>{{ project.role }}</q-item-label>
@ -136,15 +125,15 @@
</div>
</div>
<q-separator class="q-my-lg" color="indigo-4" style="width: 80%; margin: 0 auto;"/>
<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">
<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="text-h6 text-indigo-10 q-mb-md">Активность команды за последний год</div>
<div class="months-row flex" style="margin-left: 40px; margin-bottom: 4px; user-select: none;">
<div class="months-row flex" style="margin-left: 60px; margin-bottom: 4px; user-select: none;">
<div
v-for="(monthLabel, idx) in monthLabels"
:key="monthLabel"
@ -156,7 +145,7 @@
</div>
<div class="activity-grid-row row no-wrap">
<div class="weekdays-column column q-pr-sm" style="width: 40px; user-select: none; justify-content: space-around;">
<div class="weekdays-column column q-pr-sm" style="width: 30px; user-select: none; justify-content: space-around;">
<div
v-for="(day, idx) in weekDays"
:key="day"
@ -192,7 +181,6 @@
</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;">
@ -203,12 +191,21 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Ripple, Notify } from 'quasar';
import axios from 'axios';
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 } });
const route = useRoute();
@ -218,71 +215,114 @@ const profile = ref(null);
const loading = ref(true);
const profileId = computed(() => route.params.id);
const slide = ref(1);
const slide = ref(1); // Для карусели фото
// --- Активность ---
// --- Активность (логика скопирована 1:1 из HomePage) ---
const activityData = ref([]);
const dayHeight = 14;
const squareSize = ref(12);
// Подписи месяцев (с июня 2024 по май 2025, чтобы соответствовать текущему году)
const monthLabels = ['июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.', 'янв.', 'февр.', 'март', 'апр.', 'май'];
const monthLabels = ['янв.', 'февр.', 'март', 'апр.', 'май', 'июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.'];
// Дни недели (пн, ср, пт, как в Gitea)
const weekDays = ['пн', 'ср', 'пт'];
// Вычисляемая сетка активности (группировка по неделям)
const activityGrid = computed(() => {
const weeks = [];
let week = [];
const firstDay = new Date();
firstDay.setDate(firstDay.getDate() - 364); // Год назад от текущей даты
const dayOfWeek = firstDay.getDay();
const offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Смещение для выравнивания по понедельнику
const today = new Date();
const startDate = new Date(today);
startDate.setDate(today.getDate() - 364);
// Добавляем пустые ячейки в начало
const firstDayOfWeekIndex = startDate.getDay(); // 0-воскресенье, 1-понедельник
const offset = firstDayOfWeekIndex === 0 ? 6 : firstDayOfWeekIndex - 1; // Смещение для выравнивания по понедельнику (0-пн, 1-вт...)
// Добавляем пустые ячейки в начало для выравнивания первой недели
for (let i = 0; i < offset; i++) {
week.push({ date: '', count: 0 });
}
for (let i = 0; i < 365; i++) {
const date = new Date(firstDay);
date.setDate(firstDay.getDate() + 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 || i === 364) {
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;
});
// Цвета активности (как в Gitea)
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'; // Темно-фиолетовый
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 = [30, 31, 31, 30, 31, 30, 31, 31, 28, 31, 30, 31]; // Дни в месяцах с июня 2024
const daysBeforeMonth = daysInMonth.slice(0, idx).reduce((sum, days) => sum + days, 0);
const now = new Date();
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);
return weekIndex * (squareSize.value + 4); // 4 = margin (2px + 2px)
return weekIndex * (squareSize.value + 4);
}
// Загрузка активности из 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 {
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 => ({
date: item.date,
count: parseInt(item.count, 10) || 0
@ -301,28 +341,37 @@ async function loadActivity() {
activityData.value.push({date: dateStr, count});
}
} 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({
type: 'negative',
message: 'Ошибка загрузки данных активности',
message: errorMessage,
icon: 'error',
timeout: 5000
});
// Заполняем пустыми данными в случае ошибки
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});
}
fillActivityWithZeros();
}
}
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 });
}
}
// Масштабирование
function increaseScale() {
if (squareSize.value < 24) squareSize.value += 2;
}
@ -331,130 +380,136 @@ function decreaseScale() {
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) {
loading.value = true;
profile.value = null;
blobUrls.value.forEach(url => URL.revokeObjectURL(url));
blobUrls.value = [];
try {
const mockProfiles = [
{
id: 1,
first_name: 'Иван',
last_name: 'Иванов',
patronymic: 'Иванович',
birth_date: '10.01.1990',
gender: 'Мужской',
email: 'ivan.ivanov@example.com',
phone_number: '+79011234567',
main_photo: 'https://randomuser.me/api/portraits/men/32.jpg',
carousel_photos: [
{ id: 1, url: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
{ id: 2, url: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
{ id: 3, url: 'https://images.unsplash.com/photo-1522075469751-3a6694fa2a86?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
],
teams: [
{ id: 101, name: 'Digital Dream Team', role: 'Team Lead', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
{ id: 102, name: 'Growth Hackers', role: 'Mentor', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 201, title: 'CRM System Dev', role: 'Backend Lead', photo: 'https://cdn.quasar.dev/img/material.png' },
{ id: 202, title: 'Mobile App Redesign', role: 'Consultant', photo: 'https://cdn.quasar.dev/img/donuts.png' },
]
},
{
id: 2,
first_name: 'Мария',
last_name: 'Петрова',
patronymic: 'Александровна',
birth_date: '22.03.1993',
gender: 'Женский',
email: 'maria.petrova@example.com',
phone_number: '+79209876543',
main_photo: 'https://randomuser.me/api/portraits/women/44.jpg',
carousel_photos: [
{ id: 4, url: 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
{ id: 5, url: 'https://images.unsplash.com/photo-1580489944761-15ad79929219?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
],
teams: [
{ id: 101, name: 'Digital Dream Team', role: 'Frontend', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 204, title: 'Marketing Website', role: 'Lead Frontend', photo: 'https://cdn.quasar.dev/img/parallax2.jpg' },
]
},
{
id: 3,
first_name: 'Алексей',
last_name: 'Смирнов',
patronymic: 'Сергеевич',
birth_date: '05.09.1988',
gender: 'Мужской',
email: 'alex.smirnov@example.com',
phone_number: null,
main_photo: 'https://randomuser.me/api/portraits/men/65.jpg',
carousel_photos: [],
teams: [
{ id: 101, name: 'Digital Dream Team', role: 'Backend', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 201, title: 'CRM System Dev', role: 'Backend Dev', photo: 'https://cdn.quasar.dev/img/material.png' },
]
},
{
id: 4,
first_name: 'Анна',
last_name: 'Кузнецова',
patronymic: 'Викторовна',
birth_date: '30.11.1996',
gender: 'Женский',
email: 'anna.kuznetsova@example.com',
phone_number: '+79151112233',
main_photo: 'https://randomuser.me/api/portraits/women/56.jpg',
carousel_photos: [
{ id: 6, url: 'https://images.unsplash.com/photo-1522075469751-3a6694fa2a86?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
],
teams: [
{ id: 104, name: 'Digital Dream Team', role: 'Designer', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 205, title: 'Brand Identity', role: 'Lead Designer', photo: 'https://cdn.quasar.dev/img/parallax1.jpg' },
]
},
{
id: 5,
first_name: 'Дмитрий',
last_name: 'Орлов',
patronymic: 'Васильевич',
birth_date: '18.07.1991',
gender: 'Мужской',
email: 'dmitry.orlov@example.com',
phone_number: '+79304445566',
main_photo: 'https://randomuser.me/api/portraits/men/78.jpg',
carousel_photos: [],
teams: [
{ id: 105, name: 'Digital Dream Team', role: 'QA Engineer', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 206, title: 'Testing Automation Framework', role: 'QA Lead', photo: 'https://cdn.quasar.dev/img/quasar.jpg' },
]
// *** ИЗМЕНЕНИЯ ЗДЕСЬ ***
const allProfiles = await fetchAllProfiles(); // Получаем все профили
console.log('Fetched all profiles:', allProfiles);
const fetchedProfile = allProfiles.find(p => String(p.id) === String(id)); // Ищем профиль по ID
if (!fetchedProfile) {
throw new Error(`Профиль с ID ${id} не найден.`); // Если профиль не найден, генерируем ошибку
}
console.log('Found profile:', fetchedProfile);
let profileTeam = null;
let profileRoleName = null;
if (fetchedProfile.team_id) {
try {
const allTeams = await getTeams();
const foundTeam = allTeams.find(t => t.id === fetchedProfile.team_id);
if (foundTeam) {
profileTeam = {
id: foundTeam.id,
name: foundTeam.title,
logo: foundTeam.logo ? `${CONFIG.BASE_URL}/teams/${foundTeam.id}/file/` : null,
};
const rolesMap = { 1: 'Администратор', 2: 'Участник', 3: 'Менеджер' };
profileRoleName = rolesMap[fetchedProfile.role_id] || 'Участник команды';
}
} catch (teamError) {
console.warn('Ошибка загрузки команд для профиля:', teamError);
}
];
}
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({
type: 'negative',
message: 'Профиль с таким ID не найден.',
icon: 'warning',
type: 'warning',
message: 'Не удалось загрузить некоторые фотографии профиля.',
icon: 'image_not_supported',
});
}
profile.value = {
...fetchedProfile,
team: profileTeam,
role_name: profileRoleName,
projects: profileProjects,
carousel_photos: carouselPhotos
};
await loadActivity(fetchedProfile.repository_url);
} catch (error) {
console.error('Ошибка загрузки деталей профиля:', error);
Notify.create({
type: 'negative',
message: 'Не удалось загрузить информацию о профиле.',
message: error.message || 'Не удалось загрузить информацию о профиле.',
icon: 'error',
});
profile.value = null;
fillActivityWithZeros();
} finally {
loading.value = false;
}
@ -462,113 +517,125 @@ async function fetchProfileDetails(id) {
function goToTeamDetail(teamId) {
console.log(`Переход на страницу команды: ${teamId}`);
// router.push({ name: 'team-detail', params: { id: teamId } });
router.push({ name: 'team-detail', params: { id: teamId } });
}
function goToProjectDetail(projectId) {
console.log(`Переход на страницу проекта: ${projectId}`);
// router.push({ name: 'project-detail', params: { id: projectId } });
router.push({ name: 'project-detail', params: { id: projectId } });
}
onMounted(async () => {
await fetchProfileDetails(profileId.value);
await loadActivity(); // Загрузка активности при монтировании компонента
});
watch(profileId, async (newId) => {
if (newId) {
await fetchProfileDetails(newId);
await loadActivity(); // Обновление активности при изменении ID профиля
}
});
onUnmounted(() => {
blobUrls.value.forEach(url => URL.revokeObjectURL(url));
});
</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;
}
.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 {
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
min-height: 100vh;
}
.profile-name-card {
border-radius: 22px;
background: #ede9fe;
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
padding: 10px 20px;
}
.violet-card {
border-radius: 22px;
background: #ede9fe;
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>

View File

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