сделал админку конкурсов, сделал шаблон для конкурсов и роутер с главной страницы, подправил выход у админки

This commit is contained in:
Мельников Данил 2025-06-02 06:58:27 +05:00
parent 203f6763d6
commit c7533fe87c
26 changed files with 425 additions and 110 deletions

View File

@ -3,14 +3,14 @@ from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import Contest
from app.domain.models import ContestStatus
class ContestStatusesRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, contest_status_id: int) -> Optional[Contest]:
stmt = select(Contest).filter_by(id=contest_status_id)
async def get_by_id(self, contest_status_id: int) -> Optional[ContestStatus]:
stmt = select(ContestStatus).filter_by(id=contest_status_id)
result = await self.db.execute(stmt)
return result.scalars().first()

View File

@ -69,20 +69,3 @@ async def delete_contest(
return await contests_service.delete(contest_id, user)
@router.get(
'/project/{project_id}/',
response_model=Optional[ContestEntity],
summary='Get project by contest ID',
description='Retrieve project data by contest ID',
)
async def get_project_by_contest_id(
project_id: int,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
contests_service = ContestsService(db)
contest = await contests_service.get_by_project(project_id)
return contest

View File

@ -2,7 +2,7 @@ from typing import Optional
from pydantic import BaseModel
class ContestEntity(BaseModel):
id: int
id: Optional[int] = None
title: str
description: Optional[str] = None
web_url: str

View File

@ -15,7 +15,7 @@ class ContestsService:
def __init__(self, db: AsyncSession):
self.contests_repository = ContestsRepository(db)
self.projects_repository = ProjectsRepository(db)
self.statuses_repository = ContestStatusesRepository(db)
self.contest_statuses_repository = ContestStatusesRepository(db)
self.users_repository = UsersRepository(db)
async def get_all_contests(self) -> list[ContestEntity]:
@ -33,7 +33,7 @@ class ContestsService:
detail='The project with this ID was not found',
)
status_contest = await self.statuses_repository.get_by_id(contest.status_id)
status_contest = await self.contest_statuses_repository.get_by_id(contest.status_id)
if status_contest is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -75,7 +75,7 @@ class ContestsService:
detail='The project with this ID was not found',
)
status_contest = await self.statuses_repository.get_by_id(contest.role_id)
status_contest = await self.contest_statuses_repository.get_by_id(contest.status_id)
if status_contest is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -114,17 +114,6 @@ class ContestsService:
return self.model_to_entity(result)
async def get_by_project(self, project_id: int) -> Optional[ContestEntity]:
project = await self.projects_repository.get_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail='Project not found')
contest_model = await self.contests_repository.get_by_id(project.contest_id)
if not contest_model:
raise HTTPException(status_code=404, detail='Contest not found')
return self.model_to_entity(contest_model)
@staticmethod
def model_to_entity(contest_model: Contest) -> ContestEntity:
return ContestEntity(

View File

@ -1,13 +1,13 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const createProfile = async (profile) => {
console.log(profile)
const createContest = async (contest) => {
console.log(contest)
try {
const token = localStorage.getItem('access_token') // или другой способ получения токена
const response = await axios.post(
`${CONFIG.BASE_URL}/profiles`,
profile,
`${CONFIG.BASE_URL}/contests/`,
contest,
{
withCredentials: true,
headers: {
@ -21,4 +21,4 @@ const createProfile = async (profile) => {
}
}
export default createProfile
export default createContest

View File

@ -1,11 +1,11 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const deleteProfile = async (profileId) => {
const deleteContest = async (contestId) => {
try {
const token = localStorage.getItem('access_token') // получение токена
const response = await axios.delete(
`${CONFIG.BASE_URL}/profiles/${profileId}`,
`${CONFIG.BASE_URL}/contests/${contestId}`,
{
withCredentials: true,
headers: {
@ -19,4 +19,4 @@ const deleteProfile = async (profileId) => {
}
}
export default deleteProfile
export default deleteContest

View File

@ -3,7 +3,7 @@ import CONFIG from "@/core/config.js";
const fetchContests = async () => {
try {
const response = await axios.get(`${CONFIG.BASE_URL}/contests`, {
const response = await axios.get(`${CONFIG.BASE_URL}/contests/`, {
withCredentials: true,
});
return response.data;

View File

@ -1,25 +0,0 @@
import axios from 'axios'
import CONFIG from '../../core/config.js'
const fetchProfile = async () => {
try {
const token = localStorage.getItem("access_token");
const response = await axios.get(`${CONFIG.BASE_URL}/profiles`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
return response.data;
} catch (error) {
if (error.response?.status === 401) {
throw new Error("Нет доступа к пользователям");
} else if (error.response?.status === 403) {
throw new Error("Доступ запрещён");
}
throw new Error(error.message);
}
};
export default fetchProfile;

View File

@ -1,7 +1,7 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const updateProfile = async (profile) => {
const updateContest = async (profile) => {
try {
const token = localStorage.getItem('access_token')
@ -11,7 +11,7 @@ const updateProfile = async (profile) => {
console.log('Отправляем на сервер:', profileData)
const response = await axios.put(
`${CONFIG.BASE_URL}/profiles/${id}`,
`${CONFIG.BASE_URL}/contests/${id}/`,
profileData,
{
withCredentials: true,
@ -28,4 +28,4 @@ const updateProfile = async (profile) => {
}
}
export default updateProfile
export default updateContest

View File

@ -2,11 +2,10 @@ import axios from 'axios'
import CONFIG from '@/core/config.js'
const createProfile = async (profile) => {
console.log(profile)
try {
const token = localStorage.getItem('access_token') // или другой способ получения токена
const response = await axios.post(
`${CONFIG.BASE_URL}/profiles`,
`${CONFIG.BASE_URL}/profiles/`,
profile,
{
withCredentials: true,

View File

@ -5,7 +5,7 @@ const deleteProfile = async (profileId) => {
try {
const token = localStorage.getItem('access_token') // получение токена
const response = await axios.delete(
`${CONFIG.BASE_URL}/profiles/${profileId}`,
`${CONFIG.BASE_URL}/profiles/${profileId}/`,
{
withCredentials: true,
headers: {

View File

@ -5,7 +5,7 @@ import CONFIG from '../../core/config.js'
const fetchProfile = async () => {
try {
const token = localStorage.getItem("access_token");
const response = await axios.get(`${CONFIG.BASE_URL}/profiles`, {
const response = await axios.get(`${CONFIG.BASE_URL}/profiles/`, {
headers: {
Authorization: `Bearer ${token}`,
},

View File

@ -8,10 +8,8 @@ const updateProfile = async (profile) => {
// Убираем id из тела запроса, он идет в URL
const { id, ...profileData } = profile
console.log('Отправляем на сервер:', profileData)
const response = await axios.put(
`${CONFIG.BASE_URL}/profiles/${id}`,
`${CONFIG.BASE_URL}/profiles/${id}/`,
profileData,
{
withCredentials: true,
@ -21,7 +19,6 @@ const updateProfile = async (profile) => {
}
)
console.log('Ответ от сервера:', response.data)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || error.message)

View File

@ -2,11 +2,10 @@ import axios from 'axios'
import CONFIG from '@/core/config.js'
const createProject = async (project) => {
console.log(project)
try {
const token = localStorage.getItem('access_token') // или другой способ получения токена
const response = await axios.post(
`${CONFIG.BASE_URL}/projects`,
`${CONFIG.BASE_URL}/projects/`,
project,
{
withCredentials: true,

View File

@ -5,7 +5,7 @@ const deleteProject = async (projectId) => {
try {
const token = localStorage.getItem('access_token') // получение токена
const response = await axios.delete(
`${CONFIG.BASE_URL}/projects/${projectId}`,
`${CONFIG.BASE_URL}/projects/${projectId}/`,
{
withCredentials: true,
headers: {

View File

@ -6,7 +6,7 @@ const fetchProjects = async () => {
const token = localStorage.getItem("access_token");
const response = await axios.get(`${CONFIG.BASE_URL}/projects`, {
headers: {
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${token}/`,
},
});

View File

@ -10,7 +10,7 @@ const updateProject = async (project) => {
const response = await axios.put(
`${CONFIG.BASE_URL}/projects/${id}`,
`${CONFIG.BASE_URL}/projects/${id}/`,
projectData,
{
withCredentials: true,

View File

@ -5,7 +5,7 @@ const createTeam = async (team) => {
try {
const token = localStorage.getItem('access_token') // или другой способ
const response = await axios.post(
`${CONFIG.BASE_URL}/teams`,
`${CONFIG.BASE_URL}/teams/`,
team,
{
withCredentials: true,

View File

@ -5,7 +5,7 @@ const deleteTeam = async (teamId) => {
try {
const token = localStorage.getItem('access_token') // получение токена
const response = await axios.delete(
`${CONFIG.BASE_URL}/teams/${teamId}`,
`${CONFIG.BASE_URL}/teams/${teamId}/`,
{
withCredentials: true,
headers: {

View File

@ -4,7 +4,7 @@ import CONFIG from "@/core/config.js";
const fetchTeams = async () => {
try {
const token = localStorage.getItem("access_token");
const response = await axios.get(`${CONFIG.BASE_URL}/teams`, {
const response = await axios.get(`${CONFIG.BASE_URL}/teams/`, {
headers: {
Authorization: `Bearer ${token}`,
},

View File

@ -10,7 +10,7 @@ const updateTeam = async (team) => {
const response = await axios.put(
`${CONFIG.BASE_URL}/teams/${id}`,
`${CONFIG.BASE_URL}/teams/${id}/`,
teamData,
{
withCredentials: true,

View File

@ -30,7 +30,15 @@ import {
QTooltip,
QBanner,
QSlideTransition,
Ripple
Ripple,
QToggle,
QList,
QSpinnerDots,
QCarouselSlide,
QCarousel,
QItemSection,
QItemLabel,
QItem,
} from 'quasar'
@ -48,7 +56,9 @@ app.use(Quasar, {
QLayout, QPageContainer, QPage,
QTabs, QTab, QTabPanels, QTabPanel, QHeader,QTable,
QSeparator, QCardActions, QDialog, QIcon, QSpace,
QAvatar, QTooltip, QBanner, QSlideTransition
QAvatar, QTooltip, QBanner, QSlideTransition, QToggle,
QList, QSpinnerDots, QCarouselSlide, QCarousel,
QItemSection, QItemLabel, QItem
},
directives: {
Ripple

View File

@ -175,9 +175,12 @@
<template v-else-if="dialogType === 'contests'">
<q-input v-model="dialogData.title" label="Название конкурса" dense autofocus clearable />
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm" />
<q-input v-model="dialogData.start_date" label="Дата начала" dense clearable class="q-mt-sm" type="date" />
<q-input v-model="dialogData.end_date" label="Дата окончания" dense clearable class="q-mt-sm" type="date" />
<!-- Другие поля конкурса -->
<q-input v-model="dialogData.web_url" label="URL сайта" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.photo" label="Фото" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.results" label="Результаты" dense clearable class="q-mt-sm" />
<q-toggle v-model="dialogData.is_win" label="Победа (Да/Нет)" dense class="q-mt-sm" />
<q-input v-model="dialogData.project_id" label="Проект" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.status_id" label="Статус" dense clearable class="q-mt-sm" />
</template>
<template v-else>
@ -187,28 +190,28 @@
<q-card-actions align="right">
<q-btn
v-if="dialogType === 'teams'"
v-if="dialogType === 'teams' && dialogData.id"
flat
label="Удалить"
color="negative"
@click="deleteItem"
/>
<q-btn
v-if="dialogType === 'profiles'"
v-if="dialogType === 'profiles' && dialogData.id"
flat
label="Удалить"
color="negative"
@click="deleteItem"
/>
<q-btn
v-if="dialogType === 'projects'"
v-if="dialogType === 'projects' && dialogData.id"
flat
label="Удалить"
color="negative"
@click="deleteItem"
/>
<q-btn
v-if="dialogType === 'contests'"
v-if="dialogType === 'contests' && dialogData.id"
flat
label="Удалить"
color="negative"
@ -226,6 +229,16 @@
</q-card-actions>
</q-card>
</q-dialog>
<!-- Кнопка выхода / авторизации -->
<q-btn
:icon="'logout'"
class="fixed-bottom-right q-ma-md"
size="20px"
color="indigo-10"
round
@click="handleAuthAction"
/>
</q-layout>
</template>
@ -248,10 +261,12 @@ import updateProfile from '@/api/profiles/updateProfile.js'
import deleteProfileById from '@/api/profiles/deleteProfile.js'
import createProfile from '@/api/profiles/createProfile.js'
// import fetchContests from '@/api/contests/getContests.js'
// import updateContest from '@/api/contests/updateContest.js'
// import deleteContestById from '@/api/contests/deleteContest.js'
// import createContest from '@/api/contests/createContest.js'
import fetchContests from '@/api/contests/getContests.js'
import updateContest from '@/api/contests/updateContest.js'
import deleteContestById from '@/api/contests/deleteContest.js'
import createContest from '@/api/contests/createContest.js'
import {Notify} from "quasar";
import router from "@/router/index.js";
// Текущая вкладка 'teams' или 'projects'
const tab = ref('profiles')
@ -295,11 +310,14 @@ const loadingContests = ref(false)
const contestColumns = [
{ name: 'title', label: 'Название проекта', field: 'title', sortable: true },
{ name: 'description', label: 'Описание', field: 'description', sortable: true },
{ name: 'repository_url', label: 'Репозиторий', field: 'repository_url', sortable: true },
{ name: 'web_url', label: 'Репозиторий', field: 'repository_url', sortable: true },
{ name: 'photo', label: 'Фото', field: 'photo', sortable: true },
{ name: 'results', label: 'Результаты', field: 'results', sortable: true },
{ name: 'is_win', label: 'Победа', field: 'is_win', sortable: true },
{ name: 'project_id', label: 'Проект', field: 'project_id', sortable: true },
{ name: 'status_id', label: 'Статус', field: 'status_id', sortable: true },
]
// Общие состояния для диалогов
const dialogVisible = ref(false)
const dialogData = ref({})
@ -356,6 +374,15 @@ async function saveChanges() {
const newProfile = await createProfile(dialogData.value)
profiles.value.push(newProfile)
}
} else if (dialogType.value === 'contests') {
if (dialogData.value.id) {
await updateContest(dialogData.value)
const idx = contests.value.findIndex(c => c.id === dialogData.value.id)
if (idx !== -1) contests.value[idx] = JSON.parse(JSON.stringify(dialogData.value))
} else {
const newContest = await createContest(dialogData.value)
profiles.value.push(newContest)
}
}
closeDialog()
} catch (error) {
@ -394,6 +421,16 @@ async function loadData(name) {
} finally {
loadingProfiles.value = false
}
} else if (name === 'contests') {
loadingContests.value = true
try {
contests.value = await fetchContests() || []
} catch (error) {
contests.value = []
console.error(error.message)
} finally {
loadingContests.value = false
}
}
}
@ -409,6 +446,9 @@ async function deleteItem() {
} else if (dialogType.value === 'profiles') {
await deleteProfileById(dialogData.value.id)
profiles.value = profiles.value.filter(p => p.id !== dialogData.value.id)
} else if (dialogType.value === 'contests') {
await deleteContestById(dialogData.value.id)
contests.value = contests.value.filter(c => c.id !== dialogData.value.id)
}
closeDialog()
} catch (error) {
@ -417,6 +457,7 @@ async function deleteItem() {
}
function createHandler() {
dialogData.value = {};
openEdit(tab.value, null)
}
@ -435,6 +476,24 @@ onMounted(() => {
watch(tab, (newTab) => {
loadData(newTab)
})
const handleAuthAction = () => {
const isAuthenticated = ref(!!localStorage.getItem('access_token'))
if (isAuthenticated.value) {
localStorage.removeItem('access_token')
localStorage.removeItem('user_id')
isAuthenticated.value = false
Notify.create({
type: 'positive',
message: 'Выход успешно осуществлен',
icon: 'check_circle',
})
router.push('/')
}
}
</script>
<style scoped>

View File

@ -0,0 +1,299 @@
<template>
<q-page class="contest-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="contest" class="q-gutter-y-xl">
<div class="flex justify-center q-mb-md">
<q-avatar size="140px" class="contest-logo shadow-12">
<img :src="contest.photo || 'https://cdn.quasar.dev/logo-v2/svg/logo.svg'" alt="Логотип конкурса"/>
</q-avatar>
</div>
<div class="flex justify-center q-mb-xl">
<q-card class="contest-name-card">
<q-card-section class="text-h4 text-center text-indigo-10 q-pa-md">
{{ contest.title }}
</q-card-section>
</q-card>
</div>
<div class="flex justify-center q-mb-xl">
<q-card class="description-card violet-card" style="max-width: 940px; width: 100%;">
<q-card-section>
<div class="text-h6 text-indigo-10 q-mb-sm">Описание</div>
<div class="text-body1 text-indigo-9 q-mb-md">{{ contest.description }}</div>
<div v-if="contest.web_url" class="q-mt-md text-indigo-9">
<q-icon name="link" size="sm" class="q-mr-xs" />
<a :href="contest.web_url" target="_blank" class="text-indigo-9" style="text-decoration: none;">
Перейти на сайт конкурса
</a>
</div>
<div v-if="contest.results" class="q-mt-md text-indigo-9">
<q-icon name="emoji_events" size="sm" class="q-mr-xs" />
Результаты: <span class="text-weight-bold">{{ contest.results }}</span>
</div>
<div v-if="contest.is_win" class="q-mt-md text-indigo-9">
<q-icon name="celebration" size="sm" class="q-mr-xs" />
<span class="text-weight-bold text-positive">Победа!</span>
</div>
</q-card-section>
</q-card>
</div>
<div v-if="contest.carousel_photos && contest.carousel_photos.length > 0" class="flex justify-center q-mb-xl">
<q-card class="violet-card" style="max-width: 940px; 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 contest.carousel_photos" :key="index" :name="index + 1" :img-src="photo.url" />
</q-carousel>
</q-card-section>
</q-card>
</div>
<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-section>
<div class="text-h6 text-indigo-10 q-mb-md">Файлы</div>
<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-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>
</q-card-section>
</q-card>
</div>
<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%;">
<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>
<q-separator class="q-my-lg" color="indigo-4" style="width: 80%; margin: 0 auto;"/>
<div class="q-mt-md"></div>
<div v-if="contest.repository_url" class="flex justify-center q-mb-xl">
<q-card class="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-body1 text-indigo-9">
<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;">
{{ contest.repository_url }}
</a>
</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 contest = ref(null);
const loading = ref(true);
const contestId = computed(() => route.params.id);
// --- Карусель фото ---
const slide = ref(1);
// --- Участники конкурса (моковые данные) ---
const contestParticipants = ref([
{ id: 1, name: 'Иван Иванов', role: 'Team Lead', avatar: 'https://randomuser.me/api/portraits/men/32.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' },
// Добавьте больше участников или динамически загружайте их
]);
// --- Загрузка данных конкурса ---
async function fetchContestDetails(id) {
loading.value = true;
try {
// В реальном приложении здесь будет API-запрос:
// const response = await axios.get(`${CONFIG.BASE_URL}/contests/${id}`);
// contest.value = response.data;
// Моковые данные для примера (замените на реальный fetch)
const mockContests = [
{
id: 1,
title: 'Hackathon 2024',
description: 'Ежегодный хакатон для стартапов, где команды соревнуются в создании инновационных решений за короткий период времени. Фокус на Web3 и AI технологиях.',
web_url: 'https://example.com/hackathon2024',
repository_url: 'https://github.com/my-team/hackathon2024-solution',
photo: 'https://cdn.quasar.dev/img/parallax2.jpg',
results: '1 место в категории "Лучшее AI-решение"',
is_win: true,
carousel_photos: [
{ id: 1, url: 'https://images.unsplash.com/photo-1668796319088-214d6a82d54b?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' },
{ id: 2, url: 'https://cdn.quasar.dev/img/quasar.jpg' },
{ id: 3, url: 'https://cdn.quasar.dev/img/parallax1.jpg' },
{ id: 4, url: 'https://cdn.quasar.dev/img/donuts.png' },
{ id: 5, url: 'https://cdn.quasar.dev/img/parallax2.jpg' },
],
files: [
{ 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' },
],
project_id: 1,
status_id: 1,
},
{
id: 2,
title: 'CodeFest',
description: 'Масштабное соревнование по спортивному программированию, где участники решают алгоритмические задачи. Отличная возможность проверить свои навыки.',
web_url: 'https://codefest.org',
repository_url: 'https://gitlab.com/awesome-devs/codefest-challenge',
photo: 'https://cdn.quasar.dev/img/material.png',
results: null,
is_win: false,
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: 11, url: 'https://cdn.quasar.dev/img/chicken-salad.jpg' },
], // Добавлены фото для второго конкурса
files: [],
project_id: 2,
status_id: 2,
},
// Добавьте другие моковые конкурсы по мере необходимости
];
contest.value = mockContests.find(c => c.id === parseInt(id));
if (!contest.value) {
Notify.create({
type: 'negative',
message: 'Конкурс с таким ID не найден.',
icon: 'warning',
});
}
} catch (error) {
console.error('Ошибка загрузки деталей конкурса:', error);
Notify.create({
type: 'negative',
message: 'Не удалось загрузить информацию о конкурсе.',
icon: 'error',
});
contest.value = null; // Сброс, если ошибка
} finally {
loading.value = false;
}
}
onMounted(async () => {
await fetchContestDetails(contestId.value);
});
watch(contestId, async (newId) => {
if (newId) {
await fetchContestDetails(newId);
}
});
</script>
<style scoped>
.contest-detail-page {
background: linear-gradient(135deg, #4f046f 0%, #7c3aed 100%);
min-height: 100vh;
}
.contest-logo {
border: 4px solid #ede9fe;
}
.contest-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);
}
.member-card {
transition: transform 0.2s ease-in-out;
}
.member-card:hover {
transform: translateY(-5px);
}
</style>

View File

@ -46,9 +46,8 @@
:key="contest.id"
class="contest-card violet-card"
bordered
style="width: 220px;"
v-ripple
>
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>
@ -241,7 +240,7 @@ function getMonthMargin(idx) {
}
// Загрузка активности из API
const username = 'Numerum';
const username = 'archibald';
async function loadActivity() {
try {

View File

@ -2,11 +2,17 @@ import { createRouter, createWebHistory } from 'vue-router'
import LoginPage from "../pages/LoginPage.vue"
import HomePage from "../pages/HomePage.vue"
import AdminPage from "../pages/AdminPage.vue"
import ContestDetailPage from "@/pages/ContestDetailPage.vue";
const routes = [
{ path: '/', component: HomePage },
{ path: '/login', component: LoginPage },
{ path: '/admin', component: AdminPage }
{ path: '/admin', component: AdminPage },
{
path: '/contests/:id', // Динамический маршрут, :id будет ID конкурса
name: 'contest-detail', // Имя маршрута для удобства навигации (например, router.push({ name: 'contest-detail', params: { id: 123 } }))
component: ContestDetailPage // Компонент, который будет отображаться
}
]
const router = createRouter({