feat: schedule of teachers (but one)
i think its many poop code and schedule currently now working properly
This commit is contained in:
96
src/pages/api/admin/teachers.ts
Normal file
96
src/pages/api/admin/teachers.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
||||
import { loadTeachers, saveTeachers, clearTeachersCache, TeachersData } from '@/shared/data/teachers-loader'
|
||||
import { parseTeachersList } from '@/app/parser/teachers-list'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import { PROXY_URL } from '@/shared/constants/urls'
|
||||
import contentTypeParser from 'content-type'
|
||||
|
||||
type ResponseData = ApiResponse<{
|
||||
teachers?: TeachersData
|
||||
parsed?: number
|
||||
}>
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
if (req.method === 'GET') {
|
||||
// Получение списка преподавателей (всегда свежие данные для админ-панели)
|
||||
clearTeachersCache()
|
||||
const teachers = loadTeachers(true)
|
||||
res.status(200).json({ teachers })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
// Парсинг и обновление списка преподавателей
|
||||
try {
|
||||
const url = `${PROXY_URL}/?mn=3`
|
||||
|
||||
// Добавляем таймаут 10 секунд для fetch запроса
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
const page = await fetch(url, { signal: controller.signal })
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
const content = await page.text()
|
||||
const contentType = page.headers.get('content-type')
|
||||
|
||||
if (page.status !== 200 || !contentType || contentTypeParser.parse(contentType).type !== 'text/html') {
|
||||
res.status(500).json({ error: `Failed to fetch teachers list: status ${page.status}` })
|
||||
return
|
||||
}
|
||||
|
||||
const dom = new JSDOM(content, { url })
|
||||
const document = dom.window.document
|
||||
|
||||
const teachersList = parseTeachersList(document)
|
||||
|
||||
// Закрываем JSDOM для освобождения памяти
|
||||
dom.window.close()
|
||||
|
||||
if (teachersList.length === 0) {
|
||||
res.status(500).json({ error: 'No teachers found on the page' })
|
||||
return
|
||||
}
|
||||
|
||||
// Преобразуем список в формат TeachersData
|
||||
// Используем parseId как id (строковое представление)
|
||||
const teachersData: TeachersData = {}
|
||||
for (const teacher of teachersList) {
|
||||
const id = String(teacher.parseId)
|
||||
teachersData[id] = {
|
||||
parseId: teacher.parseId,
|
||||
name: teacher.name
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем в БД
|
||||
saveTeachers(teachersData)
|
||||
|
||||
// Сохраняем timestamp последнего обновления
|
||||
const { setTeachersLastUpdateTime } = await import('@/shared/data/database')
|
||||
setTeachersLastUpdateTime(Date.now())
|
||||
|
||||
// Сбрасываем кеш и загружаем свежие данные из БД
|
||||
clearTeachersCache()
|
||||
const updatedTeachers = loadTeachers(true)
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
teachers: updatedTeachers,
|
||||
parsed: teachersList.length
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('Error parsing teachers list:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
res.status(500).json({ error: `Failed to parse teachers list: ${errorMessage}` })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default withAuth(handler, ['GET', 'POST'])
|
||||
@@ -235,10 +235,20 @@ export default function HomePage(props: HomePageProps) {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
|
||||
>
|
||||
<Link href="/teachers">
|
||||
<Button variant="secondary" className="gap-2">
|
||||
Преподаватели
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{showAddGroupButton && (
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.08}s` } as React.CSSProperties}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -252,13 +262,13 @@ export default function HomePage(props: HomePageProps) {
|
||||
)}
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.08 : 0.05)}s` } as React.CSSProperties}
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.11 : 0.08)}s` } as React.CSSProperties}
|
||||
>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.11 : 0.08)}s` } as React.CSSProperties}
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.14 : 0.11)}s` } as React.CSSProperties}
|
||||
>
|
||||
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" className="gap-2">
|
||||
|
||||
376
src/pages/teacher/[teacher].tsx
Normal file
376
src/pages/teacher/[teacher].tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import { Schedule } from '@/widgets/schedule'
|
||||
import { Day } from '@/shared/model/day'
|
||||
import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
|
||||
import { getTeacherSchedule, ScheduleResult, ScheduleTimeoutError } from '@/app/agregator/schedule'
|
||||
import { NextSerialized, nextDeserialized, nextSerialized } from '@/app/utils/date-serializer'
|
||||
import { NavBar } from '@/widgets/navbar'
|
||||
import { LastUpdateAt } from '@/entities/last-update-at'
|
||||
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
|
||||
import { loadSettings, AppSettings } from '@/shared/data/settings-loader'
|
||||
import { getTeacherByParseId } from '@/shared/data/database'
|
||||
import { SITE_URL } from '@/shared/constants/urls'
|
||||
import crypto from 'crypto'
|
||||
import React from 'react'
|
||||
import { getDayOfWeek } from '@/shared/utils'
|
||||
import Head from 'next/head'
|
||||
import { WeekInfo } from '@/app/parser/schedule'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shadcn/ui/card'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
type PageProps = {
|
||||
schedule?: Day[]
|
||||
teacher: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
parsedAt?: Date
|
||||
cacheAvailableFor: string[]
|
||||
groups: GroupsData
|
||||
currentWk?: number | null
|
||||
availableWeeks?: WeekInfo[] | null
|
||||
settings: AppSettings
|
||||
error?: {
|
||||
message: string
|
||||
isTimeout: boolean
|
||||
}
|
||||
isFromCache?: boolean
|
||||
cacheAge?: number // возраст кэша в минутах
|
||||
cacheInfo?: {
|
||||
size: number
|
||||
entries: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function TeacherPage(props: NextSerialized<PageProps>) {
|
||||
const { schedule, teacher, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings, error, isFromCache, cacheAge, cacheInfo } = nextDeserialized<PageProps>(props)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || error) return
|
||||
|
||||
// Используем 'auto' для нормальной работы обновления страницы
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = 'auto'
|
||||
}
|
||||
|
||||
let attempts = 0
|
||||
const MAX_ATTEMPTS = 50 // Максимум 5 секунд (50 * 100ms)
|
||||
|
||||
const interval = setInterval(() => {
|
||||
attempts++
|
||||
const today = getDayOfWeek(new Date())
|
||||
const todayBlock = document.getElementById(today)
|
||||
|
||||
if (todayBlock) {
|
||||
const GAP = 48
|
||||
const HEADER_HEIGHT = 64
|
||||
window.scrollTo({ top: todayBlock.offsetTop - GAP - HEADER_HEIGHT, behavior: 'smooth' })
|
||||
clearInterval(interval)
|
||||
} else if (attempts >= MAX_ATTEMPTS) {
|
||||
// Прекращаем попытки после максимального количества
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// Cleanup функция для очистки интервала при размонтировании
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [schedule, error])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{error ? `Ошибка — Расписание преподавателя ${teacher.name}` : `Преподаватель ${teacher.name} — Расписание занятий в Колледже Связи`}</title>
|
||||
<link rel="canonical" href={`${SITE_URL}/teacher/${teacher.id}`} />
|
||||
<meta name="description" content={error ? `Не удалось загрузить расписание преподавателя ${teacher.name}` : `Расписание занятий преподавателя ${teacher.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
|
||||
<meta property="og:title" content={error ? `Ошибка — Расписание преподавателя ${teacher.name}` : `Преподаватель ${teacher.name} — Расписание занятий в Колледже Связи`} />
|
||||
<meta property="og:description" content={error ? `Не удалось загрузить расписание преподавателя ${teacher.name}` : `Расписание занятий преподавателя ${teacher.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
|
||||
</Head>
|
||||
<NavBar cacheAvailableFor={cacheAvailableFor} groups={groups} isTeacherPage={true} />
|
||||
{error ? (
|
||||
<div className="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<Card className="stagger-card">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
<CardTitle>Не удалось загрузить расписание</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
{error.isTimeout
|
||||
? 'Превышено время ожидания ответа от сервера. Пожалуйста, попробуйте обновить страницу через несколько минут.'
|
||||
: 'Произошла ошибка при загрузке расписания. Пожалуйста, попробуйте обновить страницу позже.'}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{parsedAt && <LastUpdateAt date={parsedAt} />}
|
||||
{schedule && (
|
||||
<Schedule
|
||||
days={schedule}
|
||||
currentWk={currentWk ?? null}
|
||||
availableWeeks={availableWeeks ?? null}
|
||||
weekNavigationEnabled={settings.weekNavigationEnabled}
|
||||
isFromCache={isFromCache}
|
||||
cacheAge={cacheAge}
|
||||
cacheInfo={cacheInfo}
|
||||
hideTeacher={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const cachedTeacherSchedules = new Map<string, { lastFetched: Date, results: ScheduleResult }>()
|
||||
const maxCacheDurationInMS = 1000 * 60 * 15 // 15 минут для нормального использования кэша
|
||||
const fallbackCacheDurationInMS = 1000 * 60 * 60 * 24 // 24 часа для fallback кэша при ошибках парсинга
|
||||
const maxCacheSize = 50 // Максимальное количество записей в кэше (только текущие недели)
|
||||
|
||||
// Очистка старых записей из кэша
|
||||
function cleanupCache() {
|
||||
const now = Date.now()
|
||||
const entriesToDelete: string[] = []
|
||||
|
||||
// Находим устаревшие записи (используем fallback TTL для сохранения кэша при ошибках)
|
||||
for (const [key, value] of cachedTeacherSchedules.entries()) {
|
||||
if (now - value.lastFetched.getTime() >= fallbackCacheDurationInMS) {
|
||||
entriesToDelete.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем устаревшие записи
|
||||
entriesToDelete.forEach(key => cachedTeacherSchedules.delete(key))
|
||||
|
||||
// Если кэш все еще слишком большой, удаляем самые старые записи
|
||||
if (cachedTeacherSchedules.size > maxCacheSize) {
|
||||
const sortedEntries = Array.from(cachedTeacherSchedules.entries())
|
||||
.sort((a, b) => a[1].lastFetched.getTime() - b[1].lastFetched.getTime())
|
||||
|
||||
const toRemove = sortedEntries.slice(0, cachedTeacherSchedules.size - maxCacheSize)
|
||||
toRemove.forEach(([key]) => cachedTeacherSchedules.delete(key))
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext<{ teacher: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
|
||||
const groups = loadGroups()
|
||||
const settings = loadSettings()
|
||||
const teacherParam = context.params?.teacher
|
||||
const wkParam = context.query.wk
|
||||
// Валидация wk параметра: проверка на валидное число (не NaN, не Infinity)
|
||||
const wk = wkParam && !isNaN(Number(wkParam)) && isFinite(Number(wkParam)) && Number.isInteger(Number(wkParam)) && Number(wkParam) > 0
|
||||
? Number(wkParam)
|
||||
: undefined
|
||||
|
||||
// Валидация teacher параметра: должен быть числом (parseId)
|
||||
const teacherParseId = teacherParam && !isNaN(Number(teacherParam)) && isFinite(Number(teacherParam)) && Number.isInteger(Number(teacherParam)) && Number(teacherParam) > 0
|
||||
? Number(teacherParam)
|
||||
: null
|
||||
|
||||
if (!teacherParseId) {
|
||||
return {
|
||||
notFound: true
|
||||
}
|
||||
}
|
||||
|
||||
const teacherInfo = getTeacherByParseId(teacherParseId)
|
||||
if (!teacherInfo) {
|
||||
return {
|
||||
notFound: true
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем debug опции
|
||||
const debug = settings.debug || {}
|
||||
|
||||
// Debug: принудительно показать ошибку
|
||||
if (debug.forceError) {
|
||||
const cacheAvailableFor = Array.from(cachedTeacherSchedules.entries())
|
||||
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
|
||||
.map(([k]) => k.split('_')[0])
|
||||
|
||||
return {
|
||||
props: nextSerialized({
|
||||
teacher: {
|
||||
id: teacherInfo.id,
|
||||
name: teacherInfo.name
|
||||
},
|
||||
cacheAvailableFor,
|
||||
groups,
|
||||
settings,
|
||||
error: {
|
||||
message: 'Debug: принудительная ошибка',
|
||||
isTimeout: false
|
||||
}
|
||||
}) as NextSerialized<PageProps>
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: принудительно симулировать таймаут
|
||||
if (debug.forceTimeout) {
|
||||
const cacheAvailableFor = Array.from(cachedTeacherSchedules.entries())
|
||||
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
|
||||
.map(([k]) => k.split('_')[0])
|
||||
|
||||
return {
|
||||
props: nextSerialized({
|
||||
teacher: {
|
||||
id: teacherInfo.id,
|
||||
name: teacherInfo.name
|
||||
},
|
||||
cacheAvailableFor,
|
||||
groups,
|
||||
settings,
|
||||
error: {
|
||||
message: 'Debug: принудительный таймаут',
|
||||
isTimeout: true
|
||||
}
|
||||
}) as NextSerialized<PageProps>
|
||||
}
|
||||
}
|
||||
|
||||
let scheduleResult: ScheduleResult
|
||||
let parsedAt
|
||||
let isFromCache = false
|
||||
let cacheAge: number | undefined
|
||||
|
||||
// Очищаем старые записи из кэша перед использованием
|
||||
cleanupCache()
|
||||
|
||||
// Кэшируем только текущую неделю (без параметра wk)
|
||||
// Если запрашивается конкретная неделя (wk указан), не используем кэш
|
||||
const useCache = !wk
|
||||
const cacheKey = `teacher_${teacherParseId}` // Ключ кэша для преподавателя
|
||||
const cachedSchedule = useCache ? cachedTeacherSchedules.get(cacheKey) : undefined
|
||||
|
||||
// Debug: принудительно использовать кэш
|
||||
if (debug.forceCache && cachedSchedule) {
|
||||
scheduleResult = cachedSchedule.results
|
||||
parsedAt = cachedSchedule.lastFetched
|
||||
isFromCache = true
|
||||
const cacheAgeMs = Date.now() - cachedSchedule.lastFetched.getTime()
|
||||
cacheAge = Math.floor(cacheAgeMs / (1000 * 60))
|
||||
} else if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) {
|
||||
scheduleResult = cachedSchedule.results
|
||||
parsedAt = cachedSchedule.lastFetched
|
||||
} else {
|
||||
try {
|
||||
// Передаем настройки в getTeacherSchedule для условного парсинга навигации
|
||||
scheduleResult = await getTeacherSchedule(teacherParseId, teacherInfo.name, wk, settings.weekNavigationEnabled)
|
||||
parsedAt = new Date()
|
||||
|
||||
// Кэшируем только текущую неделю
|
||||
if (useCache) {
|
||||
cachedTeacherSchedules.set(cacheKey, { lastFetched: new Date(), results: scheduleResult })
|
||||
// Очищаем кэш после добавления новой записи, если он стал слишком большим
|
||||
cleanupCache()
|
||||
}
|
||||
} catch(e) {
|
||||
// При таймауте или любой другой ошибке используем кэш, если он доступен (fallback кэш)
|
||||
// Используем кэш независимо от возраста при ошибке парсинга
|
||||
if (cachedSchedule) {
|
||||
scheduleResult = cachedSchedule.results
|
||||
parsedAt = cachedSchedule.lastFetched
|
||||
isFromCache = true
|
||||
const cacheAgeMs = Date.now() - cachedSchedule.lastFetched.getTime()
|
||||
cacheAge = Math.floor(cacheAgeMs / (1000 * 60))
|
||||
// Логируем использование fallback кэша с указанием возраста
|
||||
if (e instanceof ScheduleTimeoutError) {
|
||||
console.warn(`Schedule fetch timeout for teacher ${teacherInfo.name}, using fallback cache from ${cachedSchedule.lastFetched.toISOString()} (${cacheAge} minutes old)`)
|
||||
} else {
|
||||
console.warn(`Schedule fetch error for teacher ${teacherInfo.name}, using fallback cache from ${cachedSchedule.lastFetched.toISOString()} (${cacheAge} minutes old)`)
|
||||
}
|
||||
} else {
|
||||
// Если кэша нет, возвращаем страницу с ошибкой вместо throw
|
||||
const isTimeout = e instanceof ScheduleTimeoutError
|
||||
const errorMessage = isTimeout
|
||||
? 'Превышено время ожидания ответа от сервера'
|
||||
: 'Произошла ошибка при загрузке расписания'
|
||||
|
||||
console.error(`Schedule fetch failed for teacher ${teacherInfo.name}, no cache available:`, e)
|
||||
|
||||
const cacheAvailableFor = Array.from(cachedTeacherSchedules.entries())
|
||||
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
|
||||
.map(([k]) => k.split('_')[1]) // Берем parseId из ключа кэша
|
||||
|
||||
return {
|
||||
props: nextSerialized({
|
||||
teacher: {
|
||||
id: teacherInfo.id,
|
||||
name: teacherInfo.name
|
||||
},
|
||||
cacheAvailableFor,
|
||||
groups,
|
||||
settings,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
isTimeout
|
||||
}
|
||||
}) as NextSerialized<PageProps>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: принудительно показать пустое расписание
|
||||
if (debug.forceEmpty) {
|
||||
scheduleResult = {
|
||||
days: [],
|
||||
currentWk: scheduleResult.currentWk,
|
||||
availableWeeks: scheduleResult.availableWeeks
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = scheduleResult.days
|
||||
|
||||
const getSha256Hash = (input: string) => {
|
||||
const hash = crypto.createHash('sha256')
|
||||
hash.update(input)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
const etag = getSha256Hash(JSON.stringify(nextSerialized(schedule)))
|
||||
|
||||
const ifNoneMatch = context.req.headers['if-none-match']
|
||||
if (ifNoneMatch === etag) {
|
||||
context.res.writeHead(304, { ETag: `"${etag}"` })
|
||||
context.res.end()
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Content has not changed
|
||||
return { props: {} }
|
||||
}
|
||||
|
||||
const cacheAvailableFor = Array.from(cachedTeacherSchedules.entries())
|
||||
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
|
||||
.map(([k]) => k.split('_')[1]) // Берем parseId из ключа кэша
|
||||
|
||||
// Debug: информация о кэше
|
||||
const cacheInfo = debug.showCacheInfo ? {
|
||||
size: cachedTeacherSchedules.size,
|
||||
entries: cachedTeacherSchedules.size
|
||||
} : undefined
|
||||
|
||||
context.res.setHeader('ETag', `"${etag}"`)
|
||||
return {
|
||||
props: nextSerialized({
|
||||
schedule: schedule,
|
||||
parsedAt: parsedAt,
|
||||
teacher: {
|
||||
id: teacherInfo.id,
|
||||
name: teacherInfo.name
|
||||
},
|
||||
cacheAvailableFor,
|
||||
groups,
|
||||
currentWk: scheduleResult.currentWk ?? null,
|
||||
availableWeeks: scheduleResult.availableWeeks ?? null,
|
||||
settings,
|
||||
isFromCache: isFromCache ?? false,
|
||||
cacheAge: cacheAge ?? null,
|
||||
cacheInfo: cacheInfo ?? null
|
||||
}) as NextSerialized<PageProps>
|
||||
}
|
||||
}
|
||||
182
src/pages/teachers.tsx
Normal file
182
src/pages/teachers.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React from 'react'
|
||||
import { GetServerSideProps } from 'next'
|
||||
import { loadTeachers, TeachersData } from '@/shared/data/teachers-loader'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shadcn/ui/card'
|
||||
import { Button } from '@/shadcn/ui/button'
|
||||
import { ThemeSwitcher } from '@/features/theme-switch'
|
||||
import Link from 'next/link'
|
||||
import Head from 'next/head'
|
||||
import { GITHUB_REPO_URL } from '@/shared/constants/urls'
|
||||
import { FaGithub } from 'react-icons/fa'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
type TeachersPageProps = {
|
||||
teachers: TeachersData
|
||||
}
|
||||
|
||||
export default function TeachersPage({ teachers }: TeachersPageProps) {
|
||||
// Преобразуем объект преподавателей в массив и сортируем по имени
|
||||
const teachersList = Object.entries(teachers)
|
||||
.map(([id, teacher]) => ({ id, parseId: teacher.parseId, name: teacher.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Преподаватели — Расписание занятий</title>
|
||||
<meta name="description" content="Список преподавателей Колледжа Связи ПГУТИ" />
|
||||
</Head>
|
||||
<div className="min-h-screen p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
<div className="text-center space-y-2 mb-8 stagger-card" style={{ animationDelay: '0.05s' } as React.CSSProperties}>
|
||||
<h1 className="text-3xl md:text-4xl font-bold">Преподаватели</h1>
|
||||
<p className="text-muted-foreground">Выберите преподавателя для просмотра расписания</p>
|
||||
</div>
|
||||
|
||||
{teachersList.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Преподаватели не найдены
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{teachersList.map((teacher, index) => {
|
||||
const delay = 0.1 + index * 0.02
|
||||
return (
|
||||
<div
|
||||
key={teacher.id}
|
||||
className="stagger-card"
|
||||
style={{
|
||||
animationDelay: `${delay}s`,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Link href={`/teacher/${teacher.parseId}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center h-auto py-3 px-4 text-sm sm:text-base"
|
||||
>
|
||||
{teacher.name}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.1 + teachersList.length * 0.02 + 0.05}s` } as React.CSSProperties}
|
||||
>
|
||||
<Link href="/">
|
||||
<Button variant="secondary" className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
На главную
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.1 + teachersList.length * 0.02 + 0.08}s` } as React.CSSProperties}
|
||||
>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.1 + teachersList.length * 0.02 + 0.11}s` } as React.CSSProperties}
|
||||
>
|
||||
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<FaGithub className="h-4 w-4" />
|
||||
GitHub
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async function parseAndSaveTeachers(): Promise<boolean> {
|
||||
try {
|
||||
const { parseTeachersList } = await import('@/app/parser/teachers-list')
|
||||
const { JSDOM } = await import('jsdom')
|
||||
const { PROXY_URL } = await import('@/shared/constants/urls')
|
||||
const contentTypeParser = await import('content-type')
|
||||
|
||||
const teachersUrl = `${PROXY_URL}/?mn=3`
|
||||
const page = await fetch(teachersUrl)
|
||||
const content = await page.text()
|
||||
const contentType = page.headers.get('content-type')
|
||||
|
||||
if (page.status === 200 && contentType && contentTypeParser.default.parse(contentType).type === 'text/html') {
|
||||
const dom = new JSDOM(content, { url: teachersUrl })
|
||||
const document = dom.window.document
|
||||
const teachersList = parseTeachersList(document)
|
||||
dom.window.close()
|
||||
|
||||
if (teachersList.length > 0) {
|
||||
// Преобразуем в формат TeachersData
|
||||
const teachersData: TeachersData = {}
|
||||
for (const teacher of teachersList) {
|
||||
const id = String(teacher.parseId)
|
||||
teachersData[id] = {
|
||||
parseId: teacher.parseId,
|
||||
name: teacher.name
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем в БД
|
||||
const { saveTeachers } = await import('@/shared/data/teachers-loader')
|
||||
saveTeachers(teachersData)
|
||||
|
||||
// Сохраняем timestamp последнего обновления
|
||||
const { setTeachersLastUpdateTime } = await import('@/shared/data/database')
|
||||
setTeachersLastUpdateTime(Date.now())
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Error parsing teachers:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<TeachersPageProps> = async () => {
|
||||
let teachers = loadTeachers()
|
||||
|
||||
// Проверяем, нужно ли обновить список преподавателей
|
||||
const { getTeachersLastUpdateTime } = await import('@/shared/data/database')
|
||||
const lastUpdate = getTeachersLastUpdateTime()
|
||||
const now = Date.now()
|
||||
const ONE_DAY_MS = 1000 * 60 * 60 * 24 // 24 часа в миллисекундах
|
||||
|
||||
const shouldUpdate = !lastUpdate || (now - lastUpdate) >= ONE_DAY_MS
|
||||
const isEmpty = Object.keys(teachers).length === 0
|
||||
|
||||
// Если список пуст или прошло 24 часа с последнего обновления, обновляем
|
||||
if (isEmpty || shouldUpdate) {
|
||||
// Парсим и сохраняем преподавателей напрямую (без вызова API)
|
||||
const success = await parseAndSaveTeachers()
|
||||
|
||||
if (success) {
|
||||
// Перезагружаем данные из БД
|
||||
teachers = loadTeachers(true)
|
||||
} else if (isEmpty) {
|
||||
// Если не удалось загрузить и список был пуст, логируем ошибку
|
||||
console.error('Failed to load teachers list')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
teachers
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user