2025-06-09 21:45:25 +05:00

440 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>