все готово
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,6 +11,7 @@ class BaseProfileEntity(BaseModel):
|
|||||||
birthday: datetime.date
|
birthday: datetime.date
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
|
repository_url: Optional[str] = None
|
||||||
|
|
||||||
role_id: int
|
role_id: int
|
||||||
team_id: int
|
team_id: int
|
||||||
|
|||||||
@ -20,5 +20,5 @@ class Contest(AdvancedBaseModel):
|
|||||||
project = relationship('Project', back_populates='contests')
|
project = relationship('Project', back_populates='contests')
|
||||||
status = relationship('ContestStatus', back_populates='contests')
|
status = relationship('ContestStatus', back_populates='contests')
|
||||||
|
|
||||||
carousel_photos = relationship('ContestCarouselPhoto', back_populates='contest')
|
carousel_photos = relationship('ContestCarouselPhoto', back_populates='contest', cascade='all')
|
||||||
files = relationship('ContestFile', back_populates='contest')
|
files = relationship('ContestFile', back_populates='contest', cascade='all')
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, VARCHAR, Date, ForeignKey, Integer
|
from sqlalchemy import Column, VARCHAR, Date, ForeignKey, Integer, String
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from app.domain.models.base import AdvancedBaseModel
|
from app.domain.models.base import AdvancedBaseModel
|
||||||
@ -13,6 +13,7 @@ class Profile(AdvancedBaseModel):
|
|||||||
birthday = Column(Date, nullable=False)
|
birthday = Column(Date, nullable=False)
|
||||||
email = Column(VARCHAR(150))
|
email = Column(VARCHAR(150))
|
||||||
phone = Column(VARCHAR(28))
|
phone = Column(VARCHAR(28))
|
||||||
|
repository_url = Column(String, nullable=True)
|
||||||
|
|
||||||
role_id = Column(Integer, ForeignKey('roles.id'), nullable=False)
|
role_id = Column(Integer, ForeignKey('roles.id'), nullable=False)
|
||||||
team_id = Column(Integer, ForeignKey('teams.id'), nullable=False)
|
team_id = Column(Integer, ForeignKey('teams.id'), nullable=False)
|
||||||
@ -21,5 +22,5 @@ class Profile(AdvancedBaseModel):
|
|||||||
team = relationship('Team', back_populates='profiles')
|
team = relationship('Team', back_populates='profiles')
|
||||||
|
|
||||||
user = relationship('User', back_populates='profile', cascade='all')
|
user = relationship('User', back_populates='profile', cascade='all')
|
||||||
profile_photos = relationship('ProfilePhoto', back_populates='profile')
|
profile_photos = relationship('ProfilePhoto', back_populates='profile', cascade='all')
|
||||||
projects = relationship('ProjectMember', back_populates='profile')
|
projects = relationship('ProjectMember', back_populates='profile')
|
||||||
@ -12,5 +12,5 @@ class Project(AdvancedBaseModel):
|
|||||||
repository_url = Column(String, nullable=False)
|
repository_url = Column(String, nullable=False)
|
||||||
|
|
||||||
contests = relationship("Contest", back_populates="project")
|
contests = relationship("Contest", back_populates="project")
|
||||||
files = relationship("ProjectFile", back_populates="project")
|
files = relationship("ProjectFile", back_populates="project", cascade="all")
|
||||||
members = relationship("ProjectMember", back_populates="project")
|
members = relationship("ProjectMember", back_populates="project")
|
||||||
|
|||||||
@ -83,6 +83,7 @@ class ProfilesService:
|
|||||||
profile_model.birthday = profile.birthday
|
profile_model.birthday = profile.birthday
|
||||||
profile_model.email = profile.email
|
profile_model.email = profile.email
|
||||||
profile_model.phone = profile.phone
|
profile_model.phone = profile.phone
|
||||||
|
profile_model.repository_url = profile.repository_url
|
||||||
profile_model.role_id = profile.role_id
|
profile_model.role_id = profile.role_id
|
||||||
profile_model.team_id = profile.team_id
|
profile_model.team_id = profile.team_id
|
||||||
|
|
||||||
@ -130,6 +131,7 @@ class ProfilesService:
|
|||||||
birthday=profile_model.birthday,
|
birthday=profile_model.birthday,
|
||||||
email=profile_model.email,
|
email=profile_model.email,
|
||||||
phone=profile_model.phone,
|
phone=profile_model.phone,
|
||||||
|
repository_url=profile_model.repository_url,
|
||||||
role_id=profile_model.role_id,
|
role_id=profile_model.role_id,
|
||||||
team_id=profile_model.team_id,
|
team_id=profile_model.team_id,
|
||||||
)
|
)
|
||||||
@ -143,6 +145,7 @@ class ProfilesService:
|
|||||||
birthday=profile_entity.birthday,
|
birthday=profile_entity.birthday,
|
||||||
email=profile_entity.email,
|
email=profile_entity.email,
|
||||||
phone=profile_entity.phone,
|
phone=profile_entity.phone,
|
||||||
|
repository_url=profile_entity.repository_url,
|
||||||
role_id=profile_entity.role_id,
|
role_id=profile_entity.role_id,
|
||||||
team_id=profile_entity.team_id,
|
team_id=profile_entity.team_id,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -21,7 +21,12 @@ def start_app():
|
|||||||
|
|
||||||
api_app.add_middleware(
|
api_app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["https://api.numerum.team", "https://numerum.team", "http://localhost:5173"],
|
allow_origins=[
|
||||||
|
"https://api.numerum.team",
|
||||||
|
"https://numerum.team",
|
||||||
|
"http://localhost:5173", # Это уже есть
|
||||||
|
"http://127.0.0.1:5173" # <-- Добавьте эту строку
|
||||||
|
],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@ -47,7 +52,6 @@ def start_app():
|
|||||||
|
|
||||||
app = start_app()
|
app = start_app()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "Hello API"}
|
return {"message": "Hello API"}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ const deleteContestCarouselPhoto = async (photoId) => {
|
|||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
|
|
||||||
const response = await axios.delete(
|
const response = await axios.delete(
|
||||||
`${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/`, // Изменено здесь
|
`${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/`,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ const downloadContestCarouselPhotoFile = async (photoId) => {
|
|||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
|
|
||||||
const response = await axios.get(
|
const response = await axios.get(
|
||||||
`${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/file`,
|
`${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/file/`,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -12,7 +12,7 @@ const uploadContestCarouselPhoto = async (contestId, file) => {
|
|||||||
formData.append('contest_id', contestId);
|
formData.append('contest_id', contestId);
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${CONFIG.BASE_URL}/contest_carousel_photos/contests/${contestId}/upload`,
|
`${CONFIG.BASE_URL}/contest_carousel_photos/contests/${contestId}/upload/`,
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
|||||||
@ -1,38 +1,82 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import CONFIG from "@/core/config.js";
|
import CONFIG from "@/core/config.js";
|
||||||
|
|
||||||
const downloadContestFile = async (fileId) => {
|
// Добавляем параметры suggestedFileName и suggestedFileFormat для большей надежности
|
||||||
|
const downloadContestFile = async (fileId, suggestedFileName, suggestedFileFormat) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${CONFIG.BASE_URL}/contest_files/${fileId}/file`, {
|
const response = await axios.get(
|
||||||
responseType: 'blob',
|
`${CONFIG.BASE_URL}/contest_files/${fileId}/file`, // Убедитесь, что это правильный URL для скачивания самого файла
|
||||||
withCredentials: true,
|
{
|
||||||
});
|
responseType: 'blob', // Важно для бинарных данных
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
withCredentials: true, // Важно для аутентификации
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Получаем MIME-тип файла из заголовков ответа
|
||||||
|
const contentType = response.headers['content-type'] || 'application/octet-stream';
|
||||||
|
// Создаем Blob с правильным MIME-типом
|
||||||
|
const blob = new Blob([response.data], { type: contentType });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|
||||||
|
let filename = `contest_file_${fileId}`; // Запасное имя файла
|
||||||
|
|
||||||
|
// 1. Попытка получить имя файла из заголовка Content-Disposition
|
||||||
const contentDisposition = response.headers['content-disposition'];
|
const contentDisposition = response.headers['content-disposition'];
|
||||||
let filename = 'download';
|
|
||||||
if (contentDisposition) {
|
if (contentDisposition) {
|
||||||
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
|
// Расширенное регулярное выражение для извлечения filename или filename* (с поддержкой UTF-8)
|
||||||
if (filenameMatch && filenameMatch[1]) {
|
const filenameMatch = contentDisposition.match(/filename\*=(?:UTF-8'')?([^;]+)|filename="([^"]+)"/i);
|
||||||
filename = filenameMatch[1];
|
if (filenameMatch) {
|
||||||
|
if (filenameMatch[1]) { // filename* (RFC 5987)
|
||||||
|
try {
|
||||||
|
filename = decodeURIComponent(filenameMatch[1]);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Ошибка декодирования filename* из Content-Disposition:", e);
|
||||||
|
filename = filenameMatch[1]; // Используем как есть, если декодирование не удалось
|
||||||
|
}
|
||||||
|
} else if (filenameMatch[2]) { // Обычный filename
|
||||||
|
filename = filenameMatch[2];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Если имя файла всё ещё не идеально или не содержит расширения,
|
||||||
|
// используем переданные suggestedFileName и suggestedFileFormat как запасной вариант
|
||||||
|
if (!filename || filename === `contest_file_${fileId}` || !filename.includes('.')) {
|
||||||
|
let finalName = suggestedFileName || `contest_file_${fileId}`;
|
||||||
|
if (suggestedFileFormat && !finalName.toLowerCase().endsWith(`.${suggestedFileFormat.toLowerCase()}`)) {
|
||||||
|
finalName = `${finalName}.${suggestedFileFormat.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
filename = finalName;
|
||||||
|
}
|
||||||
|
|
||||||
link.setAttribute('download', filename);
|
link.setAttribute('download', filename);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url); // Освобождаем память Blob URL
|
||||||
|
|
||||||
return filename;
|
return filename;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response?.status === 401) {
|
// Улучшенная обработка ошибок для более информативных сообщений
|
||||||
throw new Error("Нет доступа для скачивания файла (401)");
|
if (error.response) {
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
throw new Error("Недостаточно прав для скачивания файла конкурса (401). Пожалуйста, авторизуйтесь.");
|
||||||
}
|
}
|
||||||
if (error.response?.status === 404) {
|
if (error.response.status === 404) {
|
||||||
throw new Error("Файл не найден (404)");
|
throw new Error(`Файл конкурса с ID ${fileId} не найден (404). Возможно, он был удалён.`);
|
||||||
|
}
|
||||||
|
// Попробуем получить сообщение об ошибке из данных ответа, если это JSON
|
||||||
|
if (error.response.data && typeof error.response.data === 'object' && error.response.data.detail) {
|
||||||
|
throw new Error(`Ошибка сервера при скачивании файла конкурса: ${error.response.data.detail}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Ошибка сети или сервера при скачивании файла конкурса: Статус ${error.response.status}`);
|
||||||
|
} else if (error.request) {
|
||||||
|
throw new Error("Не удалось отправить запрос на скачивание файла конкурса. Проверьте ваше интернет-соединение.");
|
||||||
|
} else {
|
||||||
|
throw new Error(`Неизвестная ошибка при скачивании файла конкурса: ${error.message}`);
|
||||||
}
|
}
|
||||||
throw new Error(error.message);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,12 +4,7 @@ import CONFIG from '@/core/config.js'
|
|||||||
const updateContest = async (profile) => {
|
const updateContest = async (profile) => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
|
|
||||||
// Убираем id из тела запроса, он идет в URL
|
|
||||||
const { id, ...profileData } = profile
|
const { id, ...profileData } = profile
|
||||||
|
|
||||||
console.log('Отправляем на сервер:', profileData)
|
|
||||||
|
|
||||||
const response = await axios.put(
|
const response = await axios.put(
|
||||||
`${CONFIG.BASE_URL}/contests/${id}/`,
|
`${CONFIG.BASE_URL}/contests/${id}/`,
|
||||||
profileData,
|
profileData,
|
||||||
@ -20,8 +15,6 @@ const updateContest = async (profile) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('Ответ от сервера:', response.data)
|
|
||||||
return response.data
|
return response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(error.response?.data?.detail || error.message)
|
throw new Error(error.response?.data?.detail || error.message)
|
||||||
|
|||||||
@ -7,7 +7,7 @@ const downloadPhotoFile = async (photoId) => {
|
|||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
|
|
||||||
const response = await axios.get(
|
const response = await axios.get(
|
||||||
`${CONFIG.BASE_URL}/profile_photos/${photoId}/file`,
|
`${CONFIG.BASE_URL}/profile_photos/${photoId}/file/`,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
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)
|
formData.append('file', file)
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${CONFIG.BASE_URL}/profile_photos/profiles/${profileId}/upload`,
|
`${CONFIG.BASE_URL}/profile_photos/profiles/${profileId}/upload/`,
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import CONFIG from "@/core/config.js";
|
|||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
const response = await axios.get(`${CONFIG.BASE_URL}/projects`, {
|
const response = await axios.get(`${CONFIG.BASE_URL}/projects/`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}/`,
|
Authorization: `Bearer ${token}/`,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,43 +1,82 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import CONFIG from '@/core/config.js';
|
import CONFIG from '@/core/config.js';
|
||||||
|
|
||||||
const downloadProjectFile = async (fileId) => {
|
// Добавляем параметры suggestedFileName и suggestedFileFormat
|
||||||
|
const downloadProjectFile = async (fileId, suggestedFileName, suggestedFileFormat) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(
|
const response = await axios.get(
|
||||||
`${CONFIG.BASE_URL}/project_files/${fileId}/download/`,
|
`${CONFIG.BASE_URL}/project_files/${fileId}/file/`, // Убедитесь, что это правильный URL для скачивания самого файла
|
||||||
{
|
{
|
||||||
responseType: 'blob',
|
responseType: 'blob', // Важно для бинарных данных
|
||||||
withCredentials: true,
|
withCredentials: true, // Важно для аутентификации
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
// Получаем MIME-тип файла из заголовков ответа
|
||||||
|
const contentType = response.headers['content-type'] || 'application/octet-stream';
|
||||||
|
// Создаем Blob с правильным MIME-типом
|
||||||
|
const blob = new Blob([response.data], { type: contentType });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|
||||||
|
let filename = `project_file_${fileId}`; // Запасное имя файла
|
||||||
|
|
||||||
|
// 1. Попытка получить имя файла из заголовка Content-Disposition
|
||||||
const contentDisposition = response.headers['content-disposition'];
|
const contentDisposition = response.headers['content-disposition'];
|
||||||
let filename = `project_file_${fileId}`;
|
|
||||||
if (contentDisposition) {
|
if (contentDisposition) {
|
||||||
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
|
// Расширенное регулярное выражение для извлечения filename или filename* (с поддержкой UTF-8)
|
||||||
if (filenameMatch && filenameMatch[1]) {
|
const filenameMatch = contentDisposition.match(/filename\*=(?:UTF-8'')?([^;]+)|filename="([^"]+)"/i);
|
||||||
|
if (filenameMatch) {
|
||||||
|
if (filenameMatch[1]) { // filename* (RFC 5987)
|
||||||
|
try {
|
||||||
filename = decodeURIComponent(filenameMatch[1]);
|
filename = decodeURIComponent(filenameMatch[1]);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Ошибка декодирования filename* из Content-Disposition:", e);
|
||||||
|
filename = filenameMatch[1]; // Используем как есть, если декодирование не удалось
|
||||||
|
}
|
||||||
|
} else if (filenameMatch[2]) { // Обычный filename
|
||||||
|
filename = filenameMatch[2];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Если имя файла всё ещё не идеально или не содержит расширения,
|
||||||
|
// используем переданные suggestedFileName и suggestedFileFormat как запасной вариант
|
||||||
|
if (!filename || filename === `project_file_${fileId}` || !filename.includes('.')) {
|
||||||
|
let finalName = suggestedFileName || `project_file_${fileId}`;
|
||||||
|
if (suggestedFileFormat && !finalName.toLowerCase().endsWith(`.${suggestedFileFormat.toLowerCase()}`)) {
|
||||||
|
finalName = `${finalName}.${suggestedFileFormat.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
filename = finalName;
|
||||||
|
}
|
||||||
|
|
||||||
link.setAttribute('download', filename);
|
link.setAttribute('download', filename);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url); // Освобождаем память Blob URL
|
||||||
|
|
||||||
return filename;
|
return filename;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response?.status === 401) {
|
// Улучшенная обработка ошибок для более информативных сообщений
|
||||||
throw new Error("Недостаточно прав для скачивания файла (401)");
|
if (error.response) {
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
throw new Error("Недостаточно прав для скачивания файла (401). Пожалуйста, авторизуйтесь.");
|
||||||
}
|
}
|
||||||
if (error.response?.status === 404) {
|
if (error.response.status === 404) {
|
||||||
throw new Error("Файл не найден (404)");
|
throw new Error(`Файл с ID ${fileId} не найден (404). Возможно, он был удалён.`);
|
||||||
|
}
|
||||||
|
// Попробуем получить сообщение об ошибке из данных ответа, если это JSON
|
||||||
|
if (error.response.data && typeof error.response.data === 'object' && error.response.data.detail) {
|
||||||
|
throw new Error(`Ошибка сервера при скачивании файла: ${error.response.data.detail}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Ошибка сети или сервера при скачивании файла: Статус ${error.response.status}`);
|
||||||
|
} else if (error.request) {
|
||||||
|
throw new Error("Не удалось отправить запрос на скачивание файла. Проверьте ваше интернет-соединение.");
|
||||||
|
} else {
|
||||||
|
throw new Error(`Неизвестная ошибка при скачивании файла: ${error.message}`);
|
||||||
}
|
}
|
||||||
throw new Error(error.message);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,8 @@ import {
|
|||||||
QItem,
|
QItem,
|
||||||
QImg,
|
QImg,
|
||||||
QFile,
|
QFile,
|
||||||
QSelect
|
QSelect,
|
||||||
|
QSpinner
|
||||||
} from 'quasar'
|
} from 'quasar'
|
||||||
|
|
||||||
|
|
||||||
@ -61,7 +62,7 @@ app.use(Quasar, {
|
|||||||
QSeparator, QCardActions, QDialog, QIcon, QSpace,
|
QSeparator, QCardActions, QDialog, QIcon, QSpace,
|
||||||
QAvatar, QTooltip, QBanner, QSlideTransition, QToggle,
|
QAvatar, QTooltip, QBanner, QSlideTransition, QToggle,
|
||||||
QList, QSpinnerDots, QCarouselSlide, QCarousel,
|
QList, QSpinnerDots, QCarouselSlide, QCarousel,
|
||||||
QItemSection, QItemLabel, QItem, QImg, QFile, QSelect
|
QItemSection, QItemLabel, QItem, QImg, QFile, QSelect, QSpinner
|
||||||
},
|
},
|
||||||
directives: {
|
directives: {
|
||||||
Ripple
|
Ripple
|
||||||
|
|||||||
@ -183,6 +183,8 @@
|
|||||||
@update:model-value="handleTeamIsActiveToggle"
|
@update:model-value="handleTeamIsActiveToggle"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<template v-if="dialogType === 'teams'">
|
||||||
|
<div v-if="dialogData.id">
|
||||||
<q-separator class="q-my-md"/>
|
<q-separator class="q-my-md"/>
|
||||||
<div class="text-h6 q-mb-sm">Логотип команды</div>
|
<div class="text-h6 q-mb-sm">Логотип команды</div>
|
||||||
|
|
||||||
@ -212,7 +214,6 @@
|
|||||||
accept="image/*"
|
accept="image/*"
|
||||||
@update:model-value="handleNewTeamLogoSelected"
|
@update:model-value="handleNewTeamLogoSelected"
|
||||||
class="q-mt-sm"
|
class="q-mt-sm"
|
||||||
:rules="dialogData.id ? [] : [val => !!val || 'Логотип обязателен']"
|
|
||||||
>
|
>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<q-icon v-if="newTeamLogoFile" name="check" color="positive"/>
|
<q-icon v-if="newTeamLogoFile" name="check" color="positive"/>
|
||||||
@ -227,6 +228,8 @@
|
|||||||
@click="uploadNewTeamLogo"
|
@click="uploadNewTeamLogo"
|
||||||
:loading="uploadingLogo"
|
:loading="uploadingLogo"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="dialogType === 'profiles'">
|
<template v-else-if="dialogType === 'profiles'">
|
||||||
@ -234,8 +237,12 @@
|
|||||||
<q-input v-model="dialogData.last_name" label="Фамилия" dense clearable class="q-mt-sm"/>
|
<q-input v-model="dialogData.last_name" label="Фамилия" dense clearable class="q-mt-sm"/>
|
||||||
<q-input v-model="dialogData.patronymic" label="Отчество" dense clearable class="q-mt-sm"/>
|
<q-input v-model="dialogData.patronymic" label="Отчество" dense clearable class="q-mt-sm"/>
|
||||||
<q-input v-model="dialogData.birthday" label="День рождения" dense clearable class="q-mt-sm" type="date"/>
|
<q-input v-model="dialogData.birthday" label="День рождения" dense clearable class="q-mt-sm" type="date"/>
|
||||||
<q-input v-model="dialogData.email" label="Почта" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.email" label="Почта" dense clearable class="q-mt-sm" type="email"/>
|
||||||
<q-input v-model="dialogData.phone" label="Телефон" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.phone" label="Телефон" dense clearable class="q-mt-sm"
|
||||||
|
mask="+7 (###) ###-##-##" unmask-value/>
|
||||||
|
<q-input v-model="dialogData.repository_url" label="URL репозитория" dense clearable class="q-mt-sm"
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
<q-select
|
<q-select
|
||||||
v-model="dialogData.role_id"
|
v-model="dialogData.role_id"
|
||||||
label="Роль"
|
label="Роль"
|
||||||
@ -258,6 +265,8 @@
|
|||||||
map-options
|
map-options
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<template v-if="dialogType === 'profiles'">
|
||||||
|
<div v-if="dialogData.id">
|
||||||
<q-separator class="q-my-md"/>
|
<q-separator class="q-my-md"/>
|
||||||
<div class="text-h6 q-mb-sm">Фотографии профиля</div>
|
<div class="text-h6 q-mb-sm">Фотографии профиля</div>
|
||||||
|
|
||||||
@ -269,7 +278,8 @@
|
|||||||
Пока нет фотографий.
|
Пока нет фотографий.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="q-gutter-md q-mb-md row wrap justify-center">
|
<div v-else class="q-gutter-md q-mb-md row wrap justify-center">
|
||||||
<q-card v-for="photo in profilePhotos" :key="photo.id" class="col-auto" style="width: 120px; height: 120px; position: relative;">
|
<q-card v-for="photo in profilePhotos" :key="photo.id" class="col-auto"
|
||||||
|
style="width: 120px; height: 120px; position: relative;">
|
||||||
<q-img
|
<q-img
|
||||||
:src="getPhotoUrl(photo.id, 'profile')"
|
:src="getPhotoUrl(photo.id, 'profile')"
|
||||||
alt="Profile Photo"
|
alt="Profile Photo"
|
||||||
@ -312,13 +322,18 @@
|
|||||||
@click="uploadNewProfilePhoto"
|
@click="uploadNewProfilePhoto"
|
||||||
:loading="uploadingPhoto"
|
:loading="uploadingPhoto"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="dialogType === 'projects'">
|
<template v-else-if="dialogType === 'projects'">
|
||||||
<q-input v-model="dialogData.title" label="Название проекта" dense autofocus clearable/>
|
<q-input v-model="dialogData.title" label="Название проекта" dense autofocus clearable/>
|
||||||
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm"/>
|
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm"/>
|
||||||
<q-input v-model="dialogData.repository_url" label="URL репозитория" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.repository_url" label="URL репозитория" dense clearable class="q-mt-sm"
|
||||||
|
type="url"/>
|
||||||
|
|
||||||
|
<template v-if="dialogType === 'projects'">
|
||||||
|
<div v-if="dialogData.id">
|
||||||
<q-separator class="q-my-md"/>
|
<q-separator class="q-my-md"/>
|
||||||
<div class="text-h6 q-mb-sm">Файлы проекта</div>
|
<div class="text-h6 q-mb-sm">Файлы проекта</div>
|
||||||
|
|
||||||
@ -380,12 +395,14 @@
|
|||||||
@click="uploadNewProjectFile"
|
@click="uploadNewProjectFile"
|
||||||
:loading="uploadingFile"
|
:loading="uploadingFile"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="dialogType === 'contests'">
|
<template v-else-if="dialogType === 'contests'">
|
||||||
<q-input v-model="dialogData.title" label="Название конкурса" dense autofocus clearable/>
|
<q-input v-model="dialogData.title" label="Название конкурса" dense autofocus clearable/>
|
||||||
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm"/>
|
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm"/>
|
||||||
<q-input v-model="dialogData.web_url" label="URL сайта" dense clearable class="q-mt-sm" />
|
<q-input v-model="dialogData.web_url" label="URL сайта" dense clearable class="q-mt-sm" type="url"/>
|
||||||
<q-input v-model="dialogData.results" label="Результаты" dense clearable class="q-mt-sm"/>
|
<q-input v-model="dialogData.results" label="Результаты" dense clearable class="q-mt-sm"/>
|
||||||
<q-toggle v-model="dialogData.is_win" label="Победа (Да/Нет)" dense class="q-mt-sm"/>
|
<q-toggle v-model="dialogData.is_win" label="Победа (Да/Нет)" dense class="q-mt-sm"/>
|
||||||
<q-select
|
<q-select
|
||||||
@ -406,10 +423,12 @@
|
|||||||
dense
|
dense
|
||||||
clearable
|
clearable
|
||||||
class="q-mt-sm"
|
class="q-mt-sm"
|
||||||
:options="[{ label: 'Завершен', value: 1 }, { label: 'В процессе', value: 2 }, { label: 'Ожидает начала', value: 3 }]" emit-value
|
:options="[{ label: 'Завершен', value: 1 }, { label: 'В процессе', value: 2 }, { label: 'Ожидает начала', value: 3 }]"
|
||||||
|
emit-value
|
||||||
map-options
|
map-options
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div v-if="dialogData.id">
|
||||||
<q-separator class="q-my-md"/>
|
<q-separator class="q-my-md"/>
|
||||||
<div class="text-h6 q-mb-sm">Фотографии карусели конкурса</div>
|
<div class="text-h6 q-mb-sm">Фотографии карусели конкурса</div>
|
||||||
|
|
||||||
@ -421,7 +440,8 @@
|
|||||||
Пока нет фотографий.
|
Пока нет фотографий.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="q-gutter-md q-mb-md row wrap justify-center">
|
<div v-else class="q-gutter-md q-mb-md row wrap justify-center">
|
||||||
<q-card v-for="photo in contestPhotos" :key="photo.id" class="col-auto" style="width: 120px; height: 120px; position: relative;">
|
<q-card v-for="photo in contestPhotos" :key="photo.id" class="col-auto"
|
||||||
|
style="width: 120px; height: 120px; position: relative;">
|
||||||
<q-img
|
<q-img
|
||||||
:src="getPhotoUrl(photo.id, 'contest')"
|
:src="getPhotoUrl(photo.id, 'contest')"
|
||||||
alt="Contest Photo"
|
alt="Contest Photo"
|
||||||
@ -464,7 +484,9 @@
|
|||||||
@click="uploadNewContestPhoto"
|
@click="uploadNewContestPhoto"
|
||||||
:loading="uploadingPhoto"
|
:loading="uploadingPhoto"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dialogData.id">
|
||||||
<q-separator class="q-my-md"/>
|
<q-separator class="q-my-md"/>
|
||||||
<div class="text-h6 q-mb-sm">Файлы конкурса</div>
|
<div class="text-h6 q-mb-sm">Файлы конкурса</div>
|
||||||
|
|
||||||
@ -526,6 +548,7 @@
|
|||||||
@click="uploadNewContestFile"
|
@click="uploadNewContestFile"
|
||||||
:loading="uploadingFile"
|
:loading="uploadingFile"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="dialogType === 'project_members'">
|
<template v-else-if="dialogType === 'project_members'">
|
||||||
@ -625,8 +648,12 @@
|
|||||||
<q-input v-model="newUserData.last_name" label="Фамилия" dense clearable class="q-mt-sm"/>
|
<q-input v-model="newUserData.last_name" label="Фамилия" dense clearable class="q-mt-sm"/>
|
||||||
<q-input v-model="newUserData.patronymic" label="Отчество" dense clearable class="q-mt-sm"/>
|
<q-input v-model="newUserData.patronymic" label="Отчество" dense clearable class="q-mt-sm"/>
|
||||||
<q-input v-model="newUserData.birthday" label="День рождения" dense clearable class="q-mt-sm" type="date"/>
|
<q-input v-model="newUserData.birthday" label="День рождения" dense clearable class="q-mt-sm" type="date"/>
|
||||||
<q-input v-model="newUserData.email" label="Почта" dense clearable class="q-mt-sm" />
|
<q-input v-model="newUserData.email" label="Почта" dense clearable class="q-mt-sm" type="email"/>
|
||||||
<q-input v-model="newUserData.phone" label="Телефон" dense clearable class="q-mt-sm" />
|
<q-input v-model="newUserData.phone" label="Телефон" dense clearable class="q-mt-sm" mask="+7 (###) ###-##-##"
|
||||||
|
unmask-value/>
|
||||||
|
<q-input v-model="dialogData.repository_url" label="URL репозитория" dense clearable class="q-mt-sm"
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
|
||||||
<q-select
|
<q-select
|
||||||
v-model="newUserData.role_id"
|
v-model="newUserData.role_id"
|
||||||
@ -726,6 +753,15 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
:icon="'home'"
|
||||||
|
class="fixed-bottom-right q-mr-md"
|
||||||
|
style="bottom: 80px;" size="20px"
|
||||||
|
color="indigo-10"
|
||||||
|
round
|
||||||
|
@click="goToHomePage"
|
||||||
|
/>
|
||||||
|
|
||||||
<q-btn
|
<q-btn
|
||||||
:icon="'logout'"
|
:icon="'logout'"
|
||||||
class="fixed-bottom-right q-ma-md"
|
class="fixed-bottom-right q-ma-md"
|
||||||
@ -769,12 +805,10 @@ import createContest from '@/api/contests/createContest.js'
|
|||||||
import getProfilePhotosByProfileId from '@/api/profiles/profile_photos/getPhotoFileById.js'
|
import getProfilePhotosByProfileId from '@/api/profiles/profile_photos/getPhotoFileById.js'
|
||||||
import uploadProfilePhoto from '@/api/profiles/profile_photos/uploadProfilePhoto.js'
|
import uploadProfilePhoto from '@/api/profiles/profile_photos/uploadProfilePhoto.js'
|
||||||
import deleteProfilePhoto from '@/api/profiles/profile_photos/deletePhoto.js'
|
import deleteProfilePhoto from '@/api/profiles/profile_photos/deletePhoto.js'
|
||||||
import downloadProfilePhotoFile from '@/api/profiles/profile_photos/downloadPhotoFile.js'
|
|
||||||
|
|
||||||
import getContestCarouselPhotosByContestId from '@/api/contests/contest_carousel_photos/getContestPhotoFileById.js'
|
import getContestCarouselPhotosByContestId from '@/api/contests/contest_carousel_photos/getContestPhotoFileById.js'
|
||||||
import uploadContestCarouselPhoto from '@/api/contests/contest_carousel_photos/uploadContestProfilePhoto.js'
|
import uploadContestCarouselPhoto from '@/api/contests/contest_carousel_photos/uploadContestProfilePhoto.js'
|
||||||
import deleteContestCarouselPhoto from '@/api/contests/contest_carousel_photos/deleteContestPhoto.js'
|
import deleteContestCarouselPhoto from '@/api/contests/contest_carousel_photos/deleteContestPhoto.js'
|
||||||
import downloadContestCarouselPhotoFile from '@/api/contests/contest_carousel_photos/downloadContestPhotoFile.js'
|
|
||||||
|
|
||||||
import getContestFiles from '@/api/contests/contest_files/getContestFiles.js'
|
import getContestFiles from '@/api/contests/contest_files/getContestFiles.js'
|
||||||
import uploadContestFile from '@/api/contests/contest_files/uploadContestFile.js'
|
import uploadContestFile from '@/api/contests/contest_files/uploadContestFile.js'
|
||||||
@ -789,7 +823,6 @@ import downloadProjectFile from '@/api/projects/project_files/downloadProjectFil
|
|||||||
import getProjectMemberByProject from "@/api/project_members/getProjectMemberByProject.js";
|
import getProjectMemberByProject from "@/api/project_members/getProjectMemberByProject.js";
|
||||||
import getProjectMemberByProfile from "@/api/project_members/getProjectMembersByProfile.js";
|
import getProjectMemberByProfile from "@/api/project_members/getProjectMembersByProfile.js";
|
||||||
import createProjectMember from "@/api/project_members/createProjectMember.js";
|
import createProjectMember from "@/api/project_members/createProjectMember.js";
|
||||||
import deleteProjectMember from "@/api/project_members/deleteProjectMember.js";
|
|
||||||
import updateProjectMember from "@/api/project_members/updateProjectMember.js";
|
import updateProjectMember from "@/api/project_members/updateProjectMember.js";
|
||||||
import getAllProjectMembers from "@/api/project_members/getAllProjectMembers.js";
|
import getAllProjectMembers from "@/api/project_members/getAllProjectMembers.js";
|
||||||
import deleteSingleProjectMember from "@/api/project_members/deleteSingleProjectMember.js"; // НОВЫЙ ИМПОРТ
|
import deleteSingleProjectMember from "@/api/project_members/deleteSingleProjectMember.js"; // НОВЫЙ ИМПОРТ
|
||||||
@ -810,6 +843,7 @@ const newUserData = ref({ // Модель для новых данных пол
|
|||||||
birthday: null, // Используйте null для даты
|
birthday: null, // Используйте null для даты
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
|
repository_url: '',
|
||||||
role_id: null, // null для q-select, если по умолчанию не выбрано
|
role_id: null, // null для q-select, если по умолчанию не выбрано
|
||||||
team_id: null, // null для q-select
|
team_id: null, // null для q-select
|
||||||
login: '',
|
login: '',
|
||||||
@ -819,6 +853,11 @@ const confirmNewUserPassword = ref('');
|
|||||||
|
|
||||||
const profiles = ref([])
|
const profiles = ref([])
|
||||||
const loadingProfiles = ref(false)
|
const loadingProfiles = ref(false)
|
||||||
|
const rolesMap = {
|
||||||
|
1: 'Администратор',
|
||||||
|
2: 'Участник'
|
||||||
|
};
|
||||||
|
|
||||||
const profileColumns = [
|
const profileColumns = [
|
||||||
{name: 'first_name', label: 'Имя', field: 'first_name', sortable: true},
|
{name: 'first_name', label: 'Имя', field: 'first_name', sortable: true},
|
||||||
{name: 'last_name', label: 'Фамилия', field: 'last_name', sortable: true},
|
{name: 'last_name', label: 'Фамилия', field: 'last_name', sortable: true},
|
||||||
@ -826,8 +865,24 @@ const profileColumns = [
|
|||||||
{name: 'birthday', label: 'День рождения', field: 'birthday', sortable: true},
|
{name: 'birthday', label: 'День рождения', field: 'birthday', sortable: true},
|
||||||
{name: 'email', label: 'Почта', field: 'email', sortable: true},
|
{name: 'email', label: 'Почта', field: 'email', sortable: true},
|
||||||
{name: 'phone', label: 'Телефон', field: 'phone', sortable: true},
|
{name: 'phone', label: 'Телефон', field: 'phone', sortable: true},
|
||||||
{ name: 'role_id', label: 'Роль', field: 'role_id', sortable: true },
|
{name: 'repository_url', label: 'URL репозитория', field: 'repository_url', sortable: true}, // ИСПРАВЛЕНО: Теперь ссылается на 'repository_url'
|
||||||
{ name: 'team_id', label: 'Команда', field: 'team_id', sortable: true },
|
{
|
||||||
|
name: 'role_id',
|
||||||
|
label: 'Роль',
|
||||||
|
field: 'role_id',
|
||||||
|
sortable: true,
|
||||||
|
format: val => rolesMap[val] || 'Неизвестно'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'team_id',
|
||||||
|
label: 'Команда',
|
||||||
|
field: 'team_id',
|
||||||
|
sortable: true,
|
||||||
|
format: val => {
|
||||||
|
const team = teams.value.find(t => t.id === val);
|
||||||
|
return team ? team.title : 'Неизвестно';
|
||||||
|
}
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const teams = ref([])
|
const teams = ref([])
|
||||||
@ -835,9 +890,14 @@ const loadingTeams = ref(false)
|
|||||||
const teamColumns = [
|
const teamColumns = [
|
||||||
{name: 'title', label: 'Название команды', field: 'title', sortable: true},
|
{name: 'title', label: 'Название команды', field: 'title', sortable: true},
|
||||||
{name: 'description', label: 'Описание', field: 'description', sortable: true},
|
{name: 'description', label: 'Описание', field: 'description', sortable: true},
|
||||||
{ name: 'logo', label: 'Логотип', field: 'logo', sortable: true },
|
|
||||||
{name: 'git_url', label: 'Git URL', field: 'git_url', sortable: true},
|
{name: 'git_url', label: 'Git URL', field: 'git_url', sortable: true},
|
||||||
{ name: 'is_active', label: 'Активна', field: 'is_active', sortable: true },
|
{
|
||||||
|
name: 'is_active',
|
||||||
|
label: 'Активна',
|
||||||
|
field: 'is_active',
|
||||||
|
sortable: true,
|
||||||
|
format: val => val ? 'Да' : 'Нет' // <-- Добавлено форматирование
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const newTeamLogoFile = ref(null);
|
const newTeamLogoFile = ref(null);
|
||||||
@ -854,14 +914,41 @@ const projectColumns = [
|
|||||||
|
|
||||||
const contests = ref([])
|
const contests = ref([])
|
||||||
const loadingContests = ref(false)
|
const loadingContests = ref(false)
|
||||||
|
const contestStatusesMap = {
|
||||||
|
1: 'Завершен',
|
||||||
|
2: 'В процессе',
|
||||||
|
3: 'Ожидает начала'
|
||||||
|
};
|
||||||
|
|
||||||
const contestColumns = [
|
const contestColumns = [
|
||||||
{name: 'title', label: 'Название конкурса', field: 'title', sortable: true},
|
{name: 'title', label: 'Название конкурса', field: 'title', sortable: true},
|
||||||
{name: 'description', label: 'Описание', field: 'description', sortable: true},
|
{name: 'description', label: 'Описание', field: 'description', sortable: true},
|
||||||
{name: 'web_url', label: 'URL сайта', field: 'web_url', sortable: true},
|
{name: 'web_url', label: 'URL сайта', field: 'web_url', sortable: true},
|
||||||
{name: 'results', label: 'Результаты', field: 'results', sortable: true},
|
{name: 'results', label: 'Результаты', field: 'results', sortable: true},
|
||||||
{ name: 'is_win', label: 'Победа', field: 'is_win', sortable: true },
|
{
|
||||||
{ name: 'project_id', label: 'Проект', field: 'project_id', sortable: true },
|
name: 'is_win',
|
||||||
{ name: 'status_id', label: 'Статус', field: 'status_id', sortable: true },
|
label: 'Победа',
|
||||||
|
field: 'is_win',
|
||||||
|
sortable: true,
|
||||||
|
format: val => val ? 'Да' : 'Нет' // <-- Добавлено форматирование
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'project_id',
|
||||||
|
label: 'Проект',
|
||||||
|
field: 'project_id',
|
||||||
|
sortable: true,
|
||||||
|
format: val => {
|
||||||
|
const project = allProjects.value.find(p => p.id === val);
|
||||||
|
return project ? project.title : 'Неизвестно'; // <-- Добавлено форматирование
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status_id',
|
||||||
|
label: 'Статус',
|
||||||
|
field: 'status_id',
|
||||||
|
sortable: true,
|
||||||
|
format: val => contestStatusesMap[val] || 'Неизвестно' // <-- Добавлено форматирование
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const projectMembers = ref([])
|
const projectMembers = ref([])
|
||||||
@ -923,15 +1010,12 @@ const projectFiles = ref([])
|
|||||||
const loadingProjectFiles = ref(false)
|
const loadingProjectFiles = ref(false)
|
||||||
const newProjectFile = ref(null)
|
const newProjectFile = ref(null)
|
||||||
|
|
||||||
// TO DO
|
|
||||||
// ДОБАВИТЬ ПРАВИЛЬНЫЙ URL
|
|
||||||
const getPhotoUrl = (photoId, type) => {
|
const getPhotoUrl = (photoId, type) => {
|
||||||
if (type === 'profile') {
|
if (type === 'profile') {
|
||||||
return `${CONFIG.BASE_URL}/profile_photos/${photoId}/file`;
|
return `${CONFIG.BASE_URL}/profile_photos/${photoId}/file/`;
|
||||||
} else if (type === 'contest') {
|
} else if (type === 'contest') {
|
||||||
return `${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/file`;
|
return `${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/file/`;
|
||||||
} else if (type === 'teams') {
|
|
||||||
return `${CONFIG.BASE_URL}//${photoId}/file`;
|
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@ -1061,11 +1145,11 @@ async function uploadNewTeamLogo() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadingLogo.value = true; // Используем существующий индикатор
|
uploadingLogo.value = true;
|
||||||
try {
|
try {
|
||||||
const uploadedLogo = await uploadTeamLogo(dialogData.value.id, newTeamLogoFile.value);
|
const uploadedLogo = await uploadTeamLogo(dialogData.value.id, newTeamLogoFile.value);
|
||||||
|
|
||||||
dialogData.value.logoUrl = `${CONFIG.BASE_URL}/teams/${dialogData.value.id}/logo`;
|
dialogData.value.logoUrl = `${CONFIG.BASE_URL}/teams/${dialogData.value.id}/file/`;
|
||||||
|
|
||||||
newTeamLogoFile.value = null; // Очищаем выбранный файл
|
newTeamLogoFile.value = null; // Очищаем выбранный файл
|
||||||
Notify.create({
|
Notify.create({
|
||||||
@ -1340,7 +1424,7 @@ function openEdit(type, row) {
|
|||||||
if (row) {
|
if (row) {
|
||||||
dialogData.value = JSON.parse(JSON.stringify(row));
|
dialogData.value = JSON.parse(JSON.stringify(row));
|
||||||
if (type === 'teams' && dialogData.value.logo) {
|
if (type === 'teams' && dialogData.value.logo) {
|
||||||
dialogData.value.logoUrl = `${CONFIG.BASE_URL}/teams/${dialogData.value.id}/logo`;
|
dialogData.value.logoUrl = `${CONFIG.BASE_URL}/teams/${dialogData.value.id}/file/`;
|
||||||
} else if (type === 'teams') {
|
} else if (type === 'teams') {
|
||||||
dialogData.value.logoUrl = null;
|
dialogData.value.logoUrl = null;
|
||||||
}
|
}
|
||||||
@ -1358,10 +1442,29 @@ function openEdit(type, row) {
|
|||||||
dialogData.value = {title: '', description: '', repository_url: ''};
|
dialogData.value = {title: '', description: '', repository_url: ''};
|
||||||
projectFiles.value = [];
|
projectFiles.value = [];
|
||||||
} else if (type === 'profiles') {
|
} else if (type === 'profiles') {
|
||||||
dialogData.value = { first_name: '', last_name: '', patronymic: '', birthday: '', email: '', phone: '', role_id: null, team_id: null };
|
dialogData.value = {
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
patronymic: '',
|
||||||
|
birthday: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
repository_url: '',
|
||||||
|
role_id: null,
|
||||||
|
team_id: null
|
||||||
|
};
|
||||||
profilePhotos.value = [];
|
profilePhotos.value = [];
|
||||||
} else if (type === 'contests') {
|
} else if (type === 'contests') {
|
||||||
dialogData.value = { title: '', description: '', web_url: '', photo: '', results: '', is_win: false, project_id: null, status_id: null };
|
dialogData.value = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
web_url: '',
|
||||||
|
photo: '',
|
||||||
|
results: '',
|
||||||
|
is_win: false,
|
||||||
|
project_id: null,
|
||||||
|
status_id: null
|
||||||
|
};
|
||||||
contestPhotos.value = [];
|
contestPhotos.value = [];
|
||||||
contestFiles.value = [];
|
contestFiles.value = [];
|
||||||
} else if (type === 'project_members') {
|
} else if (type === 'project_members') {
|
||||||
@ -1392,9 +1495,7 @@ function openEdit(type, row) {
|
|||||||
Notify.create({type: 'negative', message: `Ошибка загрузки профилей для списка: ${error.message}`});
|
Notify.create({type: 'negative', message: `Ошибка загрузки профилей для списка: ${error.message}`});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
|
||||||
else {
|
|
||||||
profilePhotos.value = [];
|
profilePhotos.value = [];
|
||||||
contestPhotos.value = [];
|
contestPhotos.value = [];
|
||||||
contestFiles.value = [];
|
contestFiles.value = [];
|
||||||
@ -1423,6 +1524,16 @@ function closeDialog() {
|
|||||||
async function saveChanges() {
|
async function saveChanges() {
|
||||||
try {
|
try {
|
||||||
if (dialogType.value === 'teams') {
|
if (dialogType.value === 'teams') {
|
||||||
|
if (!dialogData.value.title || dialogData.value.title.trim() === '') {
|
||||||
|
Notify.create({type: 'negative', message: 'Название команды обязательно.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Валидация Git URL, только если поле заполнено
|
||||||
|
if (dialogData.value.git_url && !/^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/.test(dialogData.value.git_url)) {
|
||||||
|
Notify.create({type: 'negative', message: 'Некорректный формат Git URL.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (dialogData.value.id) {
|
if (dialogData.value.id) {
|
||||||
await updateTeam(dialogData.value);
|
await updateTeam(dialogData.value);
|
||||||
const idx = teams.value.findIndex(t => t.id === dialogData.value.id);
|
const idx = teams.value.findIndex(t => t.id === dialogData.value.id);
|
||||||
@ -1432,33 +1543,107 @@ async function saveChanges() {
|
|||||||
const newTeam = await createTeam(newTeamData);
|
const newTeam = await createTeam(newTeamData);
|
||||||
teams.value.push(newTeam);
|
teams.value.push(newTeam);
|
||||||
}
|
}
|
||||||
|
closeDialog();
|
||||||
} else if (dialogType.value === 'projects') {
|
} else if (dialogType.value === 'projects') {
|
||||||
if (dialogData.value.id) {
|
if (!dialogData.value.title || dialogData.value.title.trim() === '') {
|
||||||
await updateProject(dialogData.value)
|
Notify.create({type: 'negative', message: 'Название проекта обязательно.', icon: 'error'});
|
||||||
const idx = projects.value.findIndex(p => p.id === dialogData.value.id)
|
return;
|
||||||
if (idx !== -1) projects.value[idx] = JSON.parse(JSON.stringify(dialogData.value))
|
|
||||||
} else {
|
|
||||||
const newProject = await createProject(dialogData.value)
|
|
||||||
projects.value.push(newProject)
|
|
||||||
}
|
}
|
||||||
|
if (!dialogData.value.description || dialogData.value.description.trim() === '') {
|
||||||
|
Notify.create({type: 'negative', message: 'Описание проекта обязательно.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Валидация URL репозитория, только если поле заполнено
|
||||||
|
if (dialogData.value.repository_url && !/^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/.test(dialogData.value.repository_url)) {
|
||||||
|
Notify.create({type: 'negative', message: 'Некорректный формат URL репозитория.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogData.value.id) {
|
||||||
|
await updateProject(dialogData.value);
|
||||||
|
const idx = projects.value.findIndex(p => p.id === dialogData.value.id);
|
||||||
|
if (idx !== -1) projects.value[idx] = JSON.parse(JSON.stringify(dialogData.value));
|
||||||
|
} else {
|
||||||
|
const newProject = await createProject(dialogData.value);
|
||||||
|
projects.value.push(newProject);
|
||||||
|
}
|
||||||
|
closeDialog();
|
||||||
} else if (dialogType.value === 'profiles') {
|
} else if (dialogType.value === 'profiles') {
|
||||||
if (dialogData.value.id) {
|
if (!dialogData.value.first_name || dialogData.value.first_name.trim() === '') {
|
||||||
await updateProfile(dialogData.value)
|
Notify.create({type: 'negative', message: 'Имя пользователя обязательно.', icon: 'error'});
|
||||||
const idx = profiles.value.findIndex(p => p.id === dialogData.value.id)
|
return;
|
||||||
if (idx !== -1) profiles.value[idx] = JSON.parse(JSON.stringify(dialogData.value))
|
|
||||||
} else {
|
|
||||||
const newProfile = await createProfile(dialogData.value)
|
|
||||||
profiles.value.push(newProfile)
|
|
||||||
}
|
}
|
||||||
|
if (!dialogData.value.last_name || dialogData.value.last_name.trim() === '') {
|
||||||
|
Notify.create({type: 'negative', message: 'Фамилия пользователя обязательна.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dialogData.value.birthday) {
|
||||||
|
Notify.create({type: 'negative', message: 'День рождения пользователя обязателен.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Валидация email, только если поле заполнено
|
||||||
|
if (dialogData.value.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(dialogData.value.email)) {
|
||||||
|
Notify.create({type: 'negative', message: 'Некорректный формат электронной почты.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Валидация номера телефона, только если поле заполнено (дополнительно, если хотите проверять формат помимо маски)
|
||||||
|
// Маска +7 (###) ###-##-## уже должна обеспечивать базовую валидацию
|
||||||
|
if (dialogData.value.phone && !/^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$/.test(dialogData.value.phone)) {
|
||||||
|
Notify.create({type: 'negative', message: 'Некорректный формат номера телефона.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Валидация URL репозитория: поле необязательное, но если оно заполнено, то должно быть корректным URL.
|
||||||
|
// Если оно пустое, эта проверка пропускается, и значение null будет отправлено.
|
||||||
|
if (dialogData.value.repository_url && dialogData.value.repository_url.trim() !== '' && !/^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/.test(dialogData.value.repository_url)) {
|
||||||
|
Notify.create({type: 'negative', message: 'Некорректный формат URL репозитория.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
} else if (dialogData.value.repository_url && dialogData.value.repository_url.trim() === '') {
|
||||||
|
// Если поле заполнено, но содержит только пробелы, делаем его null
|
||||||
|
dialogData.value.repository_url = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (dialogData.value.id) {
|
||||||
|
// Вызов функции для обновления профиля. Убедитесь, что updateProfile принимает repository_url
|
||||||
|
await updateProfile(dialogData.value);
|
||||||
|
const idx = profiles.value.findIndex(p => p.id === dialogData.value.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
profiles.value[idx] = JSON.parse(JSON.stringify(dialogData.value));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Вызов функции для создания профиля. Убедитесь, что createProfile принимает repository_url
|
||||||
|
const newProfile = await createProfile(dialogData.value);
|
||||||
|
profiles.value.push(newProfile);
|
||||||
|
}
|
||||||
|
closeDialog();
|
||||||
} else if (dialogType.value === 'contests') {
|
} else if (dialogType.value === 'contests') {
|
||||||
if (dialogData.value.id) {
|
if (!dialogData.value.title || dialogData.value.title.trim() === '') {
|
||||||
await updateContest(dialogData.value)
|
Notify.create({type: 'negative', message: 'Название конкурса обязательно.', icon: 'error'});
|
||||||
const idx = contests.value.findIndex(c => c.id === dialogData.value.id)
|
return;
|
||||||
if (idx !== -1) contests.value[idx] = JSON.parse(JSON.stringify(dialogData.value))
|
|
||||||
} else {
|
|
||||||
const newContest = await createContest(dialogData.value)
|
|
||||||
contests.value.push(newContest)
|
|
||||||
}
|
}
|
||||||
|
// Валидация URL сайта, только если поле заполнено (обязательное поле, но проверка формата все равно нужна)
|
||||||
|
if (dialogData.value.web_url && !/^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/.test(dialogData.value.web_url)) {
|
||||||
|
Notify.create({type: 'negative', message: 'URL сайта конкурса должен быть корректной ссылкой.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dialogData.value.project_id) {
|
||||||
|
Notify.create({type: 'negative', message: 'Проект конкурса обязателен.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dialogData.value.status_id) {
|
||||||
|
Notify.create({type: 'negative', message: 'Статус конкурса обязателен.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogData.value.id) {
|
||||||
|
await updateContest(dialogData.value);
|
||||||
|
const idx = contests.value.findIndex(c => c.id === dialogData.value.id);
|
||||||
|
if (idx !== -1) contests.value[idx] = JSON.parse(JSON.stringify(dialogData.value));
|
||||||
|
} else {
|
||||||
|
const newContest = await createContest(dialogData.value);
|
||||||
|
contests.value.push(newContest);
|
||||||
|
}
|
||||||
|
closeDialog();
|
||||||
} else if (dialogType.value === 'project_members') {
|
} else if (dialogType.value === 'project_members') {
|
||||||
const memberData = {
|
const memberData = {
|
||||||
id: dialogData.value.id,
|
id: dialogData.value.id,
|
||||||
@ -1467,13 +1652,19 @@ async function saveChanges() {
|
|||||||
profile_id: parseInt(dialogData.value.profile_id)
|
profile_id: parseInt(dialogData.value.profile_id)
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDuplicate = projectMembers.value.some(pm => {
|
if (!memberData.project_id) {
|
||||||
if (pm.id === memberData.id) {
|
Notify.create({type: 'negative', message: 'Проект для участника проекта обязателен.', icon: 'error'});
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
return pm.project_id === memberData.project_id && pm.profile_id === memberData.profile_id;
|
if (!memberData.profile_id) {
|
||||||
|
Notify.create({type: 'negative', message: 'Профиль участника проекта обязателен.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isDuplicate = projectMembers.value.some(pm => {
|
||||||
|
return pm.id !== memberData.id &&
|
||||||
|
pm.project_id === memberData.project_id &&
|
||||||
|
pm.profile_id === memberData.profile_id;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isDuplicate) {
|
if (isDuplicate) {
|
||||||
Notify.create({
|
Notify.create({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
@ -1483,79 +1674,105 @@ async function saveChanges() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (dialogData.value.id) {
|
if (dialogData.value.id) {
|
||||||
if (!memberData.project_id) {
|
const originalMemberInList = projectMembers.value.find(pm => pm.id === dialogData.value.id);
|
||||||
Notify.create({ type: 'negative', message: 'Невозможно обновить: ID проекта для участника отсутствует.', icon: 'error' });
|
const originalProjectId = originalMemberInList ? originalMemberInList.project_id : null;
|
||||||
return;
|
if (originalProjectId !== null && memberData.project_id !== originalProjectId) {
|
||||||
}
|
const oldProjectMembers = await getProjectMemberByProject(originalProjectId) || [];
|
||||||
|
const updatedOldProjectMembers = oldProjectMembers.filter(pm => pm.id !== memberData.id);
|
||||||
|
await updateProjectMember(originalProjectId, updatedOldProjectMembers);
|
||||||
|
const newMemberPayload = {
|
||||||
|
description: memberData.description,
|
||||||
|
project_id: memberData.project_id,
|
||||||
|
profile_id: memberData.profile_id // Старый profile_id переносится
|
||||||
|
};
|
||||||
|
const newProjectMemberResponse = await createProjectMember(newMemberPayload);
|
||||||
|
await loadData('project_members');
|
||||||
|
} else {
|
||||||
const currentMembersForProject = await getProjectMemberByProject(memberData.project_id) || [];
|
const currentMembersForProject = await getProjectMemberByProject(memberData.project_id) || [];
|
||||||
const updatedMembersList = currentMembersForProject.map(member => {
|
const updatedMembersList = currentMembersForProject.map(member => {
|
||||||
if (member.id === memberData.id) {
|
if (member.id === memberData.id) {
|
||||||
|
// Если ID совпадает, обновляем данные этой записи
|
||||||
return {...member, ...memberData};
|
return {...member, ...memberData};
|
||||||
}
|
}
|
||||||
return member;
|
return member;
|
||||||
});
|
});
|
||||||
await updateProjectMember(memberData.project_id, updatedMembersList);
|
await updateProjectMember(memberData.project_id, updatedMembersList);
|
||||||
await loadData('project_members');
|
const idx = projectMembers.value.findIndex(pm => pm.id === memberData.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
projectMembers.value[idx] = JSON.parse(JSON.stringify(memberData));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const newMemberPayload = {
|
const newMemberPayload = {
|
||||||
description: memberData.description,
|
description: memberData.description,
|
||||||
project_id: memberData.project_id,
|
project_id: memberData.project_id,
|
||||||
profile_id: memberData.profile_id
|
profile_id: memberData.profile_id
|
||||||
};
|
};
|
||||||
|
|
||||||
const newProjectMemberResponse = await createProjectMember(newMemberPayload);
|
const newProjectMemberResponse = await createProjectMember(newMemberPayload);
|
||||||
projectMembers.value.push(newProjectMemberResponse[0] || newProjectMemberResponse);
|
projectMembers.value.push(newProjectMemberResponse[0] || newProjectMemberResponse);
|
||||||
}
|
}
|
||||||
|
closeDialog();
|
||||||
}
|
}
|
||||||
closeDialog()
|
|
||||||
Notify.create({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Изменения успешно сохранены!',
|
|
||||||
icon: 'check_circle',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при сохранении:', error.message)
|
console.error('Ошибка сохранения изменений:', error);
|
||||||
Notify.create({
|
Notify.create({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: `Ошибка при сохранении: ${error.message}`,
|
message: `Ошибка сохранения: ${error.message || 'Неизвестная ошибка'}`,
|
||||||
icon: 'error',
|
icon: 'error'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTeamIsActiveToggle(newValue) {
|
|
||||||
// Проверяем, что это команда, она существует (есть ID) и флажок активен
|
|
||||||
if (dialogType.value === 'teams' && dialogData.value.id && newValue) {
|
|
||||||
try {
|
|
||||||
await setActiveTeam(dialogData.value.id);
|
|
||||||
Notify.create({
|
|
||||||
type: 'positive',
|
|
||||||
message: `Команда "${dialogData.value.title}" успешно установлена как активная.`,
|
|
||||||
});
|
|
||||||
await loadData('teams'); // Вызываем loadData для обновления списка команд
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Ошибка при установке активной команды:", error);
|
|
||||||
Notify.create({
|
|
||||||
type: 'negative',
|
|
||||||
message: `Ошибка при установке активной команды: ${error.message}`,
|
|
||||||
});
|
|
||||||
dialogData.value.is_active = !newValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveNewUser() {
|
async function saveNewUser() {
|
||||||
// Валидация входных данных
|
const {first_name, last_name, birthday, email, phone, repository_url, role_id, team_id, login, password} = newUserData.value;
|
||||||
if (!newUserData.value.first_name || !newUserData.value.last_name || !newUserData.value.email || !newUserData.value.login || !newUserData.value.password || !newUserData.value.role_id) {
|
|
||||||
$q.notify({
|
// --- Валидация обязательных полей ---
|
||||||
type: 'negative',
|
if (!first_name || first_name.trim() === '') {
|
||||||
message: 'Пожалуйста, заполните все обязательные поля (Имя, Фамилия, Почта, Логин, Пароль, Роль).',
|
$q.notify({type: 'negative', message: 'Имя пользователя обязательно.', icon: 'error'});
|
||||||
icon: 'error',
|
return;
|
||||||
});
|
}
|
||||||
|
if (!last_name || last_name.trim() === '') {
|
||||||
|
$q.notify({type: 'negative', message: 'Фамилия пользователя обязательна.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!birthday) { // День рождения теперь обязателен
|
||||||
|
$q.notify({type: 'negative', message: 'День рождения пользователя обязателен.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!login || login.trim() === '') {
|
||||||
|
$q.notify({type: 'negative', message: 'Логин пользователя обязателен.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password) { // Пароль теперь обязателен
|
||||||
|
$q.notify({type: 'negative', message: 'Пароль пользователя обязателен.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!role_id) { // Роль теперь обязательна
|
||||||
|
$q.notify({type: 'negative', message: 'Роль пользователя обязательна.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!team_id) {
|
||||||
|
$q.notify({type: 'negative', message: 'Команда пользователя обязательна.', icon: 'error'});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Валидация формата полей (если заполнено) ---
|
||||||
|
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
$q.notify({type: 'negative', message: 'Некорректный формат электронной почты.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Валидация номера телефона, только если поле заполнено (дополнительно, если хотите проверять формат помимо маски)
|
||||||
|
if (phone && !/^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$/.test(phone)) {
|
||||||
|
$q.notify({type: 'negative', message: 'Некорректный формат номера телефона.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (repository_url && !/^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/.test(dialogData.value.repository_url)) {
|
||||||
|
$q.notify({type: 'negative', message: 'Некорректный формат URL репозитория.', icon: 'error'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Валидация пароля и его подтверждения ---
|
||||||
const validationError = validatePassword(newUserData.value.password);
|
const validationError = validatePassword(newUserData.value.password);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
@ -1575,6 +1792,7 @@ async function saveNewUser() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Отправка данных ---
|
||||||
try {
|
try {
|
||||||
const dataToSend = {...newUserData.value};
|
const dataToSend = {...newUserData.value};
|
||||||
if (dataToSend.birthday) {
|
if (dataToSend.birthday) {
|
||||||
@ -1595,6 +1813,21 @@ async function saveNewUser() {
|
|||||||
icon: 'check_circle',
|
icon: 'check_circle',
|
||||||
});
|
});
|
||||||
createUserDialogVisible.value = false;
|
createUserDialogVisible.value = false;
|
||||||
|
// Сброс полей формы после успешного создания
|
||||||
|
newUserData.value = {
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
patronymic: '',
|
||||||
|
birthday: null,
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
repository_url: '',
|
||||||
|
role_id: null,
|
||||||
|
team_id: null,
|
||||||
|
login: '',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
confirmNewUserPassword.value = '';
|
||||||
await loadData('profiles'); // Обновляем список пользователей
|
await loadData('profiles'); // Обновляем список пользователей
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при создании пользователя:', error.message);
|
console.error('Ошибка при создании пользователя:', error.message);
|
||||||
@ -1689,39 +1922,64 @@ async function saveNewPassword() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadData(name) {
|
async function loadData(name) {
|
||||||
if (name === 'teams') {
|
if (allProjects.value.length === 0 && (name === 'contests' || name === 'project_members' || name === 'profiles')) {
|
||||||
loadingTeams.value = true
|
|
||||||
try {
|
try {
|
||||||
teams.value = await fetchTeams() || []
|
allProjects.value = await fetchProjects() || [];
|
||||||
|
} catch (error) {
|
||||||
|
Notify.create({type: 'negative', message: `Ошибка загрузки всех проектов: ${error.message}`, icon: 'error'});
|
||||||
|
allProjects.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allProfiles.value.length === 0 && (name === 'profiles' || name === 'project_members')) {
|
||||||
|
try {
|
||||||
|
allProfiles.value = await fetchProfiles() || [];
|
||||||
|
} catch (error) {
|
||||||
|
Notify.create({type: 'negative', message: `Ошибка загрузки всех профилей: ${error.message}`, icon: 'error'});
|
||||||
|
allProfiles.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (teams.value.length === 0 && (name === 'profiles' || name === 'teams')) {
|
||||||
|
try {
|
||||||
|
teams.value = await fetchTeams() || [];
|
||||||
|
} catch (error) {
|
||||||
|
Notify.create({type: 'negative', message: `Ошибка загрузки всех команд: ${error.message}`, icon: 'error'});
|
||||||
|
teams.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'teams') {
|
||||||
|
loadingTeams.value = true;
|
||||||
|
try {
|
||||||
|
teams.value = await fetchTeams() || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
teams.value = []
|
|
||||||
Notify.create({
|
Notify.create({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: `Ошибка загрузки команд: ${error.message}`,
|
message: `Ошибка загрузки команд: ${error.message}`,
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
});
|
});
|
||||||
|
teams.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
loadingTeams.value = false
|
loadingTeams.value = false;
|
||||||
}
|
}
|
||||||
} else if (name === 'projects') {
|
} else if (name === 'projects') {
|
||||||
loadingProjects.value = true
|
loadingProjects.value = true;
|
||||||
try {
|
try {
|
||||||
projects.value = await fetchProjects() || []
|
projects.value = await fetchProjects() || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
projects.value = []
|
projects.value = [];
|
||||||
Notify.create({
|
Notify.create({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: `Ошибка загрузки проектов: ${error.message}`,
|
message: `Ошибка загрузки проектов: ${error.message}`,
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
loadingProjects.value = false
|
loadingProjects.value = false;
|
||||||
}
|
}
|
||||||
} else if (name === 'profiles') {
|
}
|
||||||
|
else if (name === 'profiles') {
|
||||||
loadingProfiles.value = true
|
loadingProfiles.value = true
|
||||||
try {
|
try {
|
||||||
profiles.value = await fetchProfiles() || []
|
profiles.value = await fetchProfiles() || []
|
||||||
allProfiles.value = await fetchProfiles() || []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
profiles.value = []
|
profiles.value = []
|
||||||
Notify.create({
|
Notify.create({
|
||||||
@ -1750,11 +2008,6 @@ async function loadData(name) {
|
|||||||
loadingProjectMembers.value = true;
|
loadingProjectMembers.value = true;
|
||||||
try {
|
try {
|
||||||
projectMembers.value = await getAllProjectMembers() || [];
|
projectMembers.value = await getAllProjectMembers() || [];
|
||||||
|
|
||||||
// Эти запросы нужны для корректного отображения названий в таблице и q-select
|
|
||||||
allProjects.value = await fetchProjects() || [];
|
|
||||||
allProfiles.value = await fetchProfiles() || [];
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
projectMembers.value = [];
|
projectMembers.value = [];
|
||||||
allProjects.value = [];
|
allProjects.value = [];
|
||||||
@ -1833,6 +2086,7 @@ async function createNewUserHandler() {
|
|||||||
birthday: null,
|
birthday: null,
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
|
repository_url: '',
|
||||||
role_id: null,
|
role_id: null,
|
||||||
team_id: null,
|
team_id: null,
|
||||||
login: '',
|
login: '',
|
||||||
@ -1862,17 +2116,38 @@ function createHandler() {
|
|||||||
openEdit(tab.value, null)
|
openEdit(tab.value, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTeamIsActiveToggle(newValue) {
|
||||||
|
// Проверяем, что это команда, она существует (есть ID) и флажок активен
|
||||||
|
if (dialogType.value === 'teams' && dialogData.value.id && newValue) {
|
||||||
|
try {
|
||||||
|
await setActiveTeam(dialogData.value.id);
|
||||||
|
Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: `Команда "${dialogData.value.title}" успешно установлена как активная.`,
|
||||||
|
});
|
||||||
|
await loadData('teams'); // Вызываем loadData для обновления списка команд
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка при установке активной команды:", error);
|
||||||
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: `Ошибка при установке активной команды: ${error.message}`,
|
||||||
|
});
|
||||||
|
dialogData.value.is_active = !newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData(tab.value)
|
loadData(tab.value);
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
loadData(tab.value)
|
loadData(tab.value);
|
||||||
}, 5000)
|
}, 5000);
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearInterval(interval)
|
clearInterval(interval);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
watch(tab, (newTab) => {
|
watch(tab, (newTab) => {
|
||||||
loadData(newTab)
|
loadData(newTab)
|
||||||
@ -1894,6 +2169,10 @@ const handleAuthAction = () => {
|
|||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToHomePage = () => {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -15,12 +15,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="contest" class="q-gutter-y-xl">
|
<div v-else-if="contest" class="q-gutter-y-xl">
|
||||||
<div class="flex justify-center q-mb-md">
|
|
||||||
<q-avatar size="140px" class="contest-logo shadow-12">
|
|
||||||
<img :src="contest.photo || 'https://cdn.quasar.dev/logo-v2/svg/logo.svg'" alt="Логотип конкурса"/>
|
|
||||||
</q-avatar>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-center q-mb-xl">
|
<div class="flex justify-center q-mb-xl">
|
||||||
<q-card class="contest-name-card">
|
<q-card class="contest-name-card">
|
||||||
<q-card-section class="text-h4 text-center text-indigo-10 q-pa-md">
|
<q-card-section class="text-h4 text-center text-indigo-10 q-pa-md">
|
||||||
@ -53,7 +47,7 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="contest.carousel_photos && contest.carousel_photos.length > 0" class="flex justify-center q-mb-xl">
|
<div v-if="contestCarouselPhotos && contestCarouselPhotos.length > 0" class="flex justify-center q-mb-xl">
|
||||||
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
|
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-h6 text-indigo-10 q-mb-md">Галерея</div>
|
<div class="text-h6 text-indigo-10 q-mb-md">Галерея</div>
|
||||||
@ -71,24 +65,31 @@
|
|||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
height="300px"
|
height="300px"
|
||||||
>
|
>
|
||||||
<q-carousel-slide v-for="(photo, index) in contest.carousel_photos" :key="index" :name="index + 1" :img-src="photo.url" />
|
<q-carousel-slide v-for="(photo, index) in contestCarouselPhotos" :key="index" :name="index + 1" :img-src="photo.url" />
|
||||||
</q-carousel>
|
</q-carousel>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="contest && !loading" class="flex justify-center q-mb-xl">
|
||||||
|
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
|
||||||
|
<q-card-section class="text-h6 text-indigo-10 text-center">
|
||||||
|
Для этого конкурса пока нет фотографий в галерее.
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="contest.files && contest.files.length > 0" class="flex justify-center q-mb-xl">
|
<div v-if="contestFiles && contestFiles.length > 0" class="flex justify-center q-mb-xl">
|
||||||
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
|
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-h6 text-indigo-10 q-mb-md">Файлы конкурса</div>
|
<div class="text-h6 text-indigo-10 q-mb-md">Файлы конкурса</div>
|
||||||
<q-list separator bordered class="rounded-borders">
|
<q-list separator bordered class="rounded-borders">
|
||||||
<q-item v-for="file in contest.files" :key="file.id" clickable v-ripple :href="file.url" target="_blank">
|
<q-item v-for="file in contestFiles" :key="file.id" clickable v-ripple @click="handleDownloadContestFile(file.id)">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-icon name="folder_open" color="indigo-8" />
|
<q-icon name="folder_open" color="indigo-8" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>{{ file.name }}</q-item-label>
|
<q-item-label>{{ file.name || `Файл ${file.id}` }}</q-item-label>
|
||||||
<q-item-label caption>{{ file.description }}</q-item-label>
|
<q-item-label caption>{{ file.description || 'Нет описания' }}</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-icon name="download" color="indigo-6" />
|
<q-icon name="download" color="indigo-6" />
|
||||||
@ -98,42 +99,81 @@
|
|||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="contest && !loading" class="flex justify-center q-mb-xl">
|
||||||
|
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
|
||||||
|
<q-card-section class="text-h6 text-indigo-10 text-center">
|
||||||
|
Для этого конкурса пока нет файлов.
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="contestParticipants.length > 0" class="flex justify-center flex-wrap q-gutter-md q-mb-xl">
|
<div v-if="contestParticipants.length > 0" class="flex justify-center q-mb-md" style="width: 100%;">
|
||||||
<div class="flex justify-center q-mb-md" style="width: 100%;"></div>
|
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6 text-indigo-10 q-mb-md text-center">Участники конкурса</div>
|
||||||
|
<div class="flex justify-center flex-wrap q-gutter-md">
|
||||||
|
<q-card
|
||||||
|
v-for="member in contestParticipants"
|
||||||
|
:key="member.id"
|
||||||
|
class="member-card violet-card"
|
||||||
|
@click="router.push({ name: 'profile-detail', params: { id: member.id } })"
|
||||||
|
>
|
||||||
|
<q-card-section class="q-pa-md flex flex-center column">
|
||||||
|
<q-avatar v-if="member.avatar" size="70px" class="contest-logo shadow-6">
|
||||||
|
<img :src="member.avatar" :alt="member.name"/>
|
||||||
|
</q-avatar>
|
||||||
|
<q-avatar v-else size="70px" class="contest-logo shadow-6 bg-indigo-2 text-indigo-9">
|
||||||
|
{{ member.name.charAt(0) }}
|
||||||
|
</q-avatar>
|
||||||
|
<div class="text-subtitle1 text-center text-indigo-10 q-mt-sm">{{ member.name }}</div>
|
||||||
|
<div v-if="member.role" class="text-caption text-center text-indigo-8">
|
||||||
|
{{ member.role }}
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="contest && !loading" class="flex justify-center q-mb-xl">
|
||||||
|
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
|
||||||
|
<q-card-section class="text-h6 text-indigo-10 text-center">
|
||||||
|
У этого конкурса пока нет участников.
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-separator class="q-my-lg" color="indigo-4" style="width: 80%; margin: 0 auto;"/>
|
<q-separator class="q-my-lg" color="indigo-4" style="width: 80%; margin: 0 auto;"/>
|
||||||
<div class="q-mt-md"></div>
|
<div class="q-mt-md"></div>
|
||||||
|
|
||||||
<div v-if="contest.repository_url || contest.project_description || (contest.project_files && contest.project_files.length > 0)" class="flex justify-center q-mb-xl">
|
<div v-if="projectDetails.id" class="flex justify-center q-mb-xl">
|
||||||
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
|
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
|
||||||
<q-card-section class="q-pa-md">
|
<q-card-section class="q-pa-md">
|
||||||
<div class="text-h6 text-indigo-10 q-mb-md">Информация о проекте</div>
|
<div class="text-h6 text-indigo-10 q-mb-md">Информация о проекте</div>
|
||||||
|
|
||||||
<div v-if="contest.project_description" class="q-mb-md text-indigo-9">
|
<div v-if="projectDetails.description" class="q-mb-md text-indigo-9">
|
||||||
<div class="text-subtitle1 text-indigo-10 q-mb-sm">Описание проекта</div>
|
<div class="text-subtitle1 text-indigo-10 q-mb-sm">Описание проекта</div>
|
||||||
<div class="text-body1">{{ contest.project_description }}</div>
|
<div class="text-body1">{{ projectDetails.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="contest.repository_url" class="q-mb-md text-indigo-9">
|
<div v-if="projectDetails.repository_url" class="q-mb-md text-indigo-9">
|
||||||
<div class="text-subtitle1 text-indigo-10 q-mb-sm">Репозиторий проекта</div>
|
<div class="text-subtitle1 text-indigo-10 q-mb-sm">Репозиторий проекта</div>
|
||||||
<q-icon name="code" size="sm" class="q-mr-xs" />
|
<q-icon name="code" size="sm" class="q-mr-xs" />
|
||||||
<a :href="contest.repository_url" target="_blank" class="text-indigo-9" style="text-decoration: none; word-break: break-all;">
|
<a :href="projectDetails.repository_url" target="_blank" class="text-indigo-9" style="text-decoration: none; word-break: break-all;">
|
||||||
{{ contest.repository_url }}
|
{{ projectDetails.repository_url }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="contest.project_files && contest.project_files.length > 0" class="q-mt-md">
|
<div v-if="projectFiles && projectFiles.length > 0" class="q-mt-md">
|
||||||
<div class="text-subtitle1 text-indigo-10 q-mb-sm">Файлы проекта</div>
|
<div class="text-subtitle1 text-indigo-10 q-mb-sm">Файлы проекта</div>
|
||||||
<q-list separator bordered class="rounded-borders">
|
<q-list separator bordered class="rounded-borders">
|
||||||
<q-item v-for="file in contest.project_files" :key="file.id" clickable v-ripple :href="file.url" target="_blank">
|
<q-item v-for="file in projectFiles" :key="file.id" clickable v-ripple @click="handleDownloadProjectFile(file.id, file.name, file.file_format)">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-icon name="folder_open" color="indigo-8" />
|
<q-icon name="folder_open" color="indigo-8" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>{{ file.name }}</q-item-label>
|
<q-item-label>{{ file.name || `Файл ${file.id}` }}</q-item-label>
|
||||||
<q-item-label caption>{{ file.description }}</q-item-label>
|
<q-item-label caption>{{ file.description || 'Нет описания' }}</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-icon name="download" color="indigo-6" />
|
<q-icon name="download" color="indigo-6" />
|
||||||
@ -141,10 +181,20 @@
|
|||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="projectDetails.id && !projectDetails.description && !projectDetails.repository_url" class="text-body1 text-indigo-9 text-center">
|
||||||
|
Для этого проекта не указана информация и нет файлов.
|
||||||
|
</div>
|
||||||
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="contest && !loading && !projectDetails.id" class="flex justify-center q-mb-xl">
|
||||||
|
<q-card class="violet-card" style="max-width: 940px; width: 100%;">
|
||||||
|
<q-card-section class="text-h6 text-indigo-10 text-center">
|
||||||
|
Информация о проекте отсутствует.
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -156,11 +206,33 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { Ripple, Notify } from 'quasar';
|
import { Ripple, Notify } from 'quasar';
|
||||||
import axios from 'axios';
|
|
||||||
import CONFIG from "@/core/config.js";
|
// --- Импорт API функций ---
|
||||||
|
import fetchContests from '@/api/contests/getContests.js';
|
||||||
|
|
||||||
|
// Для галереи конкурса
|
||||||
|
import getContestCarouselPhotosByContestId from '@/api/contests/contest_carousel_photos/getContestPhotoFileById.js';
|
||||||
|
import downloadContestCarouselPhotoFile from '@/api/contests/contest_carousel_photos/downloadContestPhotoFile.js'; // Убедитесь, что этот файл есть
|
||||||
|
|
||||||
|
// Для файлов конкурса
|
||||||
|
import getContestFilesByContestId from '@/api/contests/contest_files/getContestFiles.js';
|
||||||
|
import downloadContestFile from '@/api/contests/contest_files/downloadContestFile.js';
|
||||||
|
|
||||||
|
// Для связанного проекта
|
||||||
|
import getProjects from '@/api/projects/getProjects.js';
|
||||||
|
import getProjectFilesByProjectId from '@/api/projects/project_files/getProjectFiles.js'; // Ваш существующий API для файлов проекта
|
||||||
|
import downloadProjectFile from '@/api/projects/project_files/downloadProjectFile.js'; // API для скачивания файлов проекта
|
||||||
|
|
||||||
|
// Для участников проекта
|
||||||
|
import getProjectMembersByProject from '@/api/project_members/getProjectMemberByProject.js';
|
||||||
|
import fetchProfiles from '@/api/profiles/getProfiles.js';
|
||||||
|
import getPhotoFileById from '@/api/profiles/profile_photos/getPhotoFileById.js';
|
||||||
|
import downloadPhotoFile from '@/api/profiles/profile_photos/downloadPhotoFile.js';
|
||||||
|
|
||||||
|
import CONFIG from '@/core/config.js'; // Убедитесь, что импортирован CONFIG
|
||||||
|
|
||||||
// Директивы
|
// Директивы
|
||||||
defineExpose({ directives: { ripple: Ripple } });
|
defineExpose({ directives: { ripple: Ripple } });
|
||||||
@ -175,97 +247,182 @@ const contestId = computed(() => route.params.id);
|
|||||||
|
|
||||||
// --- Карусель фото ---
|
// --- Карусель фото ---
|
||||||
const slide = ref(1);
|
const slide = ref(1);
|
||||||
|
const contestCarouselPhotos = ref([]); // Содержит объекты { id, url }
|
||||||
|
|
||||||
// --- Участники конкурса (моковые данные) ---
|
// --- Файлы конкурса ---
|
||||||
const contestParticipants = ref([
|
const contestFiles = ref([]); // Содержит список объектов файлов конкурса
|
||||||
{ id: 1, name: 'Иван Иванов', role: 'Team Lead', avatar: 'https://randomuser.me/api/portraits/men/32.jpg' },
|
|
||||||
{ id: 2, name: 'Мария Петрова', role: 'Frontend', avatar: 'https://randomuser.me/api/portraits/women/44.jpg' },
|
// --- Участники конкурса ---
|
||||||
{ id: 3, name: 'Алексей Смирнов', role: 'Backend', avatar: 'https://randomuser.me/api/portraits/men/65.jpg' },
|
const contestParticipants = ref([]);
|
||||||
// Добавьте больше участников или динамически загружайте их
|
|
||||||
]);
|
// --- Информация о связанном проекте ---
|
||||||
|
const projectDetails = ref({}); // Будет хранить { id, description, repository_url, ... }
|
||||||
|
const projectFiles = ref([]); // Будет хранить список файлов проекта
|
||||||
|
|
||||||
// --- Загрузка данных конкурса ---
|
// --- Загрузка данных конкурса ---
|
||||||
async function fetchContestDetails(id) {
|
async function fetchContestDetails(id) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
contest.value = null;
|
||||||
// В реальном приложении здесь будет API-запрос:
|
|
||||||
// const response = await axios.get(`${CONFIG.BASE_URL}/contests/${id}`);
|
|
||||||
// contest.value = response.data;
|
|
||||||
|
|
||||||
// Моковые данные для примера (замените на реальный fetch)
|
// Очистка предыдущих данных и URL-ов Blob
|
||||||
const mockContests = [
|
contestCarouselPhotos.value.forEach(photo => {
|
||||||
{
|
if (photo.url && photo.url.startsWith('blob:')) {
|
||||||
id: 1,
|
URL.revokeObjectURL(photo.url);
|
||||||
title: 'Hackathon 2024',
|
}
|
||||||
description: 'Ежегодный хакатон для стартапов, где команды соревнуются в создании инновационных решений за короткий период времени. Фокус на Web3 и AI технологиях.',
|
|
||||||
web_url: 'https://example.com/hackathon2024',
|
|
||||||
repository_url: 'https://github.com/my-team/hackathon2024-solution',
|
|
||||||
project_description: 'Проект представляет собой децентрализованное приложение для управления задачами, использующее блокчейн для обеспечения прозрачности и искусственный интеллект для автоматического распределения задач между участниками команды.',
|
|
||||||
project_files: [
|
|
||||||
{ id: 101, name: 'Техническое задание.pdf', description: 'Полное описание требований', url: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' },
|
|
||||||
{ id: 102, name: 'Архитектура системы.pptx', description: 'Схема взаимодействия модулей', url: 'https://file-examples.com/wp-content/uploads/2017/02/file-example-PPT_10MB.ppt' },
|
|
||||||
],
|
|
||||||
photo: 'https://cdn.quasar.dev/img/parallax2.jpg',
|
|
||||||
results: '1 место в категории "Лучшее AI-решение"',
|
|
||||||
is_win: true,
|
|
||||||
carousel_photos: [
|
|
||||||
{ id: 1, url: 'https://images.unsplash.com/photo-1668796319088-214d6a82d54b?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
|
|
||||||
{ id: 2, url: 'https://cdn.quasar.dev/img/quasar.jpg' },
|
|
||||||
{ id: 3, url: 'https://cdn.quasar.dev/img/parallax1.jpg' },
|
|
||||||
{ id: 4, url: 'https://cdn.quasar.dev/img/donuts.png' },
|
|
||||||
{ id: 5, url: 'https://cdn.quasar.dev/img/parallax2.jpg' },
|
|
||||||
],
|
|
||||||
files: [
|
|
||||||
{ id: 1, name: 'Презентация конкурса.pdf', description: 'Финальная презентация', url: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' },
|
|
||||||
{ id: 2, name: 'Код проекта.zip', description: 'Исходный код', url: 'https://www.learningcontainer.com/wp-content/uploads/2020/07/Example-Zip-File.zip' },
|
|
||||||
],
|
|
||||||
project_id: 1,
|
|
||||||
status_id: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'CodeFest',
|
|
||||||
description: 'Масштабное соревнование по спортивному программированию, где участники решают алгоритмические задачи. Отличная возможность проверить свои навыки.',
|
|
||||||
web_url: 'https://codefest.org',
|
|
||||||
repository_url: 'https://gitlab.com/awesome-devs/codefest-challenge',
|
|
||||||
project_description: null, // No project description for this one
|
|
||||||
project_files: [], // No project files for this one
|
|
||||||
photo: 'https://cdn.quasar.dev/img/material.png',
|
|
||||||
results: null,
|
|
||||||
is_win: false,
|
|
||||||
carousel_photos: [
|
|
||||||
{ id: 10, url: 'https://images.unsplash.com/photo-1584291378203-674a462de8bc?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
|
|
||||||
{ id: 11, url: 'https://cdn.quasar.dev/img/chicken-salad.jpg' },
|
|
||||||
],
|
|
||||||
files: [],
|
|
||||||
project_id: 2,
|
|
||||||
status_id: 2,
|
|
||||||
},
|
|
||||||
// Добавьте другие моковые конкурсы по мере необходимости
|
|
||||||
];
|
|
||||||
|
|
||||||
contest.value = mockContests.find(c => c.id === parseInt(id));
|
|
||||||
|
|
||||||
if (!contest.value) {
|
|
||||||
Notify.create({
|
|
||||||
type: 'negative',
|
|
||||||
message: 'Конкурс с таким ID не найден.',
|
|
||||||
icon: 'warning',
|
|
||||||
});
|
});
|
||||||
|
contestCarouselPhotos.value = [];
|
||||||
|
contestFiles.value = [];
|
||||||
|
contestParticipants.value = [];
|
||||||
|
projectDetails.value = {};
|
||||||
|
projectFiles.value = [];
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
Notify.create({ type: 'negative', message: 'ID конкурса не указан в маршруте.', icon: 'error' });
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Получаем базовую информацию о конкурсе
|
||||||
|
const allContests = await fetchContests();
|
||||||
|
const foundContest = allContests.find(c => String(c.id) === String(id));
|
||||||
|
|
||||||
|
if (foundContest) {
|
||||||
|
contest.value = foundContest;
|
||||||
|
|
||||||
|
// 2. Загружаем фотографии карусели
|
||||||
|
try {
|
||||||
|
const photosMeta = await getContestCarouselPhotosByContestId(id);
|
||||||
|
if (photosMeta && photosMeta.length > 0) {
|
||||||
|
contestCarouselPhotos.value = await Promise.all(
|
||||||
|
photosMeta.map(async photo => {
|
||||||
|
try {
|
||||||
|
const photoBlob = await downloadContestCarouselPhotoFile(photo.id);
|
||||||
|
if (photoBlob instanceof Blob) {
|
||||||
|
const url = URL.createObjectURL(photoBlob);
|
||||||
|
return { id: photo.id, url: url };
|
||||||
|
}
|
||||||
|
} catch (innerError) {
|
||||||
|
console.warn(`Не удалось загрузить BLOB для фото карусели ${photo.id}:`, innerError);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
).then(results => results.filter(p => p !== null));
|
||||||
|
}
|
||||||
|
} catch (photoError) {
|
||||||
|
console.warn(`Ошибка загрузки метаданных фотографий карусели для конкурса ${id}:`, photoError);
|
||||||
|
Notify.create({ type: 'warning', message: `Не удалось загрузить галерею: ${photoError.message}`, icon: 'warning' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Загружаем файлы конкурса
|
||||||
|
try {
|
||||||
|
const files = await getContestFilesByContestId(id);
|
||||||
|
contestFiles.value = files;
|
||||||
|
} catch (filesError) {
|
||||||
|
console.warn(`Ошибка загрузки файлов конкурса для конкурса ${id}:`, filesError);
|
||||||
|
Notify.create({ type: 'warning', message: `Не удалось загрузить файлы конкурса: ${filesError.message}`, icon: 'warning' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Загружаем информацию о связанном проекте и его участниках/файлах
|
||||||
|
if (contest.value.project_id) {
|
||||||
|
try {
|
||||||
|
const allProjects = await getProjects();
|
||||||
|
const projectData = allProjects.find(p => String(p.id) === String(contest.value.project_id));
|
||||||
|
|
||||||
|
if (projectData) {
|
||||||
|
projectDetails.value = projectData;
|
||||||
|
|
||||||
|
// Загружаем файлы проекта
|
||||||
|
try {
|
||||||
|
const pFiles = await getProjectFilesByProjectId(contest.value.project_id);
|
||||||
|
projectFiles.value = pFiles;
|
||||||
|
} catch (pFilesError) {
|
||||||
|
console.warn(`Ошибка загрузки файлов проекта для проекта ${contest.value.project_id}:`, pFilesError);
|
||||||
|
Notify.create({ type: 'warning', message: `Не удалось загрузить файлы проекта: ${pFilesError.message}`, icon: 'warning' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем участников проекта (которые и есть участники конкурса)
|
||||||
|
const projectMembersRaw = await getProjectMembersByProject(contest.value.project_id);
|
||||||
|
if (projectMembersRaw.length > 0) {
|
||||||
|
const allProfiles = await fetchProfiles();
|
||||||
|
const profilesMap = new Map(allProfiles.map(p => [p.id, p]));
|
||||||
|
|
||||||
|
contestParticipants.value = await Promise.all(
|
||||||
|
projectMembersRaw.map(async pm => {
|
||||||
|
const profile = profilesMap.get(pm.profile_id);
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
let avatarUrl = null;
|
||||||
|
try {
|
||||||
|
const photoData = await getPhotoFileById(profile.id);
|
||||||
|
if (photoData && photoData.length > 0) {
|
||||||
|
const firstPhotoId = photoData[0].id;
|
||||||
|
const photoBlob = await downloadPhotoFile(firstPhotoId);
|
||||||
|
if (photoBlob instanceof Blob) {
|
||||||
|
avatarUrl = URL.createObjectURL(photoBlob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (photoError) {
|
||||||
|
console.warn(`Ошибка загрузки фото профиля для участника ${profile.id}:`, photoError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Без имени',
|
||||||
|
role: pm.description || 'Участник проекта',
|
||||||
|
avatar: avatarUrl,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
contestParticipants.value = contestParticipants.value.filter(p => p !== null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Проект с ID ${contest.value.project_id} не найден.`);
|
||||||
|
Notify.create({ type: 'warning', message: `Связанный проект с ID ${contest.value.project_id} не найден.`, icon: 'warning' });
|
||||||
|
}
|
||||||
|
} catch (projectError) {
|
||||||
|
console.error(`Ошибка загрузки деталей проекта или участников для конкурса ${id} (project_id: ${contest.value.project_id}):`, projectError);
|
||||||
|
Notify.create({ type: 'negative', message: `Не удалось загрузить информацию о проекте или его участниках: ${projectError.message}`, icon: 'error' });
|
||||||
|
projectDetails.value = {};
|
||||||
|
projectFiles.value = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.info(`Конкурс ${id} не имеет связанного project_id. Информация о проекте и его участниках не будет загружена.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
Notify.create({ type: 'warning', message: 'Конкурс с таким ID не найден.', icon: 'warning' });
|
||||||
|
contest.value = null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки деталей конкурса:', error);
|
console.error('Ошибка загрузки деталей конкурса:', error);
|
||||||
Notify.create({
|
Notify.create({ type: 'negative', message: `Не удалось загрузить информацию о конкурсе: ${error.message}`, icon: 'error' });
|
||||||
type: 'negative',
|
contest.value = null;
|
||||||
message: 'Не удалось загрузить информацию о конкурсе.',
|
|
||||||
icon: 'error',
|
|
||||||
});
|
|
||||||
contest.value = null; // Сброс, если ошибка
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Функция для обработки скачивания файлов конкурса
|
||||||
|
async function handleDownloadContestFile(fileId) { // Удалены fileName и fileFormat, так как они не используются в downloadContestFile
|
||||||
|
try {
|
||||||
|
await downloadContestFile(fileId);
|
||||||
|
Notify.create({ type: 'positive', message: 'Файл конкурса успешно загружен!', icon: 'check_circle' });
|
||||||
|
} catch (error) {
|
||||||
|
Notify.create({ type: 'negative', message: `Ошибка при скачивании файла конкурса: ${error.message}`, icon: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для обработки скачивания файлов проекта
|
||||||
|
async function handleDownloadProjectFile(fileId, fileName, fileFormat) {
|
||||||
|
try {
|
||||||
|
await downloadProjectFile(fileId, fileName, fileFormat); // Если downloadProjectFile принимает эти параметры
|
||||||
|
Notify.create({ type: 'positive', message: 'Файл проекта успешно загружен!', icon: 'check_circle' });
|
||||||
|
} catch (error) {
|
||||||
|
Notify.create({ type: 'negative', message: `Ошибка при скачивании файла проекта: ${error.message}`, icon: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchContestDetails(contestId.value);
|
await fetchContestDetails(contestId.value);
|
||||||
});
|
});
|
||||||
@ -275,12 +432,33 @@ watch(contestId, async (newId) => {
|
|||||||
await fetchContestDetails(newId);
|
await fetchContestDetails(newId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Очистка URL-ов Blob при размонтировании компонента
|
||||||
|
onUnmounted(() => {
|
||||||
|
// Очистка URL-ов Blob для аватаров участников
|
||||||
|
contestParticipants.value.forEach(member => {
|
||||||
|
if (member.avatar && member.avatar.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(member.avatar);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Очистка URL-ов Blob для фотографий карусели конкурса
|
||||||
|
contestCarouselPhotos.value.forEach(photo => {
|
||||||
|
if (photo.url && photo.url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(photo.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.contest-detail-page {
|
.contest-detail-page {
|
||||||
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
|
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
color: #3e2465;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-bottom: 50px; /* Добавлен отступ снизу */
|
||||||
}
|
}
|
||||||
|
|
||||||
.contest-logo {
|
.contest-logo {
|
||||||
@ -300,10 +478,37 @@ watch(contestId, async (newId) => {
|
|||||||
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
|
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.description-card {
|
||||||
|
max-width: 940px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.member-card {
|
.member-card {
|
||||||
transition: transform 0.2s ease-in-out;
|
cursor: pointer;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 6px 20px rgba(124, 58, 237, 0.15), 0 2px 8px rgba(124, 58, 237, 0.1);
|
||||||
|
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||||
|
width: 180px; /* Фиксированная ширина */
|
||||||
|
min-height: 160px; /* Минимальная высота */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.member-card:hover {
|
.member-card:hover {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 12px 30px rgba(124, 58, 237, 0.25), 0 5px 15px rgba(124, 58, 237, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-gutter-md > .member-card {
|
||||||
|
margin-bottom: 16px; /* Для равномерного отступа, если нужно */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Добавлены/скорректированы стили для лучшего отображения */
|
||||||
|
.text-h6.text-indigo-10.text-center {
|
||||||
|
color: #4f046f;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@ -26,28 +26,76 @@
|
|||||||
@click="router.push({ name: 'profile-detail', params: { id: member.id } })"
|
@click="router.push({ name: 'profile-detail', params: { id: member.id } })"
|
||||||
>
|
>
|
||||||
<q-card-section class="q-pa-md flex flex-center">
|
<q-card-section class="q-pa-md flex flex-center">
|
||||||
<q-avatar size="64px" class="shadow-6">
|
<q-avatar v-if="member.avatar" size="80px" class="shadow-6 member-avatar-fix">
|
||||||
<img :src="member.avatar" :alt="member.name"/>
|
<img :src="member.avatar" :alt="member.name"/>
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
|
<q-avatar v-else size="80px" class="shadow-6 member-avatar-fix bg-indigo-2 text-indigo-9">
|
||||||
|
{{ member.name.charAt(0) }}
|
||||||
|
</q-avatar>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-pt-none">
|
<q-card-section class="q-pt-none">
|
||||||
<div class="text-subtitle1 text-center text-indigo-11">{{ member.name }}</div>
|
<div class="text-subtitle1 text-center text-indigo-11">{{ member.name }}</div>
|
||||||
<div class="text-caption text-center text-indigo-9">{{ member.role }}</div>
|
<div v-if="member.descriptions && member.descriptions.length">
|
||||||
|
<div
|
||||||
|
v-for="(desc, index) in member.descriptions"
|
||||||
|
:key="index"
|
||||||
|
class="text-caption text-center text-indigo-9"
|
||||||
|
>
|
||||||
|
{{ desc }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<q-card
|
||||||
|
v-if="!members.length && teamName"
|
||||||
|
class="team-name-card flex flex-center"
|
||||||
|
style="max-width: 400px; padding: 20px;"
|
||||||
|
>
|
||||||
|
<q-card-section class="text-h6 text-indigo-10 text-center">
|
||||||
|
В этой команде пока нет участников.
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<q-card
|
||||||
|
v-else-if="!teamName"
|
||||||
|
class="team-name-card flex flex-center"
|
||||||
|
style="max-width: 400px; padding: 20px;"
|
||||||
|
>
|
||||||
|
<q-card-section class="text-h6 text-indigo-10 text-center">
|
||||||
|
Выберите активную команду, чтобы увидеть участников.
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center flex-wrap q-gutter-md q-mb-xl">
|
<div class="flex justify-center flex-wrap q-gutter-md q-mb-xl">
|
||||||
<q-card
|
<q-card
|
||||||
v-for="contest in contests"
|
v-for="project in projects"
|
||||||
:key="contest.id"
|
:key="project.id"
|
||||||
class="contest-card violet-card"
|
class="contest-card violet-card"
|
||||||
bordered
|
bordered
|
||||||
style="width: 220px; cursor: pointer;" v-ripple
|
style="width: 220px; cursor: pointer;" v-ripple
|
||||||
@click="router.push({ name: 'contest-detail', params: { id: contest.id } })" >
|
@click="router.push({ name: 'project-detail', params: { id: project.id } })" >
|
||||||
<q-card-section class="q-pa-md">
|
<q-card-section class="q-pa-md">
|
||||||
<div class="text-h6">{{ contest.title }}</div>
|
<div class="text-h6">{{ project.title }}</div>
|
||||||
<div class="text-subtitle2 text-indigo-8">{{ contest.description }}</div>
|
<div class="text-subtitle2 text-indigo-8">{{ project.description }}</div>
|
||||||
|
<div v-if="project.memberCount" class="text-caption text-indigo-7">Участников: {{ project.memberCount }}</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<q-card
|
||||||
|
v-if="!projects.length && teamName"
|
||||||
|
class="team-name-card flex flex-center"
|
||||||
|
style="max-width: 400px; padding: 20px;"
|
||||||
|
>
|
||||||
|
<q-card-section class="text-h6 text-indigo-10 text-center">
|
||||||
|
В этой команде пока нет проектов.
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<q-card
|
||||||
|
v-else-if="!teamName"
|
||||||
|
class="team-name-card flex flex-center"
|
||||||
|
style="max-width: 400px; padding: 20px;"
|
||||||
|
>
|
||||||
|
<q-card-section class="text-h6 text-indigo-10 text-center">
|
||||||
|
Выберите активную команду, чтобы увидеть проекты.
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
@ -122,14 +170,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Ripple, Notify } from 'quasar'
|
import { Ripple, Notify } from 'quasar'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import CONFIG from '@/core/config.js'
|
import CONFIG from '@/core/config.js'
|
||||||
import fetchTeams from '@/api/teams/getTeams.js'
|
|
||||||
|
// --- Импорт API функций ---
|
||||||
|
import getActiveTeam from '@/api/teams/getActiveTeam.js'
|
||||||
import fetchProfiles from '@/api/profiles/getProfiles.js'
|
import fetchProfiles from '@/api/profiles/getProfiles.js'
|
||||||
import fetchContests from '@/api/contests/getContests.js'
|
import getPhotoFileById from '@/api/profiles/profile_photos/getPhotoFileById.js';
|
||||||
|
import downloadPhotoFile from '@/api/profiles/profile_photos/downloadPhotoFile.js';
|
||||||
|
import getProjectMembersByProfile from '@/api/project_members/getProjectMembersByProfile.js';
|
||||||
|
import getProjects from '@/api/projects/getProjects.js';
|
||||||
|
|
||||||
defineExpose({ directives: { ripple: Ripple } })
|
defineExpose({ directives: { ripple: Ripple } })
|
||||||
|
|
||||||
@ -148,6 +201,7 @@ const handleAuthAction = () => {
|
|||||||
message: 'Выход успешно осуществлен',
|
message: 'Выход успешно осуществлен',
|
||||||
icon: 'check_circle',
|
icon: 'check_circle',
|
||||||
})
|
})
|
||||||
|
router.push('/login')
|
||||||
} else {
|
} else {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
@ -156,12 +210,15 @@ const handleAuthAction = () => {
|
|||||||
// --- Данные команды ---
|
// --- Данные команды ---
|
||||||
const teamLogo = ref('')
|
const teamLogo = ref('')
|
||||||
const teamName = ref('')
|
const teamName = ref('')
|
||||||
|
const activeTeamId = ref(null);
|
||||||
|
const teamRepositoryUrl = ref(null); // Новая переменная для URL репозитория команды
|
||||||
|
|
||||||
// --- Участники ---
|
// --- Участники ---
|
||||||
const members = ref([])
|
const members = ref([])
|
||||||
|
|
||||||
// --- Конкурсы ---
|
// --- Проекты ---
|
||||||
const contests = ref([])
|
const projects = ref([])
|
||||||
|
const allProjects = ref([]);
|
||||||
|
|
||||||
// --- Активность ---
|
// --- Активность ---
|
||||||
const activityData = ref([]);
|
const activityData = ref([]);
|
||||||
@ -175,146 +232,326 @@ function getDynamicMonthLabelsShort() {
|
|||||||
];
|
];
|
||||||
const currentMonthIndex = new Date().getMonth();
|
const currentMonthIndex = new Date().getMonth();
|
||||||
|
|
||||||
// Создаем массив из 12 элементов, затем используем map для получения названий месяцев
|
const labels = [];
|
||||||
return Array.from({ length: 12 }, (_, i) => {
|
for (let i = 0; i < 12; i++) {
|
||||||
const monthIndex = (currentMonthIndex + i) % 12;
|
const monthIndex = (currentMonthIndex - (11 - i) + 12) % 12;
|
||||||
return monthNames[monthIndex];
|
labels.push(monthNames[monthIndex]);
|
||||||
});
|
}
|
||||||
|
return labels;
|
||||||
}
|
}
|
||||||
|
|
||||||
const monthLabels = getDynamicMonthLabelsShort();
|
const monthLabels = getDynamicMonthLabelsShort();
|
||||||
|
|
||||||
// Дни недели (пн, ср, пт, как в Gitea)
|
|
||||||
const weekDays = ['пн', 'ср', 'пт'];
|
const weekDays = ['пн', 'ср', 'пт'];
|
||||||
|
|
||||||
// Вычисляемая сетка активности (группировка по неделям)
|
|
||||||
const activityGrid = computed(() => {
|
const activityGrid = computed(() => {
|
||||||
const weeks = [];
|
const weeks = [];
|
||||||
let week = [];
|
let week = [];
|
||||||
const firstDay
|
const today = new Date();
|
||||||
|
const startDate = new Date(today);
|
||||||
|
startDate.setDate(today.getDate() - 364);
|
||||||
|
|
||||||
= new Date();
|
const firstDayOfWeekIndex = startDate.getDay();
|
||||||
firstDay.setDate(firstDay.getDate() - 364); // Год назад от текущей даты
|
const offset = firstDayOfWeekIndex === 0 ? 6 : firstDayOfWeekIndex - 1;
|
||||||
const dayOfWeek = firstDay.getDay();
|
|
||||||
const offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Смещение для выравнивания по понедельнику
|
|
||||||
|
|
||||||
// Добавляем пустые ячейки в начало
|
|
||||||
for (let i = 0; i < offset; i++) {
|
for (let i = 0; i < offset; i++) {
|
||||||
week.push({ date: '', count: 0 });
|
week.push({ date: '', count: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < 365; i++) {
|
for (let i = 0; i < 365; i++) {
|
||||||
const date = new Date(firstDay);
|
const date = new Date(startDate);
|
||||||
date.setDate(firstDay.getDate() + i);
|
date.setDate(startDate.getDate() + i);
|
||||||
const dateStr = date.toISOString().slice(0, 10);
|
const dateStr = date.toISOString().slice(0, 10);
|
||||||
const dayData = activityData.value.find(d => d.date === dateStr) || { date: dateStr, count: 0 };
|
const dayData = activityData.value.find(d => d.date === dateStr) || { date: dateStr, count: 0 };
|
||||||
|
|
||||||
week.push(dayData);
|
week.push(dayData);
|
||||||
if (week.length === 7 || i === 364) {
|
if (week.length === 7) {
|
||||||
weeks.push(week);
|
weeks.push(week);
|
||||||
week = [];
|
week = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (week.length > 0) {
|
||||||
|
while (week.length < 7) {
|
||||||
|
week.push({ date: '', count: 0 });
|
||||||
|
}
|
||||||
|
weeks.push(week);
|
||||||
|
}
|
||||||
|
|
||||||
return weeks;
|
return weeks;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Цвета активности (как в Gitea)
|
|
||||||
function getActivityColor(count) {
|
function getActivityColor(count) {
|
||||||
if (count === 0) return '#ede9fe'; // Светлый фон карточек
|
if (count === 0) return '#ede9fe';
|
||||||
if (count <= 2) return '#d8cff9'; // Светлый сиреневый
|
if (count <= 2) return '#d8cff9';
|
||||||
if (count <= 4) return '#a287ff'; // Светлый фиолетовый
|
if (count <= 4) return '#a287ff';
|
||||||
if (count <= 6) return '#7c3aed'; // Яркий фиолетовый
|
if (count <= 6) return '#7c3aed';
|
||||||
return '#4f046f'; // Темно-фиолетовый
|
return '#4f046f';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Позиционирование подписей месяцев
|
|
||||||
function getMonthMargin(idx) {
|
function getMonthMargin(idx) {
|
||||||
const daysInMonth = [30, 31, 31, 30, 31, 30, 31, 31, 28, 31, 30, 31]; // Дни в месяцах с июня 2024
|
const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||||
const daysBeforeMonth = daysInMonth.slice(0, idx).reduce((sum, days) => sum + days, 0);
|
const daysBeforeMonth = daysInMonth.slice(0, idx).reduce((sum, days) => sum + days, 0);
|
||||||
const weekIndex = Math.floor(daysBeforeMonth / 7);
|
const weekIndex = Math.floor(daysBeforeMonth / 7);
|
||||||
return weekIndex * (squareSize.value + 4); // 4 = margin (2px + 2px)
|
return weekIndex * (squareSize.value + 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка данных команды
|
// Загрузка данных команды
|
||||||
async function loadTeamData() {
|
async function loadTeamData() {
|
||||||
try {
|
try {
|
||||||
const teams = await fetchTeams();
|
const activeTeam = await getActiveTeam();
|
||||||
const activeTeam = teams.find(team => team.is_active === true);
|
|
||||||
|
|
||||||
if (activeTeam) {
|
if (activeTeam) {
|
||||||
teamName.value = activeTeam.title || 'Название не указано';
|
teamName.value = activeTeam.title || 'Название не указано';
|
||||||
teamLogo.value = activeTeam.logo || '';
|
teamLogo.value = activeTeam.logo ? `${CONFIG.BASE_URL}/teams/${activeTeam.id}/file/` : '';
|
||||||
|
activeTeamId.value = activeTeam.id;
|
||||||
// Вы также можете сохранить другие данные активной команды, если они нужны:
|
// Сохраняем URL репозитория команды
|
||||||
// teamDescription.value = activeTeam.description || '';
|
teamRepositoryUrl.value = activeTeam.git_url || null;
|
||||||
// teamGitUrl.value = activeTeam.git_url || '';
|
console.log('loadTeamData: Active team ID:', activeTeamId.value);
|
||||||
|
console.log('loadTeamData: Team Repository URL:', teamRepositoryUrl.value);
|
||||||
} else {
|
} else {
|
||||||
Notify.create({
|
Notify.create({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: 'Активная команда не найдена',
|
message: 'Активная команда не найдена. Пожалуйста, создайте команду и сделайте её активной в разделе "Команды".',
|
||||||
icon: 'warning',
|
icon: 'warning',
|
||||||
});
|
});
|
||||||
|
teamName.value = '';
|
||||||
|
teamLogo.value = '';
|
||||||
|
activeTeamId.value = null;
|
||||||
|
teamRepositoryUrl.value = null; // Сбрасываем URL
|
||||||
|
console.log('loadTeamData: No active team found.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки данных команды:', error);
|
console.error('Ошибка загрузки данных команды:', error);
|
||||||
Notify.create({
|
Notify.create({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'Ошибка загрузки данных команды',
|
message: `Ошибка загрузки данных команды: ${error.message}`,
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
});
|
});
|
||||||
|
teamName.value = '';
|
||||||
|
teamLogo.value = '';
|
||||||
|
activeTeamId.value = null;
|
||||||
|
teamRepositoryUrl.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка участников
|
// Загрузка участников и их проектов (привязанных к активной команде)
|
||||||
async function loadMembers() {
|
async function loadMembersAndProjects() {
|
||||||
|
members.value.forEach(member => {
|
||||||
|
if (member.avatar && member.avatar.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(member.avatar);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
members.value = [];
|
||||||
|
projects.value = [];
|
||||||
|
|
||||||
|
if (!activeTeamId.value) {
|
||||||
|
console.warn('activeTeamId не установлен. Не могу загрузить участников и проекты.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('loadMembersAndProjects: Starting data fetch for team ID:', activeTeamId.value);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const profiles = await fetchProfiles();
|
const fetchedProfiles = await fetchProfiles();
|
||||||
members.value = profiles.map(profile => ({
|
console.log('loadMembersAndProjects: Fetched all profiles:', fetchedProfiles);
|
||||||
id: profile.id, // ID всегда остается
|
|
||||||
name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Без имени', // Объединяем имя и фамилию
|
|
||||||
role: profile.role_id || 'Участник', // Используем role_id
|
|
||||||
avatar: profile.avatar || 'https://randomuser.me/api/portraits/men/1.jpg', // Аватар остался прежним
|
|
||||||
|
|
||||||
// Добавляем остальные поля, которые могут быть полезны
|
const teamMembers = fetchedProfiles.filter(profile => profile.team_id === activeTeamId.value);
|
||||||
patronymic: profile.patronymic || '',
|
console.log('loadMembersAndProjects: Filtered team members:', teamMembers);
|
||||||
birthday: profile.birthday || '',
|
|
||||||
email: profile.email || '',
|
// Добавляем логирование содержимого профилей перед обработкой аватаров
|
||||||
phone: profile.phone || '',
|
teamMembers.forEach(profile => {
|
||||||
team_id: profile.team_id || null,
|
console.log(`loadMembersAndProjects: Профиль для обработки аватара - ID: ${profile.id}, Имя: ${profile.first_name}, repository_url: ${profile.repository_url}`);
|
||||||
}));
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка загрузки участников:', error);
|
const uniqueProjects = new Map();
|
||||||
|
const fetchedProjects = await getProjects();
|
||||||
|
allProjects.value = fetchedProjects;
|
||||||
|
console.log('loadMembersAndProjects: Fetched all projects:', fetchedProjects);
|
||||||
|
|
||||||
|
members.value = await Promise.all(teamMembers.map(async profile => {
|
||||||
|
let avatarUrl = null;
|
||||||
|
let memberDescriptions = [];
|
||||||
|
|
||||||
|
console.log(`Processing profile: ${profile.id} (${profile.first_name} ${profile.last_name})`);
|
||||||
|
console.log(` Полные данные профиля:`, profile); // Логируем весь объект профиля
|
||||||
|
|
||||||
|
if (profile.id) {
|
||||||
|
// --- Логика загрузки аватара ---
|
||||||
|
try {
|
||||||
|
console.log(` Попытка загрузки фото для profile.id: ${profile.id}`);
|
||||||
|
const photoData = await getPhotoFileById(profile.id);
|
||||||
|
console.log(` Получены photoData для профиля ${profile.id}:`, photoData);
|
||||||
|
if (photoData && photoData.length > 0) {
|
||||||
|
const firstPhotoId = photoData[0].id;
|
||||||
|
console.log(` Найден firstPhotoId: ${firstPhotoId} для профиля ${profile.id}`);
|
||||||
|
const photoBlob = await downloadPhotoFile(firstPhotoId);
|
||||||
|
console.log(` Получен photoBlob для firstPhotoId ${firstPhotoId}:`, photoBlob);
|
||||||
|
if (photoBlob instanceof Blob) {
|
||||||
|
avatarUrl = URL.createObjectURL(photoBlob);
|
||||||
|
console.log(` Создан avatarUrl: ${avatarUrl}`);
|
||||||
|
} else {
|
||||||
|
console.warn(` Ошибка: downloadPhotoFile вернул не Blob для firstPhotoId ${firstPhotoId}. Тип: ${typeof photoBlob}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` Для профиля ${profile.id} нет photoData.`);
|
||||||
|
}
|
||||||
|
} catch (photoError) {
|
||||||
|
console.error(` Критическая ошибка при загрузке фото для профиля ${profile.id}:`, photoError);
|
||||||
Notify.create({
|
Notify.create({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: error.message || 'Ошибка загрузки участников',
|
message: `Ошибка загрузки фото для ${profile.first_name || ''} ${profile.last_name || ''}: ${photoError.message}`,
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Логика получения описаний из project_members ---
|
||||||
|
try {
|
||||||
|
const projectMemberships = await getProjectMembersByProfile(profile.id);
|
||||||
|
console.log(` Profile ${profile.id}: Raw project_memberships:`, projectMemberships);
|
||||||
|
|
||||||
|
const relevantProjectMemberships = projectMemberships.filter(pm => {
|
||||||
|
const isProfileMatch = pm.profile_id === profile.id;
|
||||||
|
let isProjectRelevant = false;
|
||||||
|
let foundProject = null;
|
||||||
|
|
||||||
|
if (pm.project_id) {
|
||||||
|
foundProject = allProjects.value.find(p => p.id === pm.project_id);
|
||||||
|
if (foundProject) {
|
||||||
|
isProjectRelevant = (foundProject.team_id === activeTeamId.value || foundProject.team_id === null || foundProject.team_id === undefined);
|
||||||
|
console.log(` PM ${pm.id} (Project ${pm.project_id}): Found project. Project Team ID: ${foundProject.team_id}, Active Team ID: ${activeTeamId.value}. Is project relevant? ${isProjectRelevant}`);
|
||||||
|
} else {
|
||||||
|
console.log(` PM ${pm.id} (Project ${pm.project_id}): Project NOT found in allProjects. Considering profile's team instead for relevance.`);
|
||||||
|
isProjectRelevant = (profile.team_id === activeTeamId.value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` PM ${pm.id}: No project_id on project_member. Considering profile's team for relevance.`);
|
||||||
|
isProjectRelevant = (profile.team_id === activeTeamId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка конкурсов
|
const result = isProfileMatch && isProjectRelevant;
|
||||||
async function loadContests() {
|
console.log(` PM ${pm.id}: Profile match (${isProfileMatch}) && Project relevant (${isProjectRelevant}). ===> Filter result: ${result}`);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
console.log(` Profile ${profile.id}: Relevant project_memberships (after detailed filter):`, relevantProjectMemberships);
|
||||||
|
|
||||||
|
// Добавляем только непустые описания из relevant project_members
|
||||||
|
relevantProjectMemberships.forEach(pm => {
|
||||||
|
console.log(` Processing project_member ID ${pm.id} for profile ${profile.id}. Description: "${pm.description}"`);
|
||||||
|
if (pm.description) {
|
||||||
|
const trimmedDesc = String(pm.description).trim();
|
||||||
|
if (trimmedDesc !== '') {
|
||||||
|
memberDescriptions.push(trimmedDesc);
|
||||||
|
console.log(` Added non-empty description from project_member: "${trimmedDesc}"`);
|
||||||
|
} else {
|
||||||
|
console.log(` Skipped empty description from project_member (trimmed empty): "${pm.description}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` Skipped undefined/null description from project_member ID ${pm.id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(` Profile ${profile.id}: memberDescriptions from project_members after processing:`, memberDescriptions);
|
||||||
|
|
||||||
|
// Если нет описаний из project_members, используем profile.description, если оно непустое
|
||||||
|
if (memberDescriptions.length === 0 && profile.description) {
|
||||||
|
console.log(` Profile ${profile.id}: No descriptions from project_members. Checking profile.description: "${profile.description}"`);
|
||||||
|
const trimmedProfileDesc = String(profile.description).trim();
|
||||||
|
if (trimmedProfileDesc !== '') {
|
||||||
|
memberDescriptions.push(trimmedProfileDesc);
|
||||||
|
console.log(` Added non-empty description from profile: "${trimmedProfileDesc}"`);
|
||||||
|
} else {
|
||||||
|
console.log(` Skipped empty profile.description (trimmed empty): "${profile.description}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` Profile ${profile.id}: Final memberDescriptions:`, memberDescriptions);
|
||||||
|
|
||||||
|
} catch (projectError) {
|
||||||
|
console.warn(`Не удалось загрузить проекты или описание для профиля ${profile.id}: ${projectError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Собираем проекты для команды ---
|
||||||
try {
|
try {
|
||||||
const fetchedContests = await fetchContests();
|
const projectMembershipsForProjects = await getProjectMembersByProfile(profile.id);
|
||||||
contests.value = fetchedContests.map(contest => ({
|
projectMembershipsForProjects.forEach(pm => {
|
||||||
id: contest.id,
|
const project = fetchedProjects.find(p => p.id === pm.project_id);
|
||||||
title: contest.title || 'Без названия',
|
if (project && (project.team_id === activeTeamId.value || project.team_id === null || project.team_id === undefined)) {
|
||||||
description: contest.description || 'Описание отсутствует',
|
if (!uniqueProjects.has(project.id)) {
|
||||||
|
uniqueProjects.set(project.id, { ...project, memberCount: 1 });
|
||||||
|
} else {
|
||||||
|
uniqueProjects.get(project.id).memberCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (projAggError) {
|
||||||
|
console.warn(`Ошибка при агрегации проектов для профиля ${profile.id}: ${projAggError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Без имени',
|
||||||
|
descriptions: memberDescriptions,
|
||||||
|
avatar: avatarUrl,
|
||||||
|
};
|
||||||
}));
|
}));
|
||||||
|
console.log('loadMembersAndProjects: Final members array:', members.value);
|
||||||
|
|
||||||
|
projects.value = Array.from(uniqueProjects.values());
|
||||||
|
console.log('loadMembersAndProjects: Final projects array:', projects.value);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки конкурсов:', error);
|
console.error('Ошибка загрузки участников или проектов:', error);
|
||||||
Notify.create({
|
Notify.create({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: error.message || 'Ошибка загрузки конкурсов',
|
message: error.message || 'Ошибка загрузки участников или проектов',
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка активности
|
// Загрузка активности команды
|
||||||
const username = 'archibald';
|
|
||||||
|
|
||||||
async function loadActivity() {
|
async function loadActivity() {
|
||||||
|
activityData.value = [];
|
||||||
|
|
||||||
|
if (!activeTeamId.value) {
|
||||||
|
console.warn('loadActivity: activeTeamId не установлен. Не могу загрузить активность.');
|
||||||
|
// Заполнение активности нулями, если команда не выбрана
|
||||||
|
fillActivityWithZeros();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = null;
|
||||||
|
if (teamRepositoryUrl.value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(teamRepositoryUrl.value);
|
||||||
|
// Разбиваем путь на части и берем последнюю непустую
|
||||||
|
const pathParts = url.pathname.split('/').filter(part => part !== '');
|
||||||
|
username = pathParts[pathParts.length - 1]; // Берем последнюю часть
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadActivity: Ошибка парсинга URL репозитория:', e);
|
||||||
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Некорректный URL репозитория для активности команды.',
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
|
fillActivityWithZeros();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
console.warn('loadActivity: URL репозитория команды отсутствует или не удалось извлечь username. Загрузка активности невозможна.');
|
||||||
|
Notify.create({
|
||||||
|
type: 'info',
|
||||||
|
message: 'URL репозитория для активной команды не указан. Активность не будет показана.',
|
||||||
|
icon: 'info',
|
||||||
|
});
|
||||||
|
fillActivityWithZeros();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`loadActivity: Попытка загрузить активность для username: "${username}"`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${CONFIG.BASE_URL}/rss/${username}/`);
|
const response = await axios.get(`${CONFIG.BASE_URL}/rss/${username}/`);
|
||||||
const fetchedData = response.data.map(item => ({
|
const fetchedData = response.data.map(item => ({
|
||||||
@ -334,15 +571,29 @@ async function loadActivity() {
|
|||||||
const count = dataMap.get(dateStr) || 0;
|
const count = dataMap.get(dateStr) || 0;
|
||||||
activityData.value.push({ date: dateStr, count });
|
activityData.value.push({ date: dateStr, count });
|
||||||
}
|
}
|
||||||
|
console.log('loadActivity: Активность успешно загружена.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки активности:', error);
|
console.error('loadActivity: Ошибка загрузки активности:', error);
|
||||||
|
let errorMessage = 'Ошибка загрузки данных активности.';
|
||||||
|
if (error.response && error.response.status === 404) {
|
||||||
|
errorMessage = `Активность для репозитория "${username}" не найдена. Возможно, указан неверный URL или репозиторий не существует.`;
|
||||||
|
} else {
|
||||||
|
errorMessage = `Ошибка загрузки данных активности: ${error.message || error.response?.data?.detail || 'Неизвестная ошибка'}`;
|
||||||
|
}
|
||||||
|
|
||||||
Notify.create({
|
Notify.create({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'Ошибка загрузки данных активности',
|
message: errorMessage,
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
|
timeout: 5000 // Увеличим время отображения для ошибки
|
||||||
});
|
});
|
||||||
|
|
||||||
// Заполняем пустыми данными в случае ошибки
|
fillActivityWithZeros(); // Заполнение нулями в случае ошибки
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательная функция для заполнения activityData нулями
|
||||||
|
function fillActivityWithZeros() {
|
||||||
const lastDate = new Date();
|
const lastDate = new Date();
|
||||||
const startDate = new Date(lastDate);
|
const startDate = new Date(lastDate);
|
||||||
startDate.setDate(lastDate.getDate() - 364);
|
startDate.setDate(lastDate.getDate() - 364);
|
||||||
@ -354,13 +605,31 @@ async function loadActivity() {
|
|||||||
activityData.value.push({ date: dateStr, count: 0 });
|
activityData.value.push({ date: dateStr, count: 0 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadTeamData(), loadMembers(), loadContests(), loadActivity()]);
|
await loadTeamData(); // Сначала загружаем данные команды, чтобы получить teamRepositoryUrl
|
||||||
|
|
||||||
|
// Затем загружаем активность, используя полученный URL
|
||||||
|
await Promise.allSettled([
|
||||||
|
loadMembersAndProjects(),
|
||||||
|
loadActivity()
|
||||||
|
]).then(results => {
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
// Ошибки уже логируются внутри loadMembersAndProjects и loadActivity
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
members.value.forEach(member => {
|
||||||
|
if (member.avatar && member.avatar.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(member.avatar);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Масштабирование
|
|
||||||
function increaseScale() {
|
function increaseScale() {
|
||||||
if (squareSize.value < 24) squareSize.value += 2;
|
if (squareSize.value < 24) squareSize.value += 2;
|
||||||
}
|
}
|
||||||
@ -371,6 +640,10 @@ function decreaseScale() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.member-avatar-fix img {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
/* Остальные стили без изменений */
|
||||||
.activity-grid {
|
.activity-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@ -451,7 +724,7 @@ function decreaseScale() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
margin-left: 40px; /* Синхронизация с .weekdays-column */
|
margin-left: 40px;
|
||||||
margin-bottom: 24px !important;
|
margin-bottom: 24px !important;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@ -460,9 +733,9 @@ function decreaseScale() {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: absolute; /* Для точного позиционирования */
|
position: absolute;
|
||||||
}
|
}
|
||||||
.weekdays-column {
|
.weekdays-column {
|
||||||
margin-top: -15px; /* Попробуйте разные значения, например, -10px, -30px, пока не найдете оптимальное */
|
margin-top: -15px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
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-h6 text-indigo-10 q-mb-md">Основная информация</div>
|
||||||
<div class="text-body1 text-indigo-9 q-mb-sm">
|
<div class="text-body1 text-indigo-9 q-mb-sm">
|
||||||
<q-icon name="cake" size="xs" class="q-mr-xs" />
|
<q-icon name="cake" size="xs" class="q-mr-xs" />
|
||||||
День рождения: <span class="text-weight-bold">{{ profile.birth_date }}</span>
|
День рождения: <span class="text-weight-bold">{{ formattedBirthday }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-body1 text-indigo-9">
|
<q-separator class="q-my-md" />
|
||||||
<q-icon name="person" size="xs" class="q-mr-xs" />
|
|
||||||
Пол: <span class="text-weight-bold">{{ profile.gender }}</span>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<q-card class="info-card violet-card">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-h6 text-indigo-10 q-mb-md">Контакты</div>
|
|
||||||
<div class="text-body1 text-indigo-9 q-mb-sm">
|
<div class="text-body1 text-indigo-9 q-mb-sm">
|
||||||
<q-icon name="email" size="xs" class="q-mr-xs" />
|
<q-icon name="email" size="xs" class="q-mr-xs" />
|
||||||
Email: <a :href="'mailto:' + profile.email" class="text-indigo-9" style="text-decoration: none;">{{ profile.email }}</a>
|
Email: <a :href="'mailto:' + profile.email" class="text-indigo-9" style="text-decoration: none;">{{ profile.email }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="profile.phone_number" class="text-body1 text-indigo-9">
|
<div v-if="profile.phone" class="text-body1 text-indigo-9">
|
||||||
<q-icon name="phone" size="xs" class="q-mr-xs" />
|
<q-icon name="phone" size="xs" class="q-mr-xs" />
|
||||||
Телефон: <a :href="'tel:' + profile.phone_number" class="text-indigo-9" style="text-decoration: none;">{{ profile.phone_number }}</a>
|
Телефон: <a :href="'tel:' + profile.phone" class="text-indigo-9" style="text-decoration: none;">{{ profile.phone }}</a>
|
||||||
|
</div>
|
||||||
|
<q-separator class="q-my-md" />
|
||||||
|
<div v-if="profile.repository_url" class="text-body1 text-indigo-9">
|
||||||
|
<q-icon name="code" size="xs" class="q-mr-xs" />
|
||||||
|
Репозиторий: <a :href="profile.repository_url" target="_blank" rel="noopener noreferrer" class="text-indigo-9" style="text-decoration: none;">{{ displayRepositoryUrl }}</a>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@ -87,20 +81,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row q-col-gutter-md justify-center q-mt-xl">
|
<div class="row q-col-gutter-md justify-center q-mt-xl">
|
||||||
<div class="col-xs-12 col-md-6" v-if="profile.teams && profile.teams.length > 0">
|
<div class="col-xs-12 col-md-6" v-if="profile.team">
|
||||||
<q-card class="violet-card">
|
<q-card class="violet-card">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-h6 text-indigo-10 q-mb-md">Команды</div>
|
<div class="text-h6 text-indigo-10 q-mb-md">Команда</div>
|
||||||
<q-list separator bordered class="rounded-borders">
|
<q-list separator bordered class="rounded-borders">
|
||||||
<q-item v-for="team in profile.teams" :key="team.id" clickable v-ripple @click="goToTeamDetail(team.id)">
|
<q-item clickable v-ripple @click="goToTeamDetail(profile.team.id)">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-avatar size="md">
|
<q-avatar size="md">
|
||||||
<img :src="team.logo || 'https://cdn.quasar.dev/logo-v2/svg/logo.svg'" alt="Логотип команды"/>
|
<img :src="profile.team.logo || 'https://cdn.quasar.dev/logo-v2/svg/logo.svg'" alt="Логотип команды"/>
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>{{ team.name }}</q-item-label>
|
<q-item-label>{{ profile.team.name }}</q-item-label>
|
||||||
<q-item-label caption>{{ team.role }}</q-item-label>
|
<q-item-label caption v-if="profile.role_name">{{ profile.role_name }}</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-icon name="chevron_right" color="indigo-6" />
|
<q-icon name="chevron_right" color="indigo-6" />
|
||||||
@ -111,17 +105,12 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-xs-12 col-md-6" v-if="profile.projects && profile.projects.length > 0">
|
<div :class="{'col-xs-12 col-md-6': profile.team, 'col-xs-12 col-md-12': !profile.team}" v-if="profile.projects && profile.projects.length > 0">
|
||||||
<q-card class="violet-card">
|
<q-card class="violet-card">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-h6 text-indigo-10 q-mb-md">Участие в проектах</div>
|
<div class="text-h6 text-indigo-10 q-mb-md">Участие в проектах</div>
|
||||||
<q-list separator bordered class="rounded-borders">
|
<q-list separator bordered class="rounded-borders">
|
||||||
<q-item v-for="project in profile.projects" :key="project.id" clickable v-ripple @click="goToProjectDetail(project.id)">
|
<q-item v-for="project in profile.projects" :key="project.id" clickable v-ripple @click="goToProjectDetail(project.id)">
|
||||||
<q-item-section avatar>
|
|
||||||
<q-avatar size="md">
|
|
||||||
<img :src="project.photo || 'https://cdn.quasar.dev/logo-v2/svg/logo.svg'" alt="Логотип проекта"/>
|
|
||||||
</q-avatar>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>{{ project.title }}</q-item-label>
|
<q-item-label>{{ project.title }}</q-item-label>
|
||||||
<q-item-label caption>{{ project.role }}</q-item-label>
|
<q-item-label caption>{{ project.role }}</q-item-label>
|
||||||
@ -136,15 +125,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-separator class="q-my-lg" color="indigo-4" style="width: 80%; margin: 0 auto;"/>
|
|
||||||
<div class="q-mt-md"></div>
|
<div class="q-mt-md"></div>
|
||||||
|
<q-separator class="q-my-lg" color="indigo-4" style="width: 100%; margin: 0 auto;"/>
|
||||||
|
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<q-card class="activity-card violet-card" style="max-width: 940px; width: 100%;">
|
<q-card class="activity-card violet-card" style="max-width: 940px; width: 100%;">
|
||||||
<q-card-section class="q-pa-md">
|
<q-card-section class="q-pa-md">
|
||||||
<div class="text-h6 text-indigo-10 q-mb-md">Активность за последний год</div>
|
<div class="text-h6 text-indigo-10 q-mb-md">Активность команды за последний год</div>
|
||||||
|
|
||||||
<div class="months-row flex" style="margin-left: 40px; margin-bottom: 4px; user-select: none;">
|
<div class="months-row flex" style="margin-left: 60px; margin-bottom: 4px; user-select: none;">
|
||||||
<div
|
<div
|
||||||
v-for="(monthLabel, idx) in monthLabels"
|
v-for="(monthLabel, idx) in monthLabels"
|
||||||
:key="monthLabel"
|
:key="monthLabel"
|
||||||
@ -156,7 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="activity-grid-row row no-wrap">
|
<div class="activity-grid-row row no-wrap">
|
||||||
<div class="weekdays-column column q-pr-sm" style="width: 40px; user-select: none; justify-content: space-around;">
|
<div class="weekdays-column column q-pr-sm" style="width: 30px; user-select: none; justify-content: space-around;">
|
||||||
<div
|
<div
|
||||||
v-for="(day, idx) in weekDays"
|
v-for="(day, idx) in weekDays"
|
||||||
:key="day"
|
:key="day"
|
||||||
@ -192,7 +181,6 @@
|
|||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-center q-pt-xl text-white text-h5" style="min-height: 50vh;">
|
<div v-else class="flex flex-center q-pt-xl text-white text-h5" style="min-height: 50vh;">
|
||||||
@ -203,12 +191,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { Ripple, Notify } from 'quasar';
|
import { Ripple, Notify } from 'quasar';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import CONFIG from "@/core/config.js";
|
import CONFIG from "@/core/config.js";
|
||||||
|
|
||||||
|
// --- Импорт API функций ---
|
||||||
|
import fetchAllProfiles from '@/api/profiles/getProfiles.js';
|
||||||
|
import getPhotosByProfileId from '@/api/profiles/profile_photos/getPhotoFileById.js';
|
||||||
|
import downloadPhotoFile from '@/api/profiles/profile_photos/downloadPhotoFile.js';
|
||||||
|
import getProjectMembersByProfile from '@/api/project_members/getProjectMembersByProfile.js';
|
||||||
|
import getTeams from '@/api/teams/getTeams.js';
|
||||||
|
import getProjects from '@/api/projects/getProjects.js';
|
||||||
|
|
||||||
|
|
||||||
defineExpose({ directives: { ripple: Ripple } });
|
defineExpose({ directives: { ripple: Ripple } });
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -218,71 +215,114 @@ const profile = ref(null);
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const profileId = computed(() => route.params.id);
|
const profileId = computed(() => route.params.id);
|
||||||
|
|
||||||
const slide = ref(1);
|
const slide = ref(1); // Для карусели фото
|
||||||
|
|
||||||
// --- Активность ---
|
// --- Активность (логика скопирована 1:1 из HomePage) ---
|
||||||
const activityData = ref([]);
|
const activityData = ref([]);
|
||||||
const dayHeight = 14;
|
const dayHeight = 14;
|
||||||
const squareSize = ref(12);
|
const squareSize = ref(12);
|
||||||
|
|
||||||
// Подписи месяцев (с июня 2024 по май 2025, чтобы соответствовать текущему году)
|
const monthLabels = ['янв.', 'февр.', 'март', 'апр.', 'май', 'июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.'];
|
||||||
const monthLabels = ['июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.', 'янв.', 'февр.', 'март', 'апр.', 'май'];
|
|
||||||
|
|
||||||
// Дни недели (пн, ср, пт, как в Gitea)
|
|
||||||
const weekDays = ['пн', 'ср', 'пт'];
|
const weekDays = ['пн', 'ср', 'пт'];
|
||||||
|
|
||||||
// Вычисляемая сетка активности (группировка по неделям)
|
|
||||||
const activityGrid = computed(() => {
|
const activityGrid = computed(() => {
|
||||||
const weeks = [];
|
const weeks = [];
|
||||||
let week = [];
|
let week = [];
|
||||||
const firstDay = new Date();
|
const today = new Date();
|
||||||
firstDay.setDate(firstDay.getDate() - 364); // Год назад от текущей даты
|
const startDate = new Date(today);
|
||||||
const dayOfWeek = firstDay.getDay();
|
startDate.setDate(today.getDate() - 364);
|
||||||
const offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Смещение для выравнивания по понедельнику
|
|
||||||
|
|
||||||
// Добавляем пустые ячейки в начало
|
const firstDayOfWeekIndex = startDate.getDay(); // 0-воскресенье, 1-понедельник
|
||||||
|
const offset = firstDayOfWeekIndex === 0 ? 6 : firstDayOfWeekIndex - 1; // Смещение для выравнивания по понедельнику (0-пн, 1-вт...)
|
||||||
|
|
||||||
|
// Добавляем пустые ячейки в начало для выравнивания первой недели
|
||||||
for (let i = 0; i < offset; i++) {
|
for (let i = 0; i < offset; i++) {
|
||||||
week.push({ date: '', count: 0 });
|
week.push({ date: '', count: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < 365; i++) {
|
for (let i = 0; i < 365; i++) {
|
||||||
const date = new Date(firstDay);
|
const date = new Date(startDate);
|
||||||
date.setDate(firstDay.getDate() + i);
|
date.setDate(startDate.getDate() + i);
|
||||||
const dateStr = date.toISOString().slice(0, 10);
|
const dateStr = date.toISOString().slice(0, 10);
|
||||||
const dayData = activityData.value.find(d => d.date === dateStr) || { date: dateStr, count: 0 };
|
const dayData = activityData.value.find(d => d.date === dateStr) || { date: dateStr, count: 0 };
|
||||||
|
|
||||||
week.push(dayData);
|
week.push(dayData);
|
||||||
if (week.length === 7 || i === 364) {
|
if (week.length === 7) {
|
||||||
weeks.push(week);
|
weeks.push(week);
|
||||||
week = [];
|
week = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Добавляем оставшиеся дни последней недели
|
||||||
|
if (week.length > 0) {
|
||||||
|
while (week.length < 7) {
|
||||||
|
week.push({ date: '', count: 0 });
|
||||||
|
}
|
||||||
|
weeks.push(week);
|
||||||
|
}
|
||||||
return weeks;
|
return weeks;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Цвета активности (как в Gitea)
|
|
||||||
function getActivityColor(count) {
|
function getActivityColor(count) {
|
||||||
if (count === 0) return '#ede9fe'; // Светлый фон карточек
|
if (count === 0) return '#ede9fe';
|
||||||
if (count <= 2) return '#d8cff9'; // Светлый сиреневый
|
if (count <= 2) return '#d8cff9';
|
||||||
if (count <= 4) return '#a287ff'; // Светлый фиолетовый
|
if (count <= 4) return '#a287ff';
|
||||||
if (count <= 6) return '#7c3aed'; // Яркий фиолетовый
|
if (count <= 6) return '#7c3aed';
|
||||||
return '#4f046f'; // Темно-фиолетовый
|
return '#4f046f';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Позиционирование подписей месяцев
|
|
||||||
function getMonthMargin(idx) {
|
function getMonthMargin(idx) {
|
||||||
const daysInMonth = [30, 31, 31, 30, 31, 30, 31, 31, 28, 31, 30, 31]; // Дни в месяцах с июня 2024
|
const now = new Date();
|
||||||
const daysBeforeMonth = daysInMonth.slice(0, idx).reduce((sum, days) => sum + days, 0);
|
const currentMonth = now.getMonth(); // 0-11
|
||||||
|
const daysInMonthArray = [];
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const month = (currentMonth - (11 - i) + 12) % 12;
|
||||||
|
const year = now.getFullYear();
|
||||||
|
daysInMonthArray.push(new Date(year, month + 1, 0).getDate()); // Последний день месяца даёт количество дней
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysBeforeMonth = daysInMonthArray.slice(0, idx).reduce((sum, days) => sum + days, 0);
|
||||||
const weekIndex = Math.floor(daysBeforeMonth / 7);
|
const weekIndex = Math.floor(daysBeforeMonth / 7);
|
||||||
return weekIndex * (squareSize.value + 4); // 4 = margin (2px + 2px)
|
return weekIndex * (squareSize.value + 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка активности из API
|
// Загрузка активности из API
|
||||||
const usernameForActivity = 'archibald'; // Фиксированный username для активности
|
async function loadActivity(profileRepositoryUrl) {
|
||||||
|
activityData.value = []; // Очищаем данные перед загрузкой
|
||||||
|
|
||||||
async function loadActivity() {
|
let username = null;
|
||||||
|
if (profileRepositoryUrl) {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${CONFIG.BASE_URL}/rss/${usernameForActivity}/`);
|
const url = new URL(profileRepositoryUrl);
|
||||||
|
const pathParts = url.pathname.split('/').filter(part => part !== '');
|
||||||
|
username = pathParts[pathParts.length - 1]; // Берем последнюю часть как username
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadActivity: Ошибка парсинга URL репозитория:', e);
|
||||||
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Некорректный URL репозитория для активности профиля.',
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
|
fillActivityWithZeros();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
console.warn('loadActivity: URL репозитория профиля отсутствует или не удалось извлечь username. Загрузка активности невозможна.');
|
||||||
|
Notify.create({
|
||||||
|
type: 'info',
|
||||||
|
message: 'URL репозитория для профиля не указан. Активность не будет показана.',
|
||||||
|
icon: 'info',
|
||||||
|
timeout: 3000
|
||||||
|
});
|
||||||
|
fillActivityWithZeros();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${CONFIG.BASE_URL}/rss/${username}/`);
|
||||||
const fetchedData = response.data.map(item => ({
|
const fetchedData = response.data.map(item => ({
|
||||||
date: item.date,
|
date: item.date,
|
||||||
count: parseInt(item.count, 10) || 0
|
count: parseInt(item.count, 10) || 0
|
||||||
@ -301,14 +341,25 @@ async function loadActivity() {
|
|||||||
activityData.value.push({date: dateStr, count});
|
activityData.value.push({date: dateStr, count});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки активности:', error);
|
console.error(`Ошибка загрузки активности для ${username}:`, error);
|
||||||
|
let errorMessage = `Ошибка загрузки данных активности для ${username}.`;
|
||||||
|
if (error.response && error.response.status === 404) {
|
||||||
|
errorMessage = `Активность для репозитория "${username}" не найдена. Возможно, указан неверный URL или репозиторий не существует.`;
|
||||||
|
} else {
|
||||||
|
errorMessage = `Ошибка загрузки данных активности: ${error.message || error.response?.data?.detail || 'Неизвестная ошибка'}`;
|
||||||
|
}
|
||||||
|
|
||||||
Notify.create({
|
Notify.create({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'Ошибка загрузки данных активности',
|
message: errorMessage,
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
|
timeout: 5000
|
||||||
});
|
});
|
||||||
|
fillActivityWithZeros();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Заполняем пустыми данными в случае ошибки
|
function fillActivityWithZeros() {
|
||||||
const lastDate = new Date();
|
const lastDate = new Date();
|
||||||
const startDate = new Date(lastDate);
|
const startDate = new Date(lastDate);
|
||||||
startDate.setDate(lastDate.getDate() - 364);
|
startDate.setDate(lastDate.getDate() - 364);
|
||||||
@ -320,9 +371,7 @@ async function loadActivity() {
|
|||||||
activityData.value.push({ date: dateStr, count: 0 });
|
activityData.value.push({ date: dateStr, count: 0 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Масштабирование
|
|
||||||
function increaseScale() {
|
function increaseScale() {
|
||||||
if (squareSize.value < 24) squareSize.value += 2;
|
if (squareSize.value < 24) squareSize.value += 2;
|
||||||
}
|
}
|
||||||
@ -331,130 +380,136 @@ function decreaseScale() {
|
|||||||
if (squareSize.value > 8) squareSize.value -= 2;
|
if (squareSize.value > 8) squareSize.value -= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Форматирование даты рождения
|
||||||
|
const formattedBirthday = computed(() => {
|
||||||
|
if (profile.value && profile.value.birthday) {
|
||||||
|
const dateParts = profile.value.birthday.split('-');
|
||||||
|
if (dateParts.length === 3) {
|
||||||
|
return `${dateParts[2]}.${dateParts[1]}.${dateParts[0]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Не указано';
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayRepositoryUrl = computed(() => {
|
||||||
|
if (profile.value && profile.value.repository_url) {
|
||||||
|
try {
|
||||||
|
const url = new URL(profile.value.repository_url);
|
||||||
|
const parts = url.pathname.split('/').filter(p => p);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0]}/${parts[1]}`;
|
||||||
|
}
|
||||||
|
return url.hostname;
|
||||||
|
} catch {
|
||||||
|
return profile.value.repository_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Не указан';
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const blobUrls = ref([]);
|
||||||
|
|
||||||
async function fetchProfileDetails(id) {
|
async function fetchProfileDetails(id) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
profile.value = null;
|
||||||
|
blobUrls.value.forEach(url => URL.revokeObjectURL(url));
|
||||||
|
blobUrls.value = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mockProfiles = [
|
// *** ИЗМЕНЕНИЯ ЗДЕСЬ ***
|
||||||
{
|
const allProfiles = await fetchAllProfiles(); // Получаем все профили
|
||||||
id: 1,
|
console.log('Fetched all profiles:', allProfiles);
|
||||||
first_name: 'Иван',
|
|
||||||
last_name: 'Иванов',
|
const fetchedProfile = allProfiles.find(p => String(p.id) === String(id)); // Ищем профиль по ID
|
||||||
patronymic: 'Иванович',
|
if (!fetchedProfile) {
|
||||||
birth_date: '10.01.1990',
|
throw new Error(`Профиль с ID ${id} не найден.`); // Если профиль не найден, генерируем ошибку
|
||||||
gender: 'Мужской',
|
|
||||||
email: 'ivan.ivanov@example.com',
|
|
||||||
phone_number: '+79011234567',
|
|
||||||
main_photo: 'https://randomuser.me/api/portraits/men/32.jpg',
|
|
||||||
carousel_photos: [
|
|
||||||
{ id: 1, url: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
|
|
||||||
{ id: 2, url: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
|
|
||||||
{ id: 3, url: 'https://images.unsplash.com/photo-1522075469751-3a6694fa2a86?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
|
|
||||||
],
|
|
||||||
teams: [
|
|
||||||
{ id: 101, name: 'Digital Dream Team', role: 'Team Lead', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
|
|
||||||
{ id: 102, name: 'Growth Hackers', role: 'Mentor', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
|
|
||||||
],
|
|
||||||
projects: [
|
|
||||||
{ id: 201, title: 'CRM System Dev', role: 'Backend Lead', photo: 'https://cdn.quasar.dev/img/material.png' },
|
|
||||||
{ id: 202, title: 'Mobile App Redesign', role: 'Consultant', photo: 'https://cdn.quasar.dev/img/donuts.png' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
first_name: 'Мария',
|
|
||||||
last_name: 'Петрова',
|
|
||||||
patronymic: 'Александровна',
|
|
||||||
birth_date: '22.03.1993',
|
|
||||||
gender: 'Женский',
|
|
||||||
email: 'maria.petrova@example.com',
|
|
||||||
phone_number: '+79209876543',
|
|
||||||
main_photo: 'https://randomuser.me/api/portraits/women/44.jpg',
|
|
||||||
carousel_photos: [
|
|
||||||
{ id: 4, url: 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
|
|
||||||
{ id: 5, url: 'https://images.unsplash.com/photo-1580489944761-15ad79929219?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
|
|
||||||
],
|
|
||||||
teams: [
|
|
||||||
{ id: 101, name: 'Digital Dream Team', role: 'Frontend', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
|
|
||||||
],
|
|
||||||
projects: [
|
|
||||||
{ id: 204, title: 'Marketing Website', role: 'Lead Frontend', photo: 'https://cdn.quasar.dev/img/parallax2.jpg' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
first_name: 'Алексей',
|
|
||||||
last_name: 'Смирнов',
|
|
||||||
patronymic: 'Сергеевич',
|
|
||||||
birth_date: '05.09.1988',
|
|
||||||
gender: 'Мужской',
|
|
||||||
email: 'alex.smirnov@example.com',
|
|
||||||
phone_number: null,
|
|
||||||
main_photo: 'https://randomuser.me/api/portraits/men/65.jpg',
|
|
||||||
carousel_photos: [],
|
|
||||||
teams: [
|
|
||||||
{ id: 101, name: 'Digital Dream Team', role: 'Backend', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
|
|
||||||
],
|
|
||||||
projects: [
|
|
||||||
{ id: 201, title: 'CRM System Dev', role: 'Backend Dev', photo: 'https://cdn.quasar.dev/img/material.png' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
first_name: 'Анна',
|
|
||||||
last_name: 'Кузнецова',
|
|
||||||
patronymic: 'Викторовна',
|
|
||||||
birth_date: '30.11.1996',
|
|
||||||
gender: 'Женский',
|
|
||||||
email: 'anna.kuznetsova@example.com',
|
|
||||||
phone_number: '+79151112233',
|
|
||||||
main_photo: 'https://randomuser.me/api/portraits/women/56.jpg',
|
|
||||||
carousel_photos: [
|
|
||||||
{ id: 6, url: 'https://images.unsplash.com/photo-1522075469751-3a6694fa2a86?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
|
|
||||||
],
|
|
||||||
teams: [
|
|
||||||
{ id: 104, name: 'Digital Dream Team', role: 'Designer', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
|
|
||||||
],
|
|
||||||
projects: [
|
|
||||||
{ id: 205, title: 'Brand Identity', role: 'Lead Designer', photo: 'https://cdn.quasar.dev/img/parallax1.jpg' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
first_name: 'Дмитрий',
|
|
||||||
last_name: 'Орлов',
|
|
||||||
patronymic: 'Васильевич',
|
|
||||||
birth_date: '18.07.1991',
|
|
||||||
gender: 'Мужской',
|
|
||||||
email: 'dmitry.orlov@example.com',
|
|
||||||
phone_number: '+79304445566',
|
|
||||||
main_photo: 'https://randomuser.me/api/portraits/men/78.jpg',
|
|
||||||
carousel_photos: [],
|
|
||||||
teams: [
|
|
||||||
{ id: 105, name: 'Digital Dream Team', role: 'QA Engineer', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
|
|
||||||
],
|
|
||||||
projects: [
|
|
||||||
{ id: 206, title: 'Testing Automation Framework', role: 'QA Lead', photo: 'https://cdn.quasar.dev/img/quasar.jpg' },
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
];
|
console.log('Found profile:', fetchedProfile);
|
||||||
|
|
||||||
profile.value = mockProfiles.find(p => p.id === parseInt(id));
|
let profileTeam = null;
|
||||||
|
let profileRoleName = null;
|
||||||
|
if (fetchedProfile.team_id) {
|
||||||
|
try {
|
||||||
|
const allTeams = await getTeams();
|
||||||
|
const foundTeam = allTeams.find(t => t.id === fetchedProfile.team_id);
|
||||||
|
if (foundTeam) {
|
||||||
|
profileTeam = {
|
||||||
|
id: foundTeam.id,
|
||||||
|
name: foundTeam.title,
|
||||||
|
logo: foundTeam.logo ? `${CONFIG.BASE_URL}/teams/${foundTeam.id}/file/` : null,
|
||||||
|
};
|
||||||
|
const rolesMap = { 1: 'Администратор', 2: 'Участник', 3: 'Менеджер' };
|
||||||
|
profileRoleName = rolesMap[fetchedProfile.role_id] || 'Участник команды';
|
||||||
|
}
|
||||||
|
} catch (teamError) {
|
||||||
|
console.warn('Ошибка загрузки команд для профиля:', teamError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!profile.value) {
|
const profileProjects = [];
|
||||||
Notify.create({
|
try {
|
||||||
type: 'negative',
|
const projectMemberships = await getProjectMembersByProfile(id);
|
||||||
message: 'Профиль с таким ID не найден.',
|
const allProjects = await getProjects();
|
||||||
icon: 'warning',
|
|
||||||
|
for (const pm of projectMemberships) {
|
||||||
|
const project = allProjects.find(p => p.id === pm.project_id);
|
||||||
|
if (project) {
|
||||||
|
profileProjects.push({
|
||||||
|
id: project.id,
|
||||||
|
title: project.title,
|
||||||
|
role: pm.description || 'Участник',
|
||||||
|
photo: project.photo ? `${CONFIG.BASE_URL}/projects/${project.id}/file/` : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (projectMemberError) {
|
||||||
|
console.warn('Ошибка загрузки участия в проектах для профиля:', projectMemberError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const carouselPhotos = [];
|
||||||
|
try {
|
||||||
|
const photosData = await getPhotosByProfileId(id);
|
||||||
|
for (const photoInfo of photosData) {
|
||||||
|
if (photoInfo.id) {
|
||||||
|
const photoBlob = await downloadPhotoFile(photoInfo.id);
|
||||||
|
if (photoBlob instanceof Blob) {
|
||||||
|
const url = URL.createObjectURL(photoBlob);
|
||||||
|
blobUrls.value.push(url);
|
||||||
|
carouselPhotos.push({ id: photoInfo.id, url: url });
|
||||||
|
} else {
|
||||||
|
console.warn(`downloadPhotoFile для фото ${photoInfo.id} вернул не Blob.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (photoLoadingError) {
|
||||||
|
console.error('Ошибка загрузки фотографий карусели:', photoLoadingError);
|
||||||
|
Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Не удалось загрузить некоторые фотографии профиля.',
|
||||||
|
icon: 'image_not_supported',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
profile.value = {
|
||||||
|
...fetchedProfile,
|
||||||
|
team: profileTeam,
|
||||||
|
role_name: profileRoleName,
|
||||||
|
projects: profileProjects,
|
||||||
|
carousel_photos: carouselPhotos
|
||||||
|
};
|
||||||
|
|
||||||
|
await loadActivity(fetchedProfile.repository_url);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки деталей профиля:', error);
|
console.error('Ошибка загрузки деталей профиля:', error);
|
||||||
Notify.create({
|
Notify.create({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'Не удалось загрузить информацию о профиле.',
|
message: error.message || 'Не удалось загрузить информацию о профиле.',
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
});
|
});
|
||||||
profile.value = null;
|
profile.value = null;
|
||||||
|
fillActivityWithZeros();
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -462,113 +517,125 @@ async function fetchProfileDetails(id) {
|
|||||||
|
|
||||||
function goToTeamDetail(teamId) {
|
function goToTeamDetail(teamId) {
|
||||||
console.log(`Переход на страницу команды: ${teamId}`);
|
console.log(`Переход на страницу команды: ${teamId}`);
|
||||||
// router.push({ name: 'team-detail', params: { id: teamId } });
|
router.push({ name: 'team-detail', params: { id: teamId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToProjectDetail(projectId) {
|
function goToProjectDetail(projectId) {
|
||||||
console.log(`Переход на страницу проекта: ${projectId}`);
|
console.log(`Переход на страницу проекта: ${projectId}`);
|
||||||
// router.push({ name: 'project-detail', params: { id: projectId } });
|
router.push({ name: 'project-detail', params: { id: projectId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchProfileDetails(profileId.value);
|
await fetchProfileDetails(profileId.value);
|
||||||
await loadActivity(); // Загрузка активности при монтировании компонента
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(profileId, async (newId) => {
|
watch(profileId, async (newId) => {
|
||||||
if (newId) {
|
if (newId) {
|
||||||
await fetchProfileDetails(newId);
|
await fetchProfileDetails(newId);
|
||||||
await loadActivity(); // Обновление активности при изменении ID профиля
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
blobUrls.value.forEach(url => URL.revokeObjectURL(url));
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.member-avatar-fix img {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
/* Остальные стили без изменений */
|
||||||
|
.activity-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.activity-card {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.activity-card .activity-square {
|
||||||
|
border-radius: 4px !important;
|
||||||
|
}
|
||||||
|
.bg-violet-strong {
|
||||||
|
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
color: #3e2465;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
.team-logo {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.violet-card {
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #ede9fe;
|
||||||
|
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.18), 0 2px 8px rgba(124, 58, 237, 0.12);
|
||||||
|
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.violet-card:hover {
|
||||||
|
box-shadow: 0 14px 40px rgba(124, 58, 237, 0.30), 0 6px 16px rgba(124, 58, 237, 0.20);
|
||||||
|
transform: translateY(-6px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.activity-card {
|
||||||
|
max-width: 920px;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.activity-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.activity-week {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.activity-square {
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 3px rgba(124, 58, 237, 0.3);
|
||||||
|
cursor: default;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
.months-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-left: 40px;
|
||||||
|
margin-bottom: 24px !important;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.month-label {
|
||||||
|
width: auto;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.weekdays-column {
|
||||||
|
margin-top: -15px;
|
||||||
|
}
|
||||||
.profile-detail-page {
|
.profile-detail-page {
|
||||||
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
|
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-name-card {
|
.profile-name-card {
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
background: #ede9fe;
|
background: #ede9fe;
|
||||||
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
|
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.violet-card {
|
.violet-card {
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
background: #ede9fe;
|
background: #ede9fe;
|
||||||
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
|
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-card {
|
|
||||||
/* No specific width/height here, handled by Quasar's carousel and grid system */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optional: Adjust for smaller screens if needed */
|
|
||||||
@media (max-width: 991px) { /* Breakpoint for md in Quasar grid */
|
|
||||||
.carousel-card {
|
|
||||||
margin-bottom: 24px; /* Добавить отступ, когда колонки становятся в ряд */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для активности (перенесены из HomePage) */
|
|
||||||
.activity-grid-row {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weekdays-column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-around;
|
|
||||||
height: calc(14px * 7 + 12px); /* dayHeight * 7 + (2px margin * 6 days) */
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #a287ff #ede9fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-grid::-webkit-scrollbar {
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-grid::-webkit-scrollbar-track {
|
|
||||||
background: #ede9fe;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-grid::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #a287ff;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 2px solid #ede9fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-week {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-square {
|
|
||||||
border-radius: 3px;
|
|
||||||
margin: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.months-row {
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: 24px !important; /* Увеличил отступ, чтобы месяцы не налезали на сетку */
|
|
||||||
}
|
|
||||||
|
|
||||||
.month-label {
|
|
||||||
position: absolute;
|
|
||||||
top: -20px; /* Поднимаем метки месяцев над сеткой */
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@ -1,14 +1,29 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import LoginPage from "../pages/LoginPage.vue"
|
import LoginPage from "../pages/LoginPage.vue";
|
||||||
import HomePage from "../pages/HomePage.vue"
|
import HomePage from "../pages/HomePage.vue";
|
||||||
import AdminPage from "../pages/AdminPage.vue"
|
import AdminPage from "../pages/AdminPage.vue";
|
||||||
import ContestDetailPage from "@/pages/ContestDetailPage.vue"
|
import ContestDetailPage from "@/pages/ContestDetailPage.vue";
|
||||||
import ProfileDetailPage from "@/pages/UserDetailPage.vue";
|
import ProfileDetailPage from "@/pages/UserDetailPage.vue";
|
||||||
|
import TeamDetailPage from "@/pages/TeamDetailPage.vue";
|
||||||
|
import ProjectDetailPage from "@/pages/ProjectDetailPage.vue"; // <-- Добавляем импорт ProjectDetailPage
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', component: HomePage },
|
{
|
||||||
{ path: '/login', component: LoginPage },
|
path: '/',
|
||||||
{ path: '/admin', component: AdminPage },
|
name: 'home', // Добавим имя для главной страницы
|
||||||
|
component: HomePage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login', // Добавим имя для страницы логина
|
||||||
|
component: LoginPage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
name: 'admin', // Добавим имя для страницы админа
|
||||||
|
component: AdminPage,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true } // Страница требует аутентификации и прав админа
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/contests/:id',
|
path: '/contests/:id',
|
||||||
name: 'contest-detail',
|
name: 'contest-detail',
|
||||||
@ -17,30 +32,61 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: '/profile/:id',
|
path: '/profile/:id',
|
||||||
name: 'profile-detail',
|
name: 'profile-detail',
|
||||||
component: ProfileDetailPage
|
component: ProfileDetailPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/teams/:id',
|
||||||
|
name: 'team-detail', // Даем маршруту имя
|
||||||
|
component: TeamDetailPage,
|
||||||
|
|
||||||
|
},
|
||||||
|
{ // <-- Новый маршрут для страницы проекта
|
||||||
|
path: '/projects/:id',
|
||||||
|
name: 'project-detail', // Даем маршруту имя
|
||||||
|
component: ProjectDetailPage,
|
||||||
|
|
||||||
}
|
}
|
||||||
]
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes
|
routes
|
||||||
})
|
});
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
const isAuthenticated = !!localStorage.getItem('access_token')
|
const isAuthenticated = !!localStorage.getItem('access_token');
|
||||||
const userId = localStorage.getItem('user_id')
|
const userId = localStorage.getItem('user_id'); // userId из localStorage всегда будет строкой
|
||||||
|
|
||||||
if (to.path === '/login' && isAuthenticated) {
|
const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
|
||||||
next('/')
|
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin);
|
||||||
} else if (to.path === '/admin') {
|
|
||||||
if (isAuthenticated && userId === '1') {
|
|
||||||
next()
|
|
||||||
} else {
|
|
||||||
next('/')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
// Если пользователь уже аутентифицирован и пытается перейти на страницу логина
|
||||||
|
if (to.name === 'login' && isAuthenticated) {
|
||||||
|
// Заменяем текущую запись в истории, чтобы нельзя было вернуться назад на логин
|
||||||
|
next({ name: 'home', replace: true });
|
||||||
|
}
|
||||||
|
// Если маршрут требует аутентификации
|
||||||
|
else if (requiresAuth && !isAuthenticated) {
|
||||||
|
// Перенаправляем на страницу логина.
|
||||||
|
// Передаем fullPath в query для возможного редиректа после успешного логина.
|
||||||
|
// Используем replace: true, чтобы пользователь не мог нажать "назад" и попасть на защищенную страницу.
|
||||||
|
next({ name: 'login', query: { redirectFrom: to.fullPath }, replace: true });
|
||||||
|
}
|
||||||
|
// Если маршрут требует прав администратора
|
||||||
|
else if (requiresAdmin) {
|
||||||
|
// Проверяем, аутентифицирован ли пользователь И является ли он админом
|
||||||
|
if (isAuthenticated && userId === '1') { // Предполагаем, что admin имеет userId '1'
|
||||||
|
next(); // Разрешаем доступ
|
||||||
|
} else {
|
||||||
|
// Если не админ или не аутентифицирован, перенаправляем на главную
|
||||||
|
// и заменяем текущую запись в истории
|
||||||
|
next({ name: 'home', replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Для всех остальных случаев (общедоступные страницы или аутентифицированные пользователи на разрешенных страницах)
|
||||||
|
else {
|
||||||
|
next(); // Просто продолжаем навигацию
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Loading…
x
Reference in New Issue
Block a user