сделал шаблон для участников

This commit is contained in:
Мельников Данил 2025-06-03 16:05:17 +05:00
parent 21bc00ee33
commit 7e6320aa9e
8 changed files with 682 additions and 71 deletions

View File

@ -0,0 +1,27 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const changeUserPassword = async (userId, newPasswordData) => {
try {
const token = localStorage.getItem("access_token");
const response = await axios.patch(
`${CONFIG.BASE_URL}/users/${userId}/password`,
newPasswordData,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
return response.data;
} catch (error) {
if (error.response?.status === 400) {
throw new Error(error.response.data.detail);
} else if (error.response?.status === 403) {
throw new Error("Доступ запрещён (403)");
}
throw new Error(error.message);
}
};
export default changeUserPassword;

View File

@ -1,18 +0,0 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const fetchUsers = async () => {
try {
const response = await axios.get(`${CONFIG.BASE_URL}/users`, {
withCredentials: true,
});
return response.data;
} catch (error) {
if (error.response?.status === 401) {
throw new Error("Нет доступа к пользователям (401)");
}
throw new Error(error.message);
}
};
export default fetchUsers;

View File

@ -0,0 +1,19 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const registerUser = async (userData) => {
try {
const response = await axios.post(
`${CONFIG.BASE_URL}/users/register`,
userData
);
return response.data;
} catch (error) {
if (error.response?.status === 400) {
throw new Error(error.response.data.detail);
}
throw new Error(error.message);
}
};
export default registerUser;

View File

@ -143,6 +143,7 @@
<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.logo" label="Логотип" dense clearable class="q-mt-sm" /> <q-input v-model="dialogData.logo" label="Логотип" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.git_url" label="Git URL" dense clearable class="q-mt-sm" /> <q-input v-model="dialogData.git_url" label="Git URL" dense clearable class="q-mt-sm" />
<q-toggle v-model="dialogData.is_active" label="Активна" dense class="q-mt-sm" />
</template> </template>
<template v-else-if="dialogType === 'profiles'"> <template v-else-if="dialogType === 'profiles'">
@ -469,6 +470,9 @@
import { ref, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { Notify, useQuasar } from 'quasar' import { Notify, useQuasar } from 'quasar'
import registerUser from '@/api/users/registerUser.js'
import changePassword from '@/api/users/changeUserPassword.js'
import fetchTeams from '@/api/teams/getTeams.js' import fetchTeams from '@/api/teams/getTeams.js'
import updateTeam from '@/api/teams/updateTeam.js' import updateTeam from '@/api/teams/updateTeam.js'
import deleteTeamById from '@/api/teams/deleteTeam.js' import deleteTeamById from '@/api/teams/deleteTeam.js'
@ -489,8 +493,6 @@ import updateContest from '@/api/contests/updateContest.js'
import deleteContestById from '@/api/contests/deleteContest.js' import deleteContestById from '@/api/contests/deleteContest.js'
import createContest from '@/api/contests/createContest.js' import createContest from '@/api/contests/createContest.js'
import CONFIG from '@/core/config.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'
@ -510,8 +512,9 @@ import getProjectFiles from '@/api/projects/project_files/getProjectFiles.js'
import uploadProjectFile from '@/api/projects/project_files/uploadProjectFile.js' import uploadProjectFile from '@/api/projects/project_files/uploadProjectFile.js'
import deleteProjectFile from '@/api/projects/project_files/deleteProjectFile.js' import deleteProjectFile from '@/api/projects/project_files/deleteProjectFile.js'
import downloadProjectFile from '@/api/projects/project_files/downloadProjectFile.js' import downloadProjectFile from '@/api/projects/project_files/downloadProjectFile.js'
import router from "@/router/index.js";
import router from "@/router/index.js";
import CONFIG from '@/core/config.js'
const $q = useQuasar() const $q = useQuasar()
@ -537,6 +540,7 @@ const teamColumns = [
{ name: 'description', label: 'Описание', field: 'description', sortable: true }, { name: 'description', label: 'Описание', field: 'description', sortable: true },
{ name: 'logo', label: 'Логотип', field: 'logo', 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 },
] ]
const projects = ref([]) const projects = ref([])
@ -960,7 +964,7 @@ function openEdit(type, row) {
dialogData.value = JSON.parse(JSON.stringify(row)) dialogData.value = JSON.parse(JSON.stringify(row))
} else { } else {
if (type === 'teams') { if (type === 'teams') {
dialogData.value = { title: '', description: '', logo: '', git_url: '' } dialogData.value = { title: '', description: '', logo: '', git_url: '', is_active: '' }
} else if (type === 'projects') { } else if (type === 'projects') {
dialogData.value = { title: '', description: '', repository_url: '' } dialogData.value = { title: '', description: '', repository_url: '' }
projectFiles.value = []; projectFiles.value = [];

View File

@ -80,7 +80,7 @@
<div v-if="contest.files && contest.files.length > 0" class="flex justify-center q-mb-xl"> <div v-if="contest.files && contest.files.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 contest.files" :key="file.id" clickable v-ripple :href="file.url" target="_blank">
<q-item-section avatar> <q-item-section avatar>
@ -100,45 +100,48 @@
</div> </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 flex-wrap q-gutter-md q-mb-xl">
<div 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="contest-name-card"> <q-card-section class="text-h6 text-center text-indigo-10 q-pa-md">
Участники конкурса
</q-card-section>
</q-card>
</div>
<q-card
v-for="member in contestParticipants"
:key="member.id"
class="member-card violet-card"
bordered
style="width: 180px;"
v-ripple
>
<q-card-section class="q-pa-md flex flex-center">
<q-avatar size="64px" class="shadow-6">
<img :src="member.avatar" :alt="member.name"/>
</q-avatar>
</q-card-section>
<q-card-section class="q-pt-none">
<div class="text-subtitle1 text-center text-indigo-11">{{ member.name }}</div>
<div class="text-caption text-center text-indigo-9">{{ member.role }}</div>
</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" class="flex justify-center q-mb-xl"> <div v-if="contest.repository_url || contest.project_description || (contest.project_files && contest.project_files.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 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="text-body1 text-indigo-9">
<div v-if="contest.project_description" class="q-mb-md text-indigo-9">
<div class="text-subtitle1 text-indigo-10 q-mb-sm">Описание проекта</div>
<div class="text-body1">{{ contest.project_description }}</div>
</div>
<div v-if="contest.repository_url" class="q-mb-md text-indigo-9">
<div class="text-subtitle1 text-indigo-10 q-mb-sm">Репозиторий проекта</div>
<q-icon name="code" size="sm" class="q-mr-xs" /> <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="contest.repository_url" target="_blank" class="text-indigo-9" style="text-decoration: none; word-break: break-all;">
{{ contest.repository_url }} {{ contest.repository_url }}
</a> </a>
</div> </div>
<div v-if="contest.project_files && contest.project_files.length > 0" class="q-mt-md">
<div class="text-subtitle1 text-indigo-10 q-mb-sm">Файлы проекта</div>
<q-list separator bordered class="rounded-borders">
<q-item v-for="file in contest.project_files" :key="file.id" clickable v-ripple :href="file.url" target="_blank">
<q-item-section avatar>
<q-icon name="folder_open" color="indigo-8" />
</q-item-section>
<q-item-section>
<q-item-label>{{ file.name }}</q-item-label>
<q-item-label caption>{{ file.description }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="download" color="indigo-6" />
</q-item-section>
</q-item>
</q-list>
</div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
@ -197,6 +200,11 @@ async function fetchContestDetails(id) {
description: 'Ежегодный хакатон для стартапов, где команды соревнуются в создании инновационных решений за короткий период времени. Фокус на Web3 и AI технологиях.', description: 'Ежегодный хакатон для стартапов, где команды соревнуются в создании инновационных решений за короткий период времени. Фокус на Web3 и AI технологиях.',
web_url: 'https://example.com/hackathon2024', web_url: 'https://example.com/hackathon2024',
repository_url: 'https://github.com/my-team/hackathon2024-solution', 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', photo: 'https://cdn.quasar.dev/img/parallax2.jpg',
results: '1 место в категории "Лучшее AI-решение"', results: '1 место в категории "Лучшее AI-решение"',
is_win: true, is_win: true,
@ -208,7 +216,7 @@ async function fetchContestDetails(id) {
{ id: 5, url: 'https://cdn.quasar.dev/img/parallax2.jpg' }, { id: 5, url: 'https://cdn.quasar.dev/img/parallax2.jpg' },
], ],
files: [ files: [
{ id: 1, name: 'Презентация проекта.pdf', description: 'Финальная презентация', url: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' }, { 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' }, { id: 2, name: 'Код проекта.zip', description: 'Исходный код', url: 'https://www.learningcontainer.com/wp-content/uploads/2020/07/Example-Zip-File.zip' },
], ],
project_id: 1, project_id: 1,
@ -220,13 +228,15 @@ async function fetchContestDetails(id) {
description: 'Масштабное соревнование по спортивному программированию, где участники решают алгоритмические задачи. Отличная возможность проверить свои навыки.', description: 'Масштабное соревнование по спортивному программированию, где участники решают алгоритмические задачи. Отличная возможность проверить свои навыки.',
web_url: 'https://codefest.org', web_url: 'https://codefest.org',
repository_url: 'https://gitlab.com/awesome-devs/codefest-challenge', 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', photo: 'https://cdn.quasar.dev/img/material.png',
results: null, results: null,
is_win: false, is_win: false,
carousel_photos: [ 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: 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' }, { id: 11, url: 'https://cdn.quasar.dev/img/chicken-salad.jpg' },
], // Добавлены фото для второго конкурса ],
files: [], files: [],
project_id: 2, project_id: 2,
status_id: 2, status_id: 2,

View File

@ -1,14 +1,12 @@
<template> <template>
<q-page class="home-page bg-violet-strong q-pa-md"> <q-page class="home-page bg-violet-strong q-pa-md">
<!-- Логотип по центру -->
<div class="flex justify-center q-mb-md"> <div class="flex justify-center q-mb-md">
<q-avatar size="140px" class="team-logo shadow-12"> <q-avatar size="140px" class="team-logo shadow-12">
<img :src="teamLogo" alt="Логотип команды"/> <img :src="teamLogo" alt="Логотип команды"/>
</q-avatar> </q-avatar>
</div> </div>
<!-- Название команды -->
<div class="flex justify-center q-mb-xl"> <div class="flex justify-center q-mb-xl">
<q-card class="team-name-card"> <q-card class="team-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">
@ -17,15 +15,15 @@
</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="member in members" v-for="member in members"
:key="member.id" :key="member.id"
class="member-card violet-card" class="member-card violet-card"
bordered bordered
style="width: 180px;" style="width: 180px; cursor: pointer;"
v-ripple v-ripple
@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 size="64px" class="shadow-6">
@ -39,7 +37,6 @@
</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="contest in contests"
@ -55,17 +52,14 @@
</q-card> </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>
<!-- Активность команды в стиле Gitea -->
<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: 40px; margin-bottom: 4px; user-select: none;">
<div <div
v-for="(monthLabel, idx) in monthLabels" v-for="(monthLabel, idx) in monthLabels"
@ -78,7 +72,6 @@
</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: 40px; user-select: none; justify-content: space-around;">
<div <div
v-for="(day, idx) in weekDays" v-for="(day, idx) in weekDays"
@ -90,7 +83,6 @@
</div> </div>
</div> </div>
<!-- Сетка дней -->
<div class="activity-grid" :style="{ height: (dayHeight * 7) + 'px' }"> <div class="activity-grid" :style="{ height: (dayHeight * 7) + 'px' }">
<div <div
v-for="week in activityGrid" v-for="week in activityGrid"
@ -109,7 +101,6 @@
</div> </div>
</div> </div>
<!-- Масштаб активности -->
<div class="scale-labels row justify-end q-mt-sm text-caption text-indigo-9" style="user-select: none;"> <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 class="q-mr-md" style="cursor: pointer;" @click="decreaseScale">Меньше</span>
<span style="cursor: pointer;" @click="increaseScale">Больше</span> <span style="cursor: pointer;" @click="increaseScale">Больше</span>
@ -118,7 +109,6 @@
</q-card> </q-card>
</div> </div>
<!-- Кнопка выхода / авторизации -->
<q-btn <q-btn
:icon="isAuthenticated ? 'logout' : 'login'" :icon="isAuthenticated ? 'logout' : 'login'"
class="fixed-bottom-right q-ma-md" class="fixed-bottom-right q-ma-md"
@ -136,7 +126,6 @@ import {ref, computed, onMounted} 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 {parseStringPromise} from 'xml2js'
import CONFIG from "@/core/config.js"; import CONFIG from "@/core/config.js";
defineExpose({directives: {ripple: Ripple}}) defineExpose({directives: {ripple: Ripple}})
@ -167,7 +156,7 @@ const teamName = ref('Digital Dream Team')
// --- Участники --- // --- Участники ---
const members = ref([ const members = ref([
{id: 1, name: 'Иван Иванов', role: 'Team Lead', avatar: 'https://randomuser.me/api/portraits/men/32.jpg'}, {id: 1, name: 'Иван Иванов', role: 'Team Lead', avatar: '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, name: 'Мария Петрова', role: 'Frontend', avatar: 'https://randomuser.me/api/portraits/women/44.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'}, {id: 3, name: 'Алексей Смирнов', role: 'Backend', avatar: 'https://randomuser.me/api/portraits/men/65.jpg'},
{id: 4, name: 'Анна Кузнецова', role: 'Designer', avatar: 'https://randomuser.me/api/portraits/women/56.jpg'}, {id: 4, name: 'Анна Кузнецова', role: 'Designer', avatar: 'https://randomuser.me/api/portraits/women/56.jpg'},

View File

@ -0,0 +1,574 @@
<template>
<q-page class="profile-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 v-if="loading" class="flex flex-center q-pt-xl" style="min-height: 50vh;">
<q-spinner-dots color="white" size="5em" />
</div>
<div v-else-if="profile" class="q-gutter-y-xl">
<div class="flex justify-center q-mb-xl">
<q-card class="profile-name-card">
<q-card-section class="text-h4 text-center text-indigo-10 q-pa-md">
{{ profile.first_name }} {{ profile.last_name }} {{ profile.patronymic }}
</q-card-section>
</q-card>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col-xs-12 col-md-6" v-if="profile.carousel_photos && profile.carousel_photos.length > 0">
<q-card class="violet-card carousel-card" style="width: 100%;">
<q-card-section>
<div class="text-h6 text-indigo-10 q-mb-md">Фотографии профиля</div>
<q-carousel
v-model="slide"
transition-prev="slide-right"
transition-next="slide-left"
swipeable
animated
control-color="indigo-10"
navigation
arrows
autoplay
infinite
class="rounded-borders"
height="300px"
>
<q-carousel-slide v-for="(photo, index) in profile.carousel_photos" :key="index" :name="index + 1" :img-src="photo.url" />
</q-carousel>
</q-card-section>
</q-card>
</div>
<div class="col-xs-12 col-md-6">
<div class="row q-col-gutter-md">
<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">
<q-icon name="cake" size="xs" class="q-mr-xs" />
День рождения: <span class="text-weight-bold">{{ profile.birth_date }}</span>
</div>
<div class="text-body1 text-indigo-9">
<q-icon name="person" size="xs" class="q-mr-xs" />
Пол: <span class="text-weight-bold">{{ profile.gender }}</span>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12">
<q-card class="info-card violet-card">
<q-card-section>
<div class="text-h6 text-indigo-10 q-mb-md">Контакты</div>
<div class="text-body1 text-indigo-9 q-mb-sm">
<q-icon name="email" size="xs" class="q-mr-xs" />
Email: <a :href="'mailto:' + profile.email" class="text-indigo-9" style="text-decoration: none;">{{ profile.email }}</a>
</div>
<div v-if="profile.phone_number" class="text-body1 text-indigo-9">
<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>
</div>
</q-card-section>
</q-card>
</div>
</div>
</div>
</div>
<div class="row q-col-gutter-md justify-center q-mt-xl">
<div class="col-xs-12 col-md-6" v-if="profile.teams && profile.teams.length > 0">
<q-card class="violet-card">
<q-card-section>
<div class="text-h6 text-indigo-10 q-mb-md">Команды</div>
<q-list separator bordered class="rounded-borders">
<q-item v-for="team in profile.teams" :key="team.id" clickable v-ripple @click="goToTeamDetail(team.id)">
<q-item-section avatar>
<q-avatar size="md">
<img :src="team.logo || 'https://cdn.quasar.dev/logo-v2/svg/logo.svg'" alt="Логотип команды"/>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ team.name }}</q-item-label>
<q-item-label caption>{{ team.role }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" color="indigo-6" />
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
<div class="col-xs-12 col-md-6" v-if="profile.projects && profile.projects.length > 0">
<q-card class="violet-card">
<q-card-section>
<div class="text-h6 text-indigo-10 q-mb-md">Участие в проектах</div>
<q-list separator bordered class="rounded-borders">
<q-item v-for="project in profile.projects" :key="project.id" clickable v-ripple @click="goToProjectDetail(project.id)">
<q-item-section avatar>
<q-avatar size="md">
<img :src="project.photo || 'https://cdn.quasar.dev/logo-v2/svg/logo.svg'" alt="Логотип проекта"/>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ project.title }}</q-item-label>
<q-item-label caption>{{ project.role }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" color="indigo-6" />
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</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="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: 40px; 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: 40px; 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>
</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 } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Ripple, Notify } from 'quasar';
import axios from 'axios';
import CONFIG from "@/core/config.js";
defineExpose({ directives: { ripple: Ripple } });
const route = useRoute();
const router = useRouter();
const profile = ref(null);
const loading = ref(true);
const profileId = computed(() => route.params.id);
const slide = ref(1);
// --- Активность ---
const activityData = ref([]);
const dayHeight = 14;
const squareSize = ref(12);
// Подписи месяцев (с июня 2024 по май 2025, чтобы соответствовать текущему году)
const monthLabels = ['июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.', 'янв.', 'февр.', 'март', 'апр.', 'май'];
// Дни недели (пн, ср, пт, как в Gitea)
const weekDays = ['пн', 'ср', 'пт'];
// Вычисляемая сетка активности (группировка по неделям)
const activityGrid = computed(() => {
const weeks = [];
let week = [];
const firstDay = new Date();
firstDay.setDate(firstDay.getDate() - 364); // Год назад от текущей даты
const dayOfWeek = firstDay.getDay();
const offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Смещение для выравнивания по понедельнику
// Добавляем пустые ячейки в начало
for (let i = 0; i < offset; i++) {
week.push({ date: '', count: 0 });
}
for (let i = 0; i < 365; i++) {
const date = new Date(firstDay);
date.setDate(firstDay.getDate() + i);
const dateStr = date.toISOString().slice(0, 10);
const dayData = activityData.value.find(d => d.date === dateStr) || { date: dateStr, count: 0 };
week.push(dayData);
if (week.length === 7 || i === 364) {
weeks.push(week);
week = [];
}
}
return weeks;
});
// Цвета активности (как в Gitea)
function getActivityColor(count) {
if (count === 0) return '#ede9fe'; // Светлый фон карточек
if (count <= 2) return '#d8cff9'; // Светлый сиреневый
if (count <= 4) return '#a287ff'; // Светлый фиолетовый
if (count <= 6) return '#7c3aed'; // Яркий фиолетовый
return '#4f046f'; // Темно-фиолетовый
}
// Позиционирование подписей месяцев
function getMonthMargin(idx) {
const daysInMonth = [30, 31, 31, 30, 31, 30, 31, 31, 28, 31, 30, 31]; // Дни в месяцах с июня 2024
const daysBeforeMonth = daysInMonth.slice(0, idx).reduce((sum, days) => sum + days, 0);
const weekIndex = Math.floor(daysBeforeMonth / 7);
return weekIndex * (squareSize.value + 4); // 4 = margin (2px + 2px)
}
// Загрузка активности из API
const usernameForActivity = 'archibald'; // Фиксированный username для активности
async function loadActivity() {
try {
const response = await axios.get(`${CONFIG.BASE_URL}/rss/${usernameForActivity}/`);
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});
}
} catch (error) {
console.error('Ошибка загрузки активности:', error);
Notify.create({
type: 'negative',
message: 'Ошибка загрузки данных активности',
icon: 'error',
});
// Заполняем пустыми данными в случае ошибки
const lastDate = new Date();
const startDate = new Date(lastDate);
startDate.setDate(lastDate.getDate() - 364);
activityData.value = [];
for (let i = 0; i < 365; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
const dateStr = date.toISOString().slice(0, 10);
activityData.value.push({date: dateStr, count: 0});
}
}
}
// Масштабирование
function increaseScale() {
if (squareSize.value < 24) squareSize.value += 2;
}
function decreaseScale() {
if (squareSize.value > 8) squareSize.value -= 2;
}
async function fetchProfileDetails(id) {
loading.value = true;
try {
const mockProfiles = [
{
id: 1,
first_name: 'Иван',
last_name: 'Иванов',
patronymic: 'Иванович',
birth_date: '10.01.1990',
gender: 'Мужской',
email: 'ivan.ivanov@example.com',
phone_number: '+79011234567',
main_photo: 'https://randomuser.me/api/portraits/men/32.jpg',
carousel_photos: [
{ id: 1, url: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
{ id: 2, url: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
{ id: 3, url: 'https://images.unsplash.com/photo-1522075469751-3a6694fa2a86?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
],
teams: [
{ id: 101, name: 'Digital Dream Team', role: 'Team Lead', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
{ id: 102, name: 'Growth Hackers', role: 'Mentor', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 201, title: 'CRM System Dev', role: 'Backend Lead', photo: 'https://cdn.quasar.dev/img/material.png' },
{ id: 202, title: 'Mobile App Redesign', role: 'Consultant', photo: 'https://cdn.quasar.dev/img/donuts.png' },
]
},
{
id: 2,
first_name: 'Мария',
last_name: 'Петрова',
patronymic: 'Александровна',
birth_date: '22.03.1993',
gender: 'Женский',
email: 'maria.petrova@example.com',
phone_number: '+79209876543',
main_photo: 'https://randomuser.me/api/portraits/women/44.jpg',
carousel_photos: [
{ id: 4, url: 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
{ id: 5, url: 'https://images.unsplash.com/photo-1580489944761-15ad79929219?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
],
teams: [
{ id: 101, name: 'Digital Dream Team', role: 'Frontend', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 204, title: 'Marketing Website', role: 'Lead Frontend', photo: 'https://cdn.quasar.dev/img/parallax2.jpg' },
]
},
{
id: 3,
first_name: 'Алексей',
last_name: 'Смирнов',
patronymic: 'Сергеевич',
birth_date: '05.09.1988',
gender: 'Мужской',
email: 'alex.smirnov@example.com',
phone_number: null,
main_photo: 'https://randomuser.me/api/portraits/men/65.jpg',
carousel_photos: [],
teams: [
{ id: 101, name: 'Digital Dream Team', role: 'Backend', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 201, title: 'CRM System Dev', role: 'Backend Dev', photo: 'https://cdn.quasar.dev/img/material.png' },
]
},
{
id: 4,
first_name: 'Анна',
last_name: 'Кузнецова',
patronymic: 'Викторовна',
birth_date: '30.11.1996',
gender: 'Женский',
email: 'anna.kuznetsova@example.com',
phone_number: '+79151112233',
main_photo: 'https://randomuser.me/api/portraits/women/56.jpg',
carousel_photos: [
{ id: 6, url: 'https://images.unsplash.com/photo-1522075469751-3a6694fa2a86?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
],
teams: [
{ id: 104, name: 'Digital Dream Team', role: 'Designer', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 205, title: 'Brand Identity', role: 'Lead Designer', photo: 'https://cdn.quasar.dev/img/parallax1.jpg' },
]
},
{
id: 5,
first_name: 'Дмитрий',
last_name: 'Орлов',
patronymic: 'Васильевич',
birth_date: '18.07.1991',
gender: 'Мужской',
email: 'dmitry.orlov@example.com',
phone_number: '+79304445566',
main_photo: 'https://randomuser.me/api/portraits/men/78.jpg',
carousel_photos: [],
teams: [
{ id: 105, name: 'Digital Dream Team', role: 'QA Engineer', logo: 'https://cdn.quasar.dev/logo-v2/svg/logo.svg' },
],
projects: [
{ id: 206, title: 'Testing Automation Framework', role: 'QA Lead', photo: 'https://cdn.quasar.dev/img/quasar.jpg' },
]
}
];
profile.value = mockProfiles.find(p => p.id === parseInt(id));
if (!profile.value) {
Notify.create({
type: 'negative',
message: 'Профиль с таким ID не найден.',
icon: 'warning',
});
}
} catch (error) {
console.error('Ошибка загрузки деталей профиля:', error);
Notify.create({
type: 'negative',
message: 'Не удалось загрузить информацию о профиле.',
icon: 'error',
});
profile.value = null;
} finally {
loading.value = false;
}
}
function goToTeamDetail(teamId) {
console.log(`Переход на страницу команды: ${teamId}`);
// router.push({ name: 'team-detail', params: { id: teamId } });
}
function goToProjectDetail(projectId) {
console.log(`Переход на страницу проекта: ${projectId}`);
// router.push({ name: 'project-detail', params: { id: projectId } });
}
onMounted(async () => {
await fetchProfileDetails(profileId.value);
await loadActivity(); // Загрузка активности при монтировании компонента
});
watch(profileId, async (newId) => {
if (newId) {
await fetchProfileDetails(newId);
await loadActivity(); // Обновление активности при изменении ID профиля
}
});
</script>
<style scoped>
.profile-detail-page {
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
min-height: 100vh;
}
.profile-name-card {
border-radius: 22px;
background: #ede9fe;
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
padding: 10px 20px;
}
.violet-card {
border-radius: 22px;
background: #ede9fe;
box-shadow: 0 6px 32px rgba(124, 58, 237, 0.13), 0 1.5px 6px rgba(124, 58, 237, 0.10);
}
.carousel-card {
/* No specific width/height here, handled by Quasar's carousel and grid system */
}
/* Optional: Adjust for smaller screens if needed */
@media (max-width: 991px) { /* Breakpoint for md in Quasar grid */
.carousel-card {
margin-bottom: 24px; /* Добавить отступ, когда колонки становятся в ряд */
}
}
/* Стили для активности (перенесены из HomePage) */
.activity-grid-row {
align-items: flex-start;
}
.weekdays-column {
display: flex;
flex-direction: column;
justify-content: space-around;
height: calc(14px * 7 + 12px); /* dayHeight * 7 + (2px margin * 6 days) */
}
.activity-grid {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 8px;
scrollbar-width: thin;
scrollbar-color: #a287ff #ede9fe;
}
.activity-grid::-webkit-scrollbar {
height: 8px;
}
.activity-grid::-webkit-scrollbar-track {
background: #ede9fe;
border-radius: 10px;
}
.activity-grid::-webkit-scrollbar-thumb {
background-color: #a287ff;
border-radius: 10px;
border: 2px solid #ede9fe;
}
.activity-week {
display: flex;
flex-direction: column;
flex-shrink: 0;
margin-right: 4px;
}
.activity-square {
border-radius: 3px;
margin: 2px 0;
}
.months-row {
position: relative;
margin-bottom: 24px !important; /* Увеличил отступ, чтобы месяцы не налезали на сетку */
}
.month-label {
position: absolute;
top: -20px; /* Поднимаем метки месяцев над сеткой */
white-space: nowrap;
}
</style>

View File

@ -2,16 +2,22 @@ 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";
const routes = [ const routes = [
{ path: '/', component: HomePage }, { path: '/', component: HomePage },
{ path: '/login', component: LoginPage }, { path: '/login', component: LoginPage },
{ path: '/admin', component: AdminPage }, { path: '/admin', component: AdminPage },
{ {
path: '/contests/:id', // Динамический маршрут, :id будет ID конкурса path: '/contests/:id',
name: 'contest-detail', // Имя маршрута для удобства навигации (например, router.push({ name: 'contest-detail', params: { id: 123 } })) name: 'contest-detail',
component: ContestDetailPage // Компонент, который будет отображаться component: ContestDetailPage
},
{
path: '/profile/:id',
name: 'profile-detail',
component: ProfileDetailPage
} }
] ]
@ -37,4 +43,4 @@ router.beforeEach((to, from, next) => {
} }
}) })
export default router export default router