440 lines
14 KiB
Vue
440 lines
14 KiB
Vue
<template>
|
||
<q-page class="home-page bg-violet-strong q-pa-md">
|
||
|
||
<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 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 class="flex justify-center flex-wrap q-gutter-md q-mb-xl">
|
||
<q-card
|
||
v-for="contest in contests"
|
||
:key="contest.id"
|
||
class="contest-card violet-card"
|
||
bordered
|
||
style="width: 220px; cursor: pointer;" v-ripple
|
||
@click="router.push({ name: 'contest-detail', params: { id: contest.id } })" >
|
||
<q-card-section class="q-pa-md">
|
||
<div class="text-h6">{{ contest.title }}</div>
|
||
<div class="text-subtitle2 text-indigo-8">{{ contest.description }}</div>
|
||
</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: 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>
|
||
|
||
<q-btn
|
||
:icon="isAuthenticated ? 'logout' : 'login'"
|
||
class="fixed-bottom-right q-ma-md"
|
||
size="20px"
|
||
color="indigo-10"
|
||
round
|
||
@click="handleAuthAction"
|
||
/>
|
||
|
||
</q-page>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { Ripple, Notify } from 'quasar'
|
||
import axios from 'axios'
|
||
import CONFIG from '@/core/config.js'
|
||
import fetchTeams from '@/api/teams/getTeams.js'
|
||
import fetchProfiles from '@/api/profiles/getProfiles.js'
|
||
import fetchContests from '@/api/contests/getContests.js'
|
||
|
||
defineExpose({ directives: { ripple: Ripple } })
|
||
|
||
const router = useRouter()
|
||
|
||
const isAuthenticated = ref(!!localStorage.getItem('access_token'))
|
||
|
||
const handleAuthAction = () => {
|
||
if (isAuthenticated.value) {
|
||
localStorage.removeItem('access_token')
|
||
localStorage.removeItem('user_id')
|
||
isAuthenticated.value = false
|
||
|
||
Notify.create({
|
||
type: 'positive',
|
||
message: 'Выход успешно осуществлен',
|
||
icon: 'check_circle',
|
||
})
|
||
} else {
|
||
router.push('/login')
|
||
}
|
||
}
|
||
|
||
// --- Данные команды ---
|
||
const teamLogo = ref('')
|
||
const teamName = ref('')
|
||
|
||
// --- Участники ---
|
||
const members = ref([])
|
||
|
||
// --- Конкурсы ---
|
||
const contests = ref([])
|
||
|
||
// --- Активность ---
|
||
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)
|
||
}
|
||
|
||
// Загрузка данных команды
|
||
async function loadTeamData() {
|
||
try {
|
||
const teams = await fetchTeams();
|
||
const activeTeam = teams.find(team => team.is_active === true);
|
||
if (activeTeam) {
|
||
teamName.value = activeTeam.name;
|
||
teamLogo.value = activeTeam.logo;
|
||
} else {
|
||
Notify.create({
|
||
type: 'warning',
|
||
message: 'Активная команда не найдена',
|
||
icon: 'warning',
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки данных команды:', error);
|
||
Notify.create({
|
||
type: 'negative',
|
||
message: 'Ошибка загрузки данных команды',
|
||
icon: 'error',
|
||
});
|
||
}
|
||
}
|
||
|
||
// Загрузка участников
|
||
async function loadMembers() {
|
||
try {
|
||
const profiles = await fetchProfiles();
|
||
members.value = profiles.map(profile => ({
|
||
id: profile.id,
|
||
name: profile.name || 'Без имени',
|
||
role: profile.role || 'Участник',
|
||
avatar: profile.avatar || 'https://randomuser.me/api/portraits/men/1.jpg',
|
||
}));
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки участников:', error);
|
||
Notify.create({
|
||
type: 'negative',
|
||
message: error.message || 'Ошибка загрузки участников',
|
||
icon: 'error',
|
||
});
|
||
}
|
||
}
|
||
|
||
// Загрузка конкурсов
|
||
async function loadContests() {
|
||
try {
|
||
const fetchedContests = await fetchContests();
|
||
contests.value = fetchedContests.map(contest => ({
|
||
id: contest.id,
|
||
title: contest.title || 'Без названия',
|
||
description: contest.description || 'Описание отсутствует',
|
||
}));
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки конкурсов:', error);
|
||
Notify.create({
|
||
type: 'negative',
|
||
message: error.message || 'Ошибка загрузки конкурсов',
|
||
icon: 'error',
|
||
});
|
||
}
|
||
}
|
||
|
||
// Загрузка активности
|
||
const username = 'archibald';
|
||
|
||
async function loadActivity() {
|
||
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 });
|
||
}
|
||
} 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 });
|
||
}
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([loadTeamData(), loadMembers(), loadContests(), loadActivity()]);
|
||
});
|
||
|
||
// Масштабирование
|
||
function increaseScale() {
|
||
if (squareSize.value < 24) squareSize.value += 2;
|
||
}
|
||
|
||
function decreaseScale() {
|
||
if (squareSize.value > 8) squareSize.value -= 2;
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.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; /* Синхронизация с .weekdays-column */
|
||
margin-bottom: 24px !important;
|
||
position: relative;
|
||
}
|
||
.month-label {
|
||
width: auto;
|
||
text-align: left;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
position: absolute; /* Для точного позиционирования */
|
||
}
|
||
</style> |