сделал админку конкурсов, сделал шаблон для конкурсов и роутер с главной страницы, подправил выход у админки
This commit is contained in:
parent
203f6763d6
commit
c7533fe87c
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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}`,
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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}/`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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}`,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
299
WEB/src/pages/ContestDetailPage.vue
Normal file
299
WEB/src/pages/ContestDetailPage.vue
Normal 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>
|
||||
@ -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 {
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user