все готово
This commit is contained in:
parent
b147d50d8e
commit
f5ab4f8fe0
@ -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 ###
|
||||
@ -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
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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: {
|
||||
|
||||
33
WEB/src/api/profiles/profile_photos/getProfileByUserId.js
Normal file
33
WEB/src/api/profiles/profile_photos/getProfileByUserId.js
Normal 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;
|
||||
@ -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,
|
||||
|
||||
@ -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}/`,
|
||||
},
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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
@ -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>
|
||||
@ -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>
|
||||
498
WEB/src/pages/ProjectDetailPage.vue
Normal file
498
WEB/src/pages/ProjectDetailPage.vue
Normal 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>
|
||||
707
WEB/src/pages/TeamDetailPage.vue
Normal file
707
WEB/src/pages/TeamDetailPage.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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;
|
||||
Loading…
x
Reference in New Issue
Block a user