тестовое отображение сетки активности

This commit is contained in:
Андрей Дувакин 2025-05-31 18:25:02 +05:00
parent c812273f1b
commit 24ce580bd2
7 changed files with 258 additions and 123 deletions

View File

@ -0,0 +1,17 @@
from fastapi import APIRouter
from app.domain.entities.activity_item import ActivityItem
from app.infrastructure.rss_service import RSSService
router = APIRouter()
@router.get(
"/{username}",
response_model=list[ActivityItem],
summary="Get the user's RSS activity",
description="Returns an array with the date and number of activities in the last 30 days"
)
def get_user_rss(username: str):
rss_service = RSSService()
return rss_service.get_rss_by_username(username)

View File

@ -0,0 +1,6 @@
from pydantic import BaseModel
class ActivityItem(BaseModel):
date: str
count: int

View File

@ -0,0 +1,39 @@
from fastapi import APIRouter, HTTPException
import requests
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
class RSSService:
@staticmethod
def get_rss_by_username(username: str):
url = f"https://git.numerum.team/{username}.rss"
response = requests.get(url)
if response.status_code != 200:
raise HTTPException(status_code=400, detail="Не удалось загрузить RSS")
root = ET.fromstring(response.text)
items = root.findall(".//item")
activity_map = {}
for item in items:
pub_date_elem = item.find("pubDate")
if pub_date_elem is not None:
pub_date_str = pub_date_elem.text.strip()
pub_date = datetime.strptime(pub_date_str, "%a, %d %b %Y %H:%M:%S %z")
date_str = pub_date.strftime("%Y-%m-%d")
activity_map[date_str] = activity_map.get(date_str, 0) + 1
today = datetime.now(pub_date.tzinfo)
activity_days = 365
result = []
for i in range(activity_days - 1, -1, -1):
day = today - timedelta(days=i)
date_str = day.strftime("%Y-%m-%d")
count = activity_map.get(date_str, 0)
result.append({"date": date_str, "count": count})
return result

View File

@ -8,6 +8,7 @@ from app.contollers.project_files_router import router as project_files_router
from app.contollers.project_members_router import router as project_members_router
from app.contollers.projects_router import router as projects_router
from app.contollers.register_router import router as register_router
from app.contollers.rss_router import router as rss_router
from app.contollers.teams_router import router as team_router
from app.contollers.users_router import router as users_router
from app.settings import settings
@ -31,6 +32,7 @@ def start_app():
api_app.include_router(project_members_router, prefix=f'{settings.PREFIX}/project_members', tags=['project_members'])
api_app.include_router(projects_router, prefix=f'{settings.PREFIX}/projects', tags=['projects'])
api_app.include_router(register_router, prefix=f'{settings.PREFIX}/register', tags=['register'])
api_app.include_router(rss_router, prefix=f'{settings.PREFIX}/rss', tags=['rss_router'])
api_app.include_router(team_router, prefix=f'{settings.PREFIX}/teams', tags=['teams'])
api_app.include_router(users_router, prefix=f'{settings.PREFIX}/users', tags=['users'])

31
WEB/package-lock.json generated
View File

@ -12,7 +12,8 @@
"axios": "^1.9.0",
"quasar": "^2.18.1",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"xml2js": "^0.6.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.2",
@ -1849,6 +1850,12 @@
"node": ">=14.0.0"
}
},
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2038,6 +2045,28 @@
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
}
}
}

View File

@ -13,7 +13,8 @@
"axios": "^1.9.0",
"quasar": "^2.18.1",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"xml2js": "^0.6.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.2",

View File

@ -60,43 +60,60 @@
<q-separator class="q-my-lg" color="indigo-4" style="width: 80%; margin: 0 auto;" />
<div class="q-mt-md"></div>
<!-- Активность и история коммитов -->
<!-- Активность команды в стиле Gitea -->
<div class="flex justify-center">
<q-card class="activity-card violet-card q-mb-xl" style="max-width: 920px; width: 100%;">
<q-card class="activity-card violet-card" style="max-width: 940px; width: 100%;">
<q-card-section class="q-pa-md">
<div class="text-h6 text-indigo-10 q-mb-md">Активность команды и история коммитов</div>
<div class="text-h6 text-indigo-10 q-mb-md">Активность команды за последний год</div>
<div class="activity-container row no-wrap items-start q-mb-md">
<div class="dates-column column q-pr-md" style="min-width: 50px;">
<div
v-for="item in activityData"
:key="item.date"
class="activity-date text-caption text-indigo-9"
style="height: 16px; line-height: 16px;"
>
{{ item.date.slice(5) }}
</div>
</div>
<div class="activity-grid row wrap" style="flex: 1; gap: 4px;">
<div
v-for="item in activityData"
:key="item.date"
class="activity-square"
:title="`Дата: ${item.date}, активность: ${item.count}`"
:style="{ backgroundColor: getActivityColor(item.count) }"
></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="commits-list q-pa-sm" style="max-height: 150px; overflow-y: auto;">
<div
v-for="commit in commits"
:key="commit.id"
class="commit-item text-caption text-indigo-9 q-mb-xs"
>
<strong>{{ commit.date }}:</strong> {{ commit.message }}
<div class="activity-grid-row row no-wrap">
<!-- Дни недели слева -->
<div class="weekdays-column column q-pr-sm" style="width: 40px; user-select: none;">
<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>
@ -116,9 +133,12 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import {ref, computed, onMounted} from 'vue'
import { useRouter} from 'vue-router'
import { Ripple, Notify } from 'quasar'
import axios from "axios";
import { parseStringPromise } from 'xml2js'
import CONFIG from "@/core/config.js";
defineExpose({ directives: { ripple: Ripple } })
@ -164,30 +184,112 @@ const contests = ref([
])
// --- Активность ---
const activityData = ref([])
const today = new Date()
const activityDays = 30
function pad(num) { return num < 10 ? '0' + num : num }
const activityData = ref([]);
const dayHeight = 14;
const squareSize = ref(14);
for (let i = activityDays - 1; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const count = Math.floor(Math.random() * 6)
activityData.value.push({ date: d.toISOString().slice(0, 10), count })
// Подписи месяцев (с июня 2024 по май 2025, чтобы соответствовать текущему году)
const monthLabels = ['июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.', 'янв.', 'февр.', 'март', 'апр.', 'май'];
// Дни недели (пн, ср, пт, как в Gitea)
const weekDays = ['пн', 'ср', 'пт'];
// Вычисляемая сетка активности (группировка по неделям)
const activityGrid = computed(() => {
const weeks = [];
let week = [];
let firstDay = new Date(activityData.value[0]?.date || new Date());
firstDay.setDate(firstDay.getDate() - 364); // Начинаем с 365 дней назад
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 '#ebedf0';
if (count <= 2) return '#c6e48b';
if (count <= 4) return '#7bc96f';
if (count <= 6) return '#239a3b';
return '#196127';
}
// --- Коммиты ---
const commits = ref([
{ id: 1, message: 'Добавлен график активности', date: '2025-05-28' },
{ id: 2, message: 'Исправлена верстка карточек', date: '2025-05-27' },
{ id: 3, message: 'Обновлены данные команды', date: '2025-05-26' },
{ id: 4, message: 'Оптимизирован компонент участников', date: '2025-05-25' },
])
// Позиционирование подписей месяцев
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)
}
function getActivityColor(count) {
if (count === 0) return '#ddd'
const opacity = 0.15 + (count / 5) * 0.6
return `rgba(124, 58, 237, ${opacity.toFixed(2)})`
// Загрузка активности из API
const username = 'andrei';
async function loadActivity() {
try {
const response = await axios.get(`${CONFIG.BASE_URL}/rss/${username}/`);
activityData.value = response.data;
// Дополняем до 365 дней
const lastDate = new Date();
const startDate = new Date(lastDate);
startDate.setDate(lastDate.getDate() - 364);
const existingDates = new Set(activityData.value.map(d => d.date));
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 dayData = response.data.find(d => d.date === dateStr) || { date: dateStr, count: 0 };
activityData.value.push(dayData);
}
// Сортировка не требуется, так как мы формируем массив в правильном порядке
} 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(() => {
loadActivity();
});
// Масштабирование
function increaseScale() {
if (squareSize.value < 24) squareSize.value += 2;
}
function decreaseScale() {
if (squareSize.value > 8) squareSize.value -= 2;
}
</script>
@ -218,25 +320,19 @@ function getActivityColor(count) {
.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);
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);
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);
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;
}
@ -249,81 +345,26 @@ function getActivityColor(count) {
justify-content: center;
}
.horizontal-scroll {
display: flex;
overflow-x: auto;
padding-bottom: 6px;
-webkit-overflow-scrolling: touch;
}
.horizontal-scroll::-webkit-scrollbar {
height: 6px;
}
.horizontal-scroll::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.4);
border-radius: 3px;
}
.activity-card {
max-width: 920px;
border-radius: 20px;
padding: 16px;
}
.activity-container {
/* align-items: center; */
}
.dates-column {
display: flex;
flex-direction: column;
justify-content: flex-start;
color: #5e35b1;
font-weight: 600;
user-select: none;
font-size: 11px;
}
.activity-date {
margin-bottom: 4px;
text-align: right;
white-space: nowrap;
}
.activity-grid {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
user-select: none;
scrollbar-width: thin;
scrollbar-color: rgba(124, 58, 237, 0.4) transparent;
flex-direction: row;
}
.activity-grid::-webkit-scrollbar {
height: 10px;
}
.activity-grid::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.4);
border-radius: 5px;
.activity-week {
display: flex;
flex-direction: column;
}
.activity-square {
width: 16px;
height: 16px;
border-radius: 4px;
margin-right: 2px;
box-shadow: 0 0 3px rgba(124, 58, 237, 0.3);
cursor: default;
transition: background-color 0.3s ease;
}
.commits-list {
background: #f5f3ff;
border-radius: 12px;
padding: 8px 12px;
font-family: 'Courier New', Courier, monospace;
color: #4b0082;
box-shadow: inset 0 0 6px #c7b3f7;
}
.commit-item strong {
color: #7c3aed;
}
</style>