тестовое отображение сетки активности
This commit is contained in:
parent
c812273f1b
commit
24ce580bd2
17
API/app/contollers/rss_router.py
Normal file
17
API/app/contollers/rss_router.py
Normal 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)
|
||||
6
API/app/domain/entities/activity_item.py
Normal file
6
API/app/domain/entities/activity_item.py
Normal file
@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ActivityItem(BaseModel):
|
||||
date: str
|
||||
count: int
|
||||
39
API/app/infrastructure/rss_service.py
Normal file
39
API/app/infrastructure/rss_service.py
Normal 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
|
||||
@ -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
31
WEB/package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user