Базовая функциональность
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.idea
|
||||||
|
.venv
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
deploy.sh
|
||||||
|
Makefile
|
||||||
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
schedule_link=
|
||||||
|
SCHEDULE_CACHE_TTL=900
|
||||||
|
API_PORT=8000
|
||||||
|
FRONTEND_PORT=8080
|
||||||
|
SCHEDULE_API=http://api:8000
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
**/*.idea
|
||||||
|
**/*.env
|
||||||
|
/.idea/
|
||||||
|
.env
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt \
|
||||||
|
&& groupadd --system app && useradd --system --gid app app
|
||||||
|
|
||||||
|
FROM base AS api
|
||||||
|
|
||||||
|
COPY --chown=app:app src/backend/main.py src/backend/schedule_parser.py ./
|
||||||
|
USER app
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
|
|
||||||
|
FROM base AS frontend
|
||||||
|
|
||||||
|
COPY --chown=app:app src/frontend/main.py frontend/main.py
|
||||||
|
COPY --chown=app:app src/frontend/static frontend/static
|
||||||
|
USER app
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["uvicorn", "frontend.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
29
Makefile
Normal file
29
Makefile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
.PHONY: build up down logs restart deploy ps dev
|
||||||
|
|
||||||
|
build: ## Собрать образы микросервисов
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
up: ## Запустить все микросервисы одной командой
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
down: ## Остановить и удалить контейнеры
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
restart: ## Перезапустить микросервисы
|
||||||
|
docker compose restart
|
||||||
|
|
||||||
|
logs: ## Логи всех микросервисов
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
ps: ## Статус микросервисов
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
deploy: ## Собрать и запустить (деплой на сервере)
|
||||||
|
./deploy.sh
|
||||||
|
|
||||||
|
dev: ## Запустить локально без Docker (бэкенд + фронт)
|
||||||
|
@echo "Запуск бэкенда на :8000 и фронта на :8080..."
|
||||||
|
@trap 'kill 0' EXIT; \
|
||||||
|
cd src/backend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8000 & \
|
||||||
|
cd src/frontend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --port 8080 & \
|
||||||
|
wait
|
||||||
23
deploy.sh
Executable file
23
deploy.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "Ошибка: файл .env не найден. Скопируйте .env.example в .env и укажите schedule_link." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -qE '^schedule_link=.+' .env; then
|
||||||
|
echo "Ошибка: в .env не заполнена переменная schedule_link." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Сборка образов..."
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
echo "Запуск микросервисов..."
|
||||||
|
docker compose up -d --remove-orphans
|
||||||
|
|
||||||
|
echo
|
||||||
|
docker compose ps
|
||||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: api
|
||||||
|
environment:
|
||||||
|
schedule_link: ${schedule_link}
|
||||||
|
SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900}
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-8000}:8000"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/groups', timeout=5)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: frontend
|
||||||
|
environment:
|
||||||
|
SCHEDULE_API: ${SCHEDULE_API:-http://api:8000}
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-8080}:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/', timeout=5)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
2
requirements-dev.txt
Normal file
2
requirements-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
httpx2
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
python-dotenv
|
||||||
|
requests
|
||||||
298
src/backend/main.py
Normal file
298
src/backend/main.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
|
||||||
|
from schedule_parser import (
|
||||||
|
DAYS,
|
||||||
|
ScheduleError,
|
||||||
|
build_day_times,
|
||||||
|
build_group_schedule,
|
||||||
|
build_room_schedule,
|
||||||
|
build_teacher_room_index,
|
||||||
|
build_teacher_schedule,
|
||||||
|
current_parity,
|
||||||
|
download_schedule,
|
||||||
|
extract_week_ranges,
|
||||||
|
fetch_table_modified,
|
||||||
|
parse_rooms_info,
|
||||||
|
parse_schedule,
|
||||||
|
parse_teachers_full,
|
||||||
|
parse_workbook,
|
||||||
|
sort_rooms,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _find_dotenv():
|
||||||
|
path = Path(__file__).resolve().parent
|
||||||
|
for _ in range(5):
|
||||||
|
env = path / ".env"
|
||||||
|
if env.is_file():
|
||||||
|
return env
|
||||||
|
if path.parent == path:
|
||||||
|
break
|
||||||
|
path = path.parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_dotenv_path = _find_dotenv()
|
||||||
|
if _dotenv_path:
|
||||||
|
load_dotenv(_dotenv_path)
|
||||||
|
|
||||||
|
SCHEDULE_LINK = os.getenv("schedule_link") or os.getenv("SCHEDULE_LINK", "").strip()
|
||||||
|
CACHE_TTL = int(os.getenv("SCHEDULE_CACHE_TTL", "900"))
|
||||||
|
|
||||||
|
_cache = {
|
||||||
|
"data": None,
|
||||||
|
"weeks": None,
|
||||||
|
"modified": None,
|
||||||
|
"loaded_at": 0.0,
|
||||||
|
"error": None,
|
||||||
|
"index": None,
|
||||||
|
"teacher_full": {},
|
||||||
|
"room_info": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
try:
|
||||||
|
_load_data()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Расписание",
|
||||||
|
description="Расписание групп из Яндекс.Таблицы",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_data(force: bool = False):
|
||||||
|
if not SCHEDULE_LINK:
|
||||||
|
raise ScheduleError("Ссылка schedule_link не задана в .env")
|
||||||
|
now = time.time()
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and _cache["data"] is not None
|
||||||
|
and now - _cache["loaded_at"] < CACHE_TTL
|
||||||
|
and _cache["error"] is None
|
||||||
|
):
|
||||||
|
return _cache["data"], _cache["weeks"]
|
||||||
|
|
||||||
|
modified = fetch_table_modified(SCHEDULE_LINK)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and modified
|
||||||
|
and modified == _cache["modified"]
|
||||||
|
and _cache["data"] is not None
|
||||||
|
and _cache["error"] is None
|
||||||
|
):
|
||||||
|
_cache["loaded_at"] = now
|
||||||
|
return _cache["data"], _cache["weeks"]
|
||||||
|
|
||||||
|
xlsx = download_schedule(SCHEDULE_LINK)
|
||||||
|
sheets = parse_workbook(xlsx)
|
||||||
|
data = parse_schedule(sheets)
|
||||||
|
weeks = extract_week_ranges(sheets)
|
||||||
|
_cache["data"] = data
|
||||||
|
_cache["weeks"] = weeks
|
||||||
|
_cache["modified"] = modified
|
||||||
|
_cache["loaded_at"] = now
|
||||||
|
_cache["error"] = None
|
||||||
|
_cache["index"] = None
|
||||||
|
_cache["teacher_full"] = parse_teachers_full(sheets)
|
||||||
|
_cache["room_info"] = parse_rooms_info(sheets)
|
||||||
|
return data, weeks
|
||||||
|
|
||||||
|
|
||||||
|
def _get_index(force: bool = False):
|
||||||
|
data, _ = _get_data(force=force)
|
||||||
|
if _cache["index"] is None:
|
||||||
|
_cache["index"] = build_teacher_room_index(data)
|
||||||
|
return _cache["index"]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_data(force: bool = False):
|
||||||
|
try:
|
||||||
|
return _load_data(force=force)
|
||||||
|
except ScheduleError as exc:
|
||||||
|
_cache["error"] = str(exc)
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index():
|
||||||
|
return {
|
||||||
|
"title": "Расписание",
|
||||||
|
"endpoints": {
|
||||||
|
"/groups": "Список групп",
|
||||||
|
"/schedule/{group}": "Расписание группы",
|
||||||
|
"/schedule/{group}/{day}": "Расписание группы на день",
|
||||||
|
"/today?group={group}": "Расписание группы на сегодня",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/groups")
|
||||||
|
def groups(force: bool = Query(False)):
|
||||||
|
data, _ = _get_data(force=force)
|
||||||
|
return sorted(
|
||||||
|
data.keys(),
|
||||||
|
key=lambda g: (
|
||||||
|
0 if g.endswith("БО") else (1 if g.endswith("МО") else 2),
|
||||||
|
g,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/teachers")
|
||||||
|
def teachers(force: bool = Query(False)):
|
||||||
|
teachers_map, _ = _get_index(force=force)
|
||||||
|
return sorted(teachers_map.keys())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/rooms")
|
||||||
|
def rooms(force: bool = Query(False)):
|
||||||
|
_, rooms_map = _get_index(force=force)
|
||||||
|
return sort_rooms(rooms_map.keys())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/teacher/{name}")
|
||||||
|
def teacher(name: str, force: bool = Query(False)):
|
||||||
|
teachers_map, _ = _get_index(force=force)
|
||||||
|
result = build_teacher_schedule(teachers_map, name)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Преподаватель «{name}» не найден")
|
||||||
|
result["full_name"] = _cache.get("teacher_full", {}).get(name, name)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/room/{label}")
|
||||||
|
def room(label: str, force: bool = Query(False)):
|
||||||
|
data, _ = _get_data(force=force)
|
||||||
|
_, rooms_map = _get_index(force=force)
|
||||||
|
result = build_room_schedule(rooms_map, label, build_day_times(data))
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Аудитория «{label}» не найдена")
|
||||||
|
result["info"] = _cache.get("room_info", {}).get(label, {}).get("info", "")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/rooms-free")
|
||||||
|
def rooms_free(
|
||||||
|
day: str,
|
||||||
|
period: int,
|
||||||
|
force: bool = Query(False),
|
||||||
|
):
|
||||||
|
normalized = day.strip().lower()
|
||||||
|
if normalized not in DAYS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"День должен быть одним из: {', '.join(DAYS)}")
|
||||||
|
if period < 1 or period > 8:
|
||||||
|
raise HTTPException(status_code=400, detail="Пара должна быть от 1 до 8")
|
||||||
|
_, rooms_map = _get_index(force=force)
|
||||||
|
room_info = _cache.get("room_info", {})
|
||||||
|
free = []
|
||||||
|
for label in sort_rooms(room_info.keys()):
|
||||||
|
occupied = rooms_map.get(label, {}).get(normalized, {}).get(str(period), {})
|
||||||
|
busy = bool((occupied.get("ч") or []) or (occupied.get("з") or []))
|
||||||
|
if not busy:
|
||||||
|
free.append({"room": label, "info": room_info[label].get("info", "")})
|
||||||
|
return free
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/rooms-day")
|
||||||
|
def rooms_day(day: str, force: bool = Query(False)):
|
||||||
|
normalized = day.strip().lower()
|
||||||
|
if normalized not in DAYS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"День должен быть одним из: {', '.join(DAYS)}")
|
||||||
|
data, _ = _get_data(force=force)
|
||||||
|
_, rooms_map = _get_index(force=force)
|
||||||
|
room_info = _cache.get("room_info", {})
|
||||||
|
day_times = build_day_times(data).get(normalized, {})
|
||||||
|
|
||||||
|
labels = set(rooms_map.keys()) | set(room_info.keys())
|
||||||
|
rooms_out = []
|
||||||
|
for label in sort_rooms(labels):
|
||||||
|
info = room_info.get(label, {}).get("info", "")
|
||||||
|
occupied_day = rooms_map.get(label, {}).get(normalized, {})
|
||||||
|
periods = {}
|
||||||
|
keys = set(occupied_day.keys()) | set(day_times.keys())
|
||||||
|
for period in sorted(keys, key=lambda p: int(p)):
|
||||||
|
slot = occupied_day.get(period, {})
|
||||||
|
periods[str(period)] = {
|
||||||
|
"time": day_times.get(period, slot.get("time", "")),
|
||||||
|
"ч": slot.get("ч", []) or [],
|
||||||
|
"з": slot.get("з", []) or [],
|
||||||
|
}
|
||||||
|
rooms_out.append({"room": label, "info": info, "periods": periods})
|
||||||
|
return {"day": normalized, "rooms": rooms_out}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/week")
|
||||||
|
def week(force: bool = Query(False)):
|
||||||
|
_, weeks = _get_data(force=force)
|
||||||
|
today = datetime.now().date()
|
||||||
|
return {
|
||||||
|
"today": today.isoformat(),
|
||||||
|
"parity": current_parity(weeks, today),
|
||||||
|
"weeks": [
|
||||||
|
{"parity": w["parity"], "start": w["start"].isoformat(), "end": w["end"].isoformat()}
|
||||||
|
for w in weeks
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/status")
|
||||||
|
def status(force: bool = Query(False)):
|
||||||
|
_get_data(force=force)
|
||||||
|
return {
|
||||||
|
"cache_ttl_seconds": CACHE_TTL,
|
||||||
|
"last_updated": (
|
||||||
|
datetime.fromtimestamp(_cache["loaded_at"], datetime.now().astimezone().tzinfo).isoformat() if _cache["loaded_at"] else None
|
||||||
|
),
|
||||||
|
"table_modified": _cache["modified"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/schedule/{group}")
|
||||||
|
def schedule(group: str, force: bool = Query(False)):
|
||||||
|
data, _ = _get_data(force=force)
|
||||||
|
if group not in data:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
|
||||||
|
return build_group_schedule(data, group)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/schedule/{group}/{day}")
|
||||||
|
def schedule_day(group: str, day: str, force: bool = Query(False)):
|
||||||
|
data, _ = _get_data(force=force)
|
||||||
|
if group not in data:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
|
||||||
|
normalized = day.strip().lower()
|
||||||
|
if normalized not in DAYS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"День должен быть одним из: {', '.join(DAYS)}")
|
||||||
|
sched = build_group_schedule(data, group)
|
||||||
|
day_data = sched["days"].get(normalized, {})
|
||||||
|
return {"group": group, "day": normalized, "periods": day_data}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/today")
|
||||||
|
def today(group: str, force: bool = Query(False)):
|
||||||
|
data, _ = _get_data(force=force)
|
||||||
|
if group not in data:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
|
||||||
|
weekday = datetime.now().weekday()
|
||||||
|
day_name = DAYS[weekday]
|
||||||
|
sched = build_group_schedule(data, group)
|
||||||
|
return {"group": group, "date": datetime.now().date().isoformat(), "day": day_name, "periods": sched["days"].get(day_name, {})}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
|
||||||
799
src/backend/schedule_parser.py
Normal file
799
src/backend/schedule_parser.py
Normal file
@@ -0,0 +1,799 @@
|
|||||||
|
import io
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
XLSX_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||||
|
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||||
|
MAIN_SHEET = "Бакалавры и магистры"
|
||||||
|
|
||||||
|
DAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
|
||||||
|
|
||||||
|
_PERIOD_RE = re.compile(r"^\s*(\d+)\s*(.*)$")
|
||||||
|
_REF_RE = re.compile(r"^([A-Z]+)(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_text(node):
|
||||||
|
for el in node.iter(f"{{{XLSX_NS}}}t"):
|
||||||
|
if el.text:
|
||||||
|
yield el.text
|
||||||
|
|
||||||
|
|
||||||
|
def _col_index(col_letter):
|
||||||
|
idx = 0
|
||||||
|
for ch in col_letter:
|
||||||
|
idx = idx * 26 + (ord(ch) - ord("A") + 1)
|
||||||
|
return idx - 1
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ref(ref):
|
||||||
|
m = _REF_RE.match(ref)
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"Некорректная ссылка на ячейку: {ref}")
|
||||||
|
return _col_index(m.group(1)), int(m.group(2)) - 1
|
||||||
|
|
||||||
|
|
||||||
|
RED_OPEN = "\u27EA"
|
||||||
|
RED_CLOSE = "\u27EB"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_red_rgb(rgb):
|
||||||
|
try:
|
||||||
|
if not rgb or len(rgb) < 8 or not rgb.startswith("FF"):
|
||||||
|
return False
|
||||||
|
r = int(rgb[2:4], 16)
|
||||||
|
g = int(rgb[4:6], 16)
|
||||||
|
b = int(rgb[6:8], 16)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
return r > 0x90 and g < 0x70 and b < 0x70
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_shared_strings(xml_bytes):
|
||||||
|
root = ET.fromstring(xml_bytes)
|
||||||
|
out = []
|
||||||
|
for si in root.iter(f"{{{XLSX_NS}}}si"):
|
||||||
|
parts = []
|
||||||
|
for child in si:
|
||||||
|
if child.tag == f"{{{XLSX_NS}}}t":
|
||||||
|
parts.append((child.text or "", False))
|
||||||
|
continue
|
||||||
|
if child.tag != f"{{{XLSX_NS}}}r":
|
||||||
|
continue
|
||||||
|
t = child.find(f"{{{XLSX_NS}}}t")
|
||||||
|
text = t.text if t is not None else ""
|
||||||
|
red = False
|
||||||
|
rpr = child.find(f"{{{XLSX_NS}}}rPr")
|
||||||
|
if rpr is not None:
|
||||||
|
color = rpr.find(f"{{{XLSX_NS}}}color")
|
||||||
|
if color is not None:
|
||||||
|
red = _is_red_rgb(color.get("rgb"))
|
||||||
|
parts.append((text or "", red))
|
||||||
|
chunks = []
|
||||||
|
for text, red in parts:
|
||||||
|
if red:
|
||||||
|
chunks.append(RED_OPEN + text + RED_CLOSE)
|
||||||
|
else:
|
||||||
|
chunks.append(text)
|
||||||
|
out.append("".join(chunks))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sheet(xml_bytes, shared):
|
||||||
|
root = ET.fromstring(xml_bytes)
|
||||||
|
cells = {}
|
||||||
|
max_row = 0
|
||||||
|
max_col = 0
|
||||||
|
hidden_cols = set()
|
||||||
|
|
||||||
|
cols_node = root.find(f".//{{{XLSX_NS}}}cols")
|
||||||
|
if cols_node is not None:
|
||||||
|
for col in cols_node.iter(f"{{{XLSX_NS}}}col"):
|
||||||
|
if col.get("hidden") == "1":
|
||||||
|
try:
|
||||||
|
c_min = int(col.get("min"))
|
||||||
|
c_max = int(col.get("max"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
hidden_cols.update(range(c_min - 1, c_max))
|
||||||
|
|
||||||
|
for row in root.iter(f"{{{XLSX_NS}}}row"):
|
||||||
|
r = int(row.get("r")) - 1
|
||||||
|
for cell in row.iter(f"{{{XLSX_NS}}}c"):
|
||||||
|
ref = cell.get("r")
|
||||||
|
if not ref:
|
||||||
|
continue
|
||||||
|
c = _col_index("".join(ch for ch in ref if ch.isalpha()))
|
||||||
|
t = cell.get("t")
|
||||||
|
v = cell.find(f"{{{XLSX_NS}}}v")
|
||||||
|
if t == "s" and v is not None and v.text:
|
||||||
|
idx = int(v.text)
|
||||||
|
value = shared[idx] if idx < len(shared) else ""
|
||||||
|
elif v is not None and v.text is not None:
|
||||||
|
value = v.text
|
||||||
|
else:
|
||||||
|
is_node = cell.find(f"{{{XLSX_NS}}}is")
|
||||||
|
value = "".join(_iter_text(is_node)) if is_node is not None else ""
|
||||||
|
if value != "":
|
||||||
|
cells[(r, c)] = value
|
||||||
|
max_row = max(max_row, r)
|
||||||
|
max_col = max(max_col, c)
|
||||||
|
|
||||||
|
merge_node = root.find(f".//{{{XLSX_NS}}}mergeCells")
|
||||||
|
if merge_node is not None:
|
||||||
|
for mc in merge_node.iter(f"{{{XLSX_NS}}}mergeCell"):
|
||||||
|
ref = mc.get("ref")
|
||||||
|
if not ref or ":" not in ref:
|
||||||
|
continue
|
||||||
|
a, b = ref.split(":")
|
||||||
|
c1, r1 = _parse_ref(a)
|
||||||
|
c2, r2 = _parse_ref(b)
|
||||||
|
max_row = max(max_row, r2)
|
||||||
|
max_col = max(max_col, c2)
|
||||||
|
top = cells.get((r1, c1), "")
|
||||||
|
if not top:
|
||||||
|
continue
|
||||||
|
for rr in range(r1, r2 + 1):
|
||||||
|
for cc in range(c1, c2 + 1):
|
||||||
|
if (rr, cc) not in cells:
|
||||||
|
cells[(rr, cc)] = top
|
||||||
|
|
||||||
|
matrix = [
|
||||||
|
[cells.get((r, c), "") for c in range(max_col + 1)]
|
||||||
|
for r in range(max_row + 1)
|
||||||
|
]
|
||||||
|
return matrix, hidden_cols
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cell(row, col_idx):
|
||||||
|
if col_idx < len(row):
|
||||||
|
return row[col_idx]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def download_schedule(public_url):
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
"https://cloud-api.yandex.net/v1/disk/public/resources/download",
|
||||||
|
params={"public_key": public_url},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
href = resp.json()["href"]
|
||||||
|
file_resp = requests.get(href, timeout=60)
|
||||||
|
file_resp.raise_for_status()
|
||||||
|
return file_resp.content
|
||||||
|
except Exception as exc:
|
||||||
|
raise ScheduleError(f"Не удалось скачать таблицу: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_table_modified(public_url):
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
"https://cloud-api.yandex.net/v1/disk/public/resources",
|
||||||
|
params={"public_key": public_url},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("modified")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_workbook(xlsx_bytes):
|
||||||
|
try:
|
||||||
|
zf = zipfile.ZipFile(io.BytesIO(xlsx_bytes))
|
||||||
|
except zipfile.BadZipFile as exc:
|
||||||
|
raise ScheduleError("Скачанный файл не является xlsx") from exc
|
||||||
|
|
||||||
|
shared = []
|
||||||
|
try:
|
||||||
|
shared = _parse_shared_strings(zf.read("xl/sharedStrings.xml"))
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
rels_root = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels"))
|
||||||
|
rel_map = {}
|
||||||
|
for rel in rels_root:
|
||||||
|
rid = rel.get("Id")
|
||||||
|
target = rel.get("Target")
|
||||||
|
if rid and target:
|
||||||
|
rel_map[rid] = target
|
||||||
|
|
||||||
|
wb_root = ET.fromstring(zf.read("xl/workbook.xml"))
|
||||||
|
sheets = {}
|
||||||
|
for sheet in wb_root.iter(f"{{{XLSX_NS}}}sheet"):
|
||||||
|
name = sheet.get("name")
|
||||||
|
rid = sheet.get(f"{{{REL_NS}}}id")
|
||||||
|
if name and rid and rid in rel_map:
|
||||||
|
target = rel_map[rid]
|
||||||
|
if target.startswith("worksheets/"):
|
||||||
|
matrix, hidden_cols = _parse_sheet(zf.read("xl/" + target), shared)
|
||||||
|
sheets[name] = {"rows": matrix, "hidden_cols": hidden_cols}
|
||||||
|
|
||||||
|
return sheets
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_header(text):
|
||||||
|
return text.split("\n", 1)[0].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _excel_serial_to_date(serial):
|
||||||
|
try:
|
||||||
|
value = float(str(serial).replace(",", "."))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if value <= 0:
|
||||||
|
return None
|
||||||
|
if value <= 60:
|
||||||
|
value -= 1
|
||||||
|
return (datetime(1899, 12, 30) + timedelta(days=value)).date()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_week_ranges(sheets):
|
||||||
|
if MAIN_SHEET not in sheets:
|
||||||
|
return []
|
||||||
|
matrix = sheets[MAIN_SHEET]["rows"]
|
||||||
|
ranges = []
|
||||||
|
seen = set()
|
||||||
|
for row in matrix[1:]:
|
||||||
|
if not any(_read_cell(row, i) for i in range(4)):
|
||||||
|
continue
|
||||||
|
for parity, start_col, end_col in (("ч", 0, 1), ("з", 2, 3)):
|
||||||
|
start = _excel_serial_to_date(_read_cell(row, start_col))
|
||||||
|
end = _excel_serial_to_date(_read_cell(row, end_col))
|
||||||
|
if start and end:
|
||||||
|
key = (parity, start)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
ranges.append({"parity": parity, "start": start, "end": end})
|
||||||
|
return sorted(ranges, key=lambda w: w["start"])
|
||||||
|
|
||||||
|
|
||||||
|
def current_parity(week_ranges, today=None):
|
||||||
|
today = today or date.today()
|
||||||
|
for week in week_ranges:
|
||||||
|
if week["start"] <= today <= week["end"]:
|
||||||
|
return week["parity"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_schedule(sheets):
|
||||||
|
if MAIN_SHEET not in sheets:
|
||||||
|
raise ScheduleError(f"Лист «{MAIN_SHEET}» не найден в таблице")
|
||||||
|
sheet = sheets[MAIN_SHEET]
|
||||||
|
matrix = sheet["rows"]
|
||||||
|
hidden_cols = sheet["hidden_cols"]
|
||||||
|
|
||||||
|
header = matrix[0]
|
||||||
|
group_columns = {}
|
||||||
|
seen_names = set()
|
||||||
|
group_re = re.compile(r"^[А-ЯЁ]{2,4}-\d")
|
||||||
|
for idx, cell in enumerate(header):
|
||||||
|
if idx in hidden_cols:
|
||||||
|
continue
|
||||||
|
name = _normalize_header(cell)
|
||||||
|
if name and group_re.match(name) and name not in seen_names:
|
||||||
|
group_columns[idx] = name
|
||||||
|
seen_names.add(name)
|
||||||
|
|
||||||
|
groups = {}
|
||||||
|
for col, name in group_columns.items():
|
||||||
|
header_text = _read_cell(header, col)
|
||||||
|
semester = None
|
||||||
|
if "\n" in header_text:
|
||||||
|
second = header_text.split("\n", 1)[1].strip()
|
||||||
|
m = re.search(r"(\d{1,2}\.\d{2}\.\d{2})", second)
|
||||||
|
if m:
|
||||||
|
semester = m.group(1)
|
||||||
|
groups[name] = {"semester_until": semester, "days": {}}
|
||||||
|
|
||||||
|
day_periods = {}
|
||||||
|
cur_day = None
|
||||||
|
period = None
|
||||||
|
cur_time = ""
|
||||||
|
parity = None
|
||||||
|
|
||||||
|
for row in matrix[1:]:
|
||||||
|
has_dates = any(_read_cell(row, i) for i in range(4))
|
||||||
|
if not has_dates:
|
||||||
|
continue
|
||||||
|
|
||||||
|
day = _read_cell(row, 4)
|
||||||
|
if day:
|
||||||
|
normalized = day.strip().lower()
|
||||||
|
if normalized in DAYS:
|
||||||
|
if normalized != cur_day:
|
||||||
|
cur_day = normalized
|
||||||
|
parity = None
|
||||||
|
if cur_day is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
period_cell = _read_cell(row, 5)
|
||||||
|
if period_cell:
|
||||||
|
lines = [ln.strip() for ln in period_cell.split("\n") if ln.strip()]
|
||||||
|
m = _PERIOD_RE.match(lines[0])
|
||||||
|
if m:
|
||||||
|
period = int(m.group(1))
|
||||||
|
time_parts = [m.group(2).strip()]
|
||||||
|
time_parts += lines[1:]
|
||||||
|
cur_time = " ".join(p for p in time_parts if p)
|
||||||
|
cur_time = re.sub(r"\s*[-–—]\s*", "-", cur_time)
|
||||||
|
day_periods.setdefault(cur_day, {}).setdefault(period, cur_time)
|
||||||
|
|
||||||
|
if period is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parity = "з" if parity == "ч" else "ч"
|
||||||
|
|
||||||
|
for col, group_name in group_columns.items():
|
||||||
|
cell_text = _read_cell(row, col)
|
||||||
|
if cell_text:
|
||||||
|
days_map = groups[group_name]["days"].setdefault(cur_day, {})
|
||||||
|
slot = days_map.setdefault(period, {"time": cur_time, "ч": None, "з": None})
|
||||||
|
slot[parity] = cell_text
|
||||||
|
|
||||||
|
for group_name in groups:
|
||||||
|
for day, periods in day_periods.items():
|
||||||
|
days_map = groups[group_name]["days"]
|
||||||
|
for period, time in periods.items():
|
||||||
|
days_map.setdefault(day, {})[period] = days_map.get(day, {}).get(
|
||||||
|
period, {"time": time, "ч": None, "з": None}
|
||||||
|
)
|
||||||
|
days_map[day] = dict(sorted(days_map[day].items()))
|
||||||
|
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
_MARKER_RE = re.compile(r"к\.в\.?\s*-?\s*\d*\s*", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
_ROOM_LINE_RE = re.compile(r"ауд\.\s*[\w\-]+", re.IGNORECASE)
|
||||||
|
_SUBJECT_START_RE = re.compile(r"^[А-ЯЁA-Z]")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_lesson_blocks(cell_text):
|
||||||
|
lines = cell_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||||
|
blocks = []
|
||||||
|
current = []
|
||||||
|
prev_blank = True
|
||||||
|
prev_room_line = False
|
||||||
|
for raw in lines:
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
prev_blank = True
|
||||||
|
continue
|
||||||
|
starts_marker = bool(_MARKER_RE.match(line))
|
||||||
|
room_line = bool(_ROOM_LINE_RE.search(line))
|
||||||
|
subject_start = bool(_SUBJECT_START_RE.match(line)) and not any(
|
||||||
|
w in line for w in ("не будет", "отменяется", "переносится")
|
||||||
|
)
|
||||||
|
if current and (prev_blank or starts_marker or (prev_room_line and subject_start)):
|
||||||
|
blocks.append(current)
|
||||||
|
current = []
|
||||||
|
current.append(line)
|
||||||
|
prev_blank = False
|
||||||
|
prev_room_line = room_line
|
||||||
|
if current:
|
||||||
|
blocks.append(current)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _lesson(marker, subject, teacher, room):
|
||||||
|
room = re.sub(r"ауд\.?\s*", "ауд. ", room, flags=re.IGNORECASE)
|
||||||
|
room = re.sub(r"\s{2,}", " ", room).strip()
|
||||||
|
return {
|
||||||
|
"marker": marker,
|
||||||
|
"subject": subject,
|
||||||
|
"teacher": teacher,
|
||||||
|
"room": room,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _split_subject_teacher(head):
|
||||||
|
head = head.strip()
|
||||||
|
name_re = re.compile(
|
||||||
|
r"([А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ]\.[А-ЯЁ]\.?)+)(\s*\([^)]*\))*$"
|
||||||
|
)
|
||||||
|
name_m = name_re.search(head)
|
||||||
|
if name_m:
|
||||||
|
teacher = name_m.group(1).strip()
|
||||||
|
subject = (head[: name_m.start()] + (name_m.group(2) or "")).strip()
|
||||||
|
return subject, teacher
|
||||||
|
words = head.split()
|
||||||
|
if len(words) >= 2 and words[-1][:1].isupper():
|
||||||
|
return " ".join(words[:-1]), words[-1]
|
||||||
|
return head, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_inline(text, marker):
|
||||||
|
text = text.strip()
|
||||||
|
m = None
|
||||||
|
for match in re.finditer(r"ауд\.?\s*[\d\-]+\w*", text, re.IGNORECASE):
|
||||||
|
m = match
|
||||||
|
if not m:
|
||||||
|
return _lesson(marker, text, "", "")
|
||||||
|
room = m.group(0).strip()
|
||||||
|
head = text[: m.start()].strip()
|
||||||
|
subject, teacher = _split_subject_teacher(head)
|
||||||
|
return _lesson(marker, subject, teacher, room)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_lesson(lines):
|
||||||
|
lines = [
|
||||||
|
ln.replace(RED_OPEN, "").replace(RED_CLOSE, "").strip()
|
||||||
|
for ln in lines
|
||||||
|
if ln.strip()
|
||||||
|
]
|
||||||
|
if not lines:
|
||||||
|
return None
|
||||||
|
|
||||||
|
marker = None
|
||||||
|
m = re.match(r"^(к\.в\.?\s*-?\s*\d*)\s*(.*)$", lines[0], re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
marker = m.group(1).strip()
|
||||||
|
rest_head = m.group(2).strip()
|
||||||
|
if rest_head:
|
||||||
|
lines[0] = rest_head
|
||||||
|
else:
|
||||||
|
lines = lines[1:]
|
||||||
|
if not lines:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(lines) == 1:
|
||||||
|
return _parse_inline(lines[0], marker)
|
||||||
|
|
||||||
|
if len(lines) == 2:
|
||||||
|
second = lines[1]
|
||||||
|
if re.search(r"(ауд\.|http|зал|каб\.|онлайн)", second, re.IGNORECASE):
|
||||||
|
subject, teacher = _split_subject_teacher(lines[0])
|
||||||
|
return _lesson(marker, subject, teacher, second)
|
||||||
|
if re.search(r"\b[А-ЯЁ]\.[А-ЯЁ]\.?$", second):
|
||||||
|
return _lesson(marker, lines[0], second, "")
|
||||||
|
return _lesson(marker, lines[0], "", second)
|
||||||
|
|
||||||
|
# >=3 строк: ищем строку аудитории, преподаватель — строка перед ней;
|
||||||
|
# строки после аудитории (примечания) не ломают разбор
|
||||||
|
room_idx = None
|
||||||
|
for i in range(len(lines) - 1, -1, -1):
|
||||||
|
if _ROOM_LINE_RE.match(lines[i]):
|
||||||
|
room_idx = i
|
||||||
|
break
|
||||||
|
if room_idx is None:
|
||||||
|
room = lines[-1]
|
||||||
|
teacher = lines[-2]
|
||||||
|
subject = " ".join(lines[:-2])
|
||||||
|
return _lesson(marker, subject, teacher, room)
|
||||||
|
|
||||||
|
room = lines[room_idx]
|
||||||
|
before = lines[:room_idx]
|
||||||
|
teacher = ""
|
||||||
|
teacher_idx = None
|
||||||
|
for i in range(len(before) - 1, -1, -1):
|
||||||
|
cand = before[i]
|
||||||
|
if _looks_like_teacher_line(cand):
|
||||||
|
teacher_idx = i
|
||||||
|
teacher = cand
|
||||||
|
break
|
||||||
|
if teacher_idx is not None:
|
||||||
|
subject = " ".join(before[:teacher_idx])
|
||||||
|
else:
|
||||||
|
subject = " ".join(before)
|
||||||
|
return _lesson(marker, subject, teacher, room)
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_parens(text):
|
||||||
|
text = text.strip()
|
||||||
|
if text.startswith("(") and text.endswith(")"):
|
||||||
|
inner = text[1:-1].strip()
|
||||||
|
if inner:
|
||||||
|
return inner
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_notes(text):
|
||||||
|
notes = []
|
||||||
|
for m in re.findall(r"⟪([^⟫]*)⟫", text):
|
||||||
|
frag = m.strip()
|
||||||
|
if frag:
|
||||||
|
notes.append((_unwrap_parens(frag), True))
|
||||||
|
plain = re.sub(r"⟪[^⟫]*⟫", "", text)
|
||||||
|
for m in re.finditer(r"\(([^()]*)\)", plain):
|
||||||
|
inner = m.group(1).strip()
|
||||||
|
if not inner:
|
||||||
|
continue
|
||||||
|
if re.search(r"[А-ЯЁ]\.[А-ЯЁ]\.?", inner):
|
||||||
|
continue
|
||||||
|
if not re.search(r"[а-яёА-ЯЁa-zA-Z]", inner):
|
||||||
|
continue
|
||||||
|
notes.append((_unwrap_parens(inner), False))
|
||||||
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_note_markers(value):
|
||||||
|
return value.replace(RED_OPEN, "").replace(RED_CLOSE, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_lessons(cell_text):
|
||||||
|
lessons = []
|
||||||
|
last_marker = None
|
||||||
|
for block in _split_lesson_blocks(cell_text):
|
||||||
|
block_text = "\n".join(block)
|
||||||
|
lesson = _parse_lesson(block)
|
||||||
|
if lesson is None:
|
||||||
|
continue
|
||||||
|
for key in ("marker", "subject", "teacher", "room"):
|
||||||
|
if lesson[key]:
|
||||||
|
lesson[key] = _strip_note_markers(lesson[key])
|
||||||
|
fields = " ".join(
|
||||||
|
x for x in (lesson["subject"], lesson["teacher"], lesson["room"]) if x
|
||||||
|
)
|
||||||
|
keep = []
|
||||||
|
seen_notes = set()
|
||||||
|
for note_text, is_red in _collect_notes(block_text):
|
||||||
|
if not note_text or note_text in seen_notes:
|
||||||
|
continue
|
||||||
|
if not is_red and note_text in fields:
|
||||||
|
continue
|
||||||
|
seen_notes.add(note_text)
|
||||||
|
keep.append(note_text)
|
||||||
|
lesson["note"] = "\n".join(keep)
|
||||||
|
if lesson["marker"]:
|
||||||
|
last_marker = lesson["marker"]
|
||||||
|
elif last_marker:
|
||||||
|
lesson["marker"] = last_marker
|
||||||
|
lessons.append(lesson)
|
||||||
|
return lessons
|
||||||
|
|
||||||
|
|
||||||
|
def build_group_schedule(data, group_name):
|
||||||
|
group = data.get(group_name)
|
||||||
|
if group is None:
|
||||||
|
return None
|
||||||
|
out = {
|
||||||
|
"group": group_name,
|
||||||
|
"semester_until": group.get("semester_until"),
|
||||||
|
"days": {},
|
||||||
|
}
|
||||||
|
for day, periods in group["days"].items():
|
||||||
|
out["days"][day] = {}
|
||||||
|
for period, slot in periods.items():
|
||||||
|
entry = {"time": slot.get("time", "")}
|
||||||
|
for parity in ("ч", "з"):
|
||||||
|
raw = slot.get(parity)
|
||||||
|
if raw:
|
||||||
|
entry[parity] = {"raw": raw, "lessons": _split_lessons(raw)}
|
||||||
|
else:
|
||||||
|
entry[parity] = None
|
||||||
|
out["days"][day][str(period)] = entry
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_ROOM_RE = re.compile(r"ауд\.\s*([\w\-]+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_lessons(schedule_data):
|
||||||
|
for group, gdata in schedule_data.items():
|
||||||
|
for day, periods in gdata.get("days", {}).items():
|
||||||
|
for period, slot in periods.items():
|
||||||
|
time = slot.get("time", "")
|
||||||
|
for parity in ("ч", "з"):
|
||||||
|
raw = slot.get(parity)
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
for lesson in _split_lessons(raw):
|
||||||
|
yield group, day, period, time, parity, lesson
|
||||||
|
|
||||||
|
|
||||||
|
def _is_name(text):
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
if text.startswith(("(", "—", "–", "-", "«", '"')):
|
||||||
|
return False
|
||||||
|
if any(w in text for w in ("не будет", "отменяется", "переносится", "пара будет")):
|
||||||
|
return False
|
||||||
|
if not re.search(r"[А-ЯЁа-яё]", text):
|
||||||
|
return False
|
||||||
|
if re.search(r"\d", text) and not re.search(r"[А-ЯЁ]\.[А-ЯЁ]", text):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_NAME_RE = re.compile(r"[А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ]\.[А-ЯЁ]\.?)+")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_teachers(text):
|
||||||
|
return [m.strip() for m in _NAME_RE.findall(text) if m.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_org(text):
|
||||||
|
if not _is_name(text):
|
||||||
|
return False
|
||||||
|
if any(ch in text for ch in "()"):
|
||||||
|
return False
|
||||||
|
if re.search(r"[А-ЯЁ]\.[А-ЯЁ]", text):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_ORG_LINE_RE = re.compile(
|
||||||
|
r"[А-ЯЁ][A-Za-zА-ЯЁа-яё-]*(?:\s+[А-ЯЁ][A-Za-zА-ЯЁа-яё-]+)?"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_teacher_line(text):
|
||||||
|
if re.search(r"[А-ЯЁ]\.[А-ЯЁ]\.?", text):
|
||||||
|
return True
|
||||||
|
return bool(_ORG_LINE_RE.fullmatch(text)) and len(text) <= 40
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_entry(slot_list, entry_key, fields):
|
||||||
|
for item in slot_list:
|
||||||
|
if all(item.get(k) == v for k, v in entry_key.items()):
|
||||||
|
return item
|
||||||
|
item = dict(entry_key)
|
||||||
|
item["groups"] = []
|
||||||
|
item.update(fields)
|
||||||
|
slot_list.append(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def build_teacher_room_index(schedule_data):
|
||||||
|
teachers = {}
|
||||||
|
rooms = {}
|
||||||
|
|
||||||
|
for group, day, period, time, parity, lesson in _iter_lessons(schedule_data):
|
||||||
|
subject = lesson["subject"]
|
||||||
|
room = lesson["room"].strip()
|
||||||
|
|
||||||
|
if lesson["teacher"]:
|
||||||
|
teachers_found = _extract_teachers(lesson["teacher"])
|
||||||
|
if not teachers_found and _is_org(lesson["teacher"]):
|
||||||
|
teachers_found = [lesson["teacher"].strip()]
|
||||||
|
|
||||||
|
for teacher in teachers_found:
|
||||||
|
days_map = teachers.setdefault(teacher, {}).setdefault(day, {})
|
||||||
|
slot = days_map.setdefault(
|
||||||
|
str(period), {"time": time, "ч": [], "з": []}
|
||||||
|
)
|
||||||
|
item = _merge_entry(
|
||||||
|
slot[parity],
|
||||||
|
{"subject": subject, "room": room},
|
||||||
|
{"marker": lesson["marker"]},
|
||||||
|
)
|
||||||
|
if group not in item["groups"]:
|
||||||
|
item["groups"].append(group)
|
||||||
|
|
||||||
|
display_teacher = ", ".join(teachers_found) if teachers_found else ""
|
||||||
|
else:
|
||||||
|
display_teacher = ""
|
||||||
|
|
||||||
|
room_m = _ROOM_RE.match(room)
|
||||||
|
if room_m:
|
||||||
|
label = "ауд. " + room_m.group(1)
|
||||||
|
days_map = rooms.setdefault(label, {}).setdefault(day, {})
|
||||||
|
slot = days_map.setdefault(
|
||||||
|
str(period), {"time": time, "ч": [], "з": []}
|
||||||
|
)
|
||||||
|
item = _merge_entry(
|
||||||
|
slot[parity],
|
||||||
|
{"subject": subject, "teacher": display_teacher},
|
||||||
|
{"marker": lesson["marker"]},
|
||||||
|
)
|
||||||
|
if group not in item["groups"]:
|
||||||
|
item["groups"].append(group)
|
||||||
|
|
||||||
|
return teachers, rooms
|
||||||
|
|
||||||
|
|
||||||
|
def sort_rooms(room_names):
|
||||||
|
def key(name):
|
||||||
|
m = re.search(r"(\d+)", name)
|
||||||
|
return (0, int(m.group(1))) if m else (1, name)
|
||||||
|
|
||||||
|
return sorted(room_names, key=key)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rooms_info(sheets):
|
||||||
|
"""Аудитории: номер (заголовок) -> оснащение/вместимость (2-я строка)."""
|
||||||
|
name = "аудитории"
|
||||||
|
if name not in sheets:
|
||||||
|
return {}
|
||||||
|
rows = sheets[name]["rows"]
|
||||||
|
if len(rows) < 2:
|
||||||
|
return {}
|
||||||
|
header = rows[0]
|
||||||
|
desc = rows[1]
|
||||||
|
info = {}
|
||||||
|
for col in range(6, max(len(header), len(desc))):
|
||||||
|
raw = _read_cell(header, col)
|
||||||
|
m = re.search(r"(\d+)", str(raw))
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
number = int(m.group(1))
|
||||||
|
text = " ".join(
|
||||||
|
line.strip()
|
||||||
|
for line in _read_cell(desc, col).split("\n")
|
||||||
|
if line.strip()
|
||||||
|
)
|
||||||
|
label = f"ауд. {number}"
|
||||||
|
info[label] = {"room": label, "info": text}
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _initials_from_parts(first, patronymic):
|
||||||
|
initials = ""
|
||||||
|
if first:
|
||||||
|
initials += first[0].upper() + "."
|
||||||
|
if patronymic:
|
||||||
|
initials += patronymic[0].upper() + "."
|
||||||
|
return initials
|
||||||
|
|
||||||
|
|
||||||
|
def parse_teachers_full(sheets):
|
||||||
|
"""Преподаватели: короткое «Фамилия И.О.» -> полное ФИО (3-я строка-заголовок)."""
|
||||||
|
name = "Преподаватели"
|
||||||
|
if name not in sheets:
|
||||||
|
return {}
|
||||||
|
rows = sheets[name]["rows"]
|
||||||
|
if len(rows) < 2:
|
||||||
|
return {}
|
||||||
|
header = rows[1]
|
||||||
|
result = {}
|
||||||
|
for col in range(6, len(header)):
|
||||||
|
value = _read_cell(header, col)
|
||||||
|
parts = [p.strip() for p in re.split(r"[\s\n]+", value) if p.strip()]
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
surname = parts[0]
|
||||||
|
first = parts[1] if len(parts) > 1 else ""
|
||||||
|
patronymic = parts[2] if len(parts) > 2 else ""
|
||||||
|
short = f"{surname} {_initials_from_parts(first, patronymic)}".strip()
|
||||||
|
full = " ".join(parts)
|
||||||
|
if short and full:
|
||||||
|
result.setdefault(short, full)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_teacher_schedule(teachers, name):
|
||||||
|
if name not in teachers:
|
||||||
|
return None
|
||||||
|
return {"name": name, "days": teachers[name]}
|
||||||
|
|
||||||
|
|
||||||
|
def build_day_times(schedule_data):
|
||||||
|
day_times = {}
|
||||||
|
for gdata in schedule_data.values():
|
||||||
|
for day, periods in gdata.get("days", {}).items():
|
||||||
|
target = day_times.setdefault(day, {})
|
||||||
|
for period, slot in periods.items():
|
||||||
|
target.setdefault(str(period), slot.get("time", ""))
|
||||||
|
return day_times
|
||||||
|
|
||||||
|
|
||||||
|
def build_room_schedule(rooms, label, day_times=None):
|
||||||
|
if label not in rooms:
|
||||||
|
return None
|
||||||
|
data = rooms[label]
|
||||||
|
out = {"room": label, "days": {}}
|
||||||
|
day_times = day_times or {}
|
||||||
|
all_days = {*data.keys(), *day_times.keys()}
|
||||||
|
for day in sorted(all_days, key=lambda d: DAYS.index(d) if d in DAYS else 99):
|
||||||
|
out["days"][day] = {}
|
||||||
|
times = day_times.get(day, {})
|
||||||
|
periods = {*data.get(day, {}).keys(), *times.keys()}
|
||||||
|
for period in sorted(periods, key=lambda p: int(p)):
|
||||||
|
entry = {"time": times.get(period, data.get(day, {}).get(period, {}).get("time", ""))}
|
||||||
|
occupied = data.get(day, {}).get(period, {})
|
||||||
|
for parity in ("ч", "з"):
|
||||||
|
lessons = occupied.get(parity) or []
|
||||||
|
entry[parity] = lessons if lessons else None
|
||||||
|
out["days"][day][str(period)] = entry
|
||||||
|
return out
|
||||||
93
src/frontend/main.py
Normal file
93
src/frontend/main.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import uvicorn
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
|
||||||
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
app = FastAPI(title="Расписание — фронт")
|
||||||
|
|
||||||
|
|
||||||
|
def _proxy(path: str, params: dict):
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{SCHEDULE_API}/{path}", params=params, timeout=30)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
try:
|
||||||
|
detail = resp.json().get("detail", resp.text)
|
||||||
|
except Exception:
|
||||||
|
detail = resp.text
|
||||||
|
raise HTTPException(status_code=resp.status_code, detail=detail)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/groups")
|
||||||
|
def api_groups(force: bool = Query(False)):
|
||||||
|
return _proxy("groups", {"force": force})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/schedule/{group}")
|
||||||
|
def api_schedule(group: str, force: bool = Query(False)):
|
||||||
|
return _proxy(f"schedule/{group}", {"force": force})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/teachers")
|
||||||
|
def api_teachers(force: bool = Query(False)):
|
||||||
|
return _proxy("teachers", {"force": force})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/rooms")
|
||||||
|
def api_rooms(force: bool = Query(False)):
|
||||||
|
return _proxy("rooms", {"force": force})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/teacher/{name}")
|
||||||
|
def api_teacher(name: str, force: bool = Query(False)):
|
||||||
|
return _proxy(f"teacher/{name}", {"force": force})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/room/{label}")
|
||||||
|
def api_room(label: str, force: bool = Query(False)):
|
||||||
|
return _proxy(f"room/{label}", {"force": force})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/rooms-free")
|
||||||
|
def api_rooms_free(day: str, period: int, force: bool = Query(False)):
|
||||||
|
return _proxy("rooms-free", {"day": day, "period": period, "force": force})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/rooms-day")
|
||||||
|
def api_rooms_day(day: str, force: bool = Query(False)):
|
||||||
|
return _proxy("rooms-day", {"day": day, "force": force})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/week")
|
||||||
|
def api_week(force: bool = Query(False)):
|
||||||
|
return _proxy("week", {"force": force})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
def api_status(force: bool = Query(False)):
|
||||||
|
return _proxy("status", {"force": force})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index():
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(app, host="127.0.0.1", port=8080)
|
||||||
|
|
||||||
526
src/frontend/static/app.js
Normal file
526
src/frontend/static/app.js
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
const groupSelect = document.getElementById("group-select");
|
||||||
|
const entityLabel = document.getElementById("entity-label");
|
||||||
|
const themeToggle = document.getElementById("theme-toggle");
|
||||||
|
const scheduleEl = document.getElementById("schedule");
|
||||||
|
const errorEl = document.getElementById("error");
|
||||||
|
const loadingEl = document.getElementById("loading");
|
||||||
|
|
||||||
|
const RUSSIAN_DAYS = ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
|
||||||
|
const DAY_ORDER = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"];
|
||||||
|
|
||||||
|
const MODES = {
|
||||||
|
groups: { label: "Группа", listUrl: "/api/groups", valueKey: "schedule-group" },
|
||||||
|
teachers: { label: "Преподаватель", listUrl: "/api/teachers", valueKey: "schedule-teacher" },
|
||||||
|
rooms: { label: "Аудитория", listUrl: "/api/rooms", valueKey: "schedule-room" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARITY_LABEL = { ч: "Числитель", з: "Знаменатель" };
|
||||||
|
const PARITY_CLASS = { ч: "badge badge-ch", з: "badge badge-zn" };
|
||||||
|
|
||||||
|
let currentParity = null;
|
||||||
|
let mode = "groups";
|
||||||
|
|
||||||
|
function todayName() {
|
||||||
|
return RUSSIAN_DAYS[new Date().getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(value) {
|
||||||
|
return encodeURIComponent(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJSON(url) {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) {
|
||||||
|
let detail = resp.statusText;
|
||||||
|
try {
|
||||||
|
const body = await resp.json();
|
||||||
|
if (body.detail) detail = body.detail;
|
||||||
|
} catch (_) {}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWeek() {
|
||||||
|
try {
|
||||||
|
const data = await fetchJSON("/api/week");
|
||||||
|
currentParity = data.parity;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDT(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
try {
|
||||||
|
return await fetchJSON("/api/status");
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- навигация по вкладкам ---------- */
|
||||||
|
|
||||||
|
function setActiveMenu(view) {
|
||||||
|
document.querySelectorAll(".side-menu a").forEach((a) => {
|
||||||
|
a.classList.toggle("active", a.dataset.view === view);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillOptions(list) {
|
||||||
|
groupSelect.innerHTML = "";
|
||||||
|
list.forEach((item) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = item;
|
||||||
|
opt.textContent = item;
|
||||||
|
groupSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMode(next) {
|
||||||
|
mode = next;
|
||||||
|
localStorage.setItem("schedule-mode", next);
|
||||||
|
entityLabel.textContent = MODES[next].label;
|
||||||
|
setActiveMenu(next);
|
||||||
|
if (next === "rooms") {
|
||||||
|
entityLabel.textContent = "День";
|
||||||
|
groupSelect.classList.remove("hidden");
|
||||||
|
fillOptions(FREE_WEEKDAYS);
|
||||||
|
const savedDay = localStorage.getItem("schedule-room-day");
|
||||||
|
if (savedDay && FREE_WEEKDAYS.includes(savedDay)) groupSelect.value = savedDay;
|
||||||
|
await renderRoomsView(groupSelect.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
groupSelect.classList.remove("hidden");
|
||||||
|
const list = await fetchJSON(MODES[next].listUrl);
|
||||||
|
fillOptions(list);
|
||||||
|
if (!list.length) return;
|
||||||
|
const saved = localStorage.getItem(MODES[next].valueKey);
|
||||||
|
if (saved && list.includes(saved)) groupSelect.value = saved;
|
||||||
|
await renderView();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderView(force = false) {
|
||||||
|
const value = groupSelect.value;
|
||||||
|
if (!value) return;
|
||||||
|
localStorage.setItem(MODES[mode].valueKey, value);
|
||||||
|
scheduleEl.innerHTML = "";
|
||||||
|
errorEl.classList.add("hidden");
|
||||||
|
loadingEl.classList.remove("hidden");
|
||||||
|
try {
|
||||||
|
if (mode === "groups") {
|
||||||
|
const [data, status] = await Promise.all([
|
||||||
|
fetchJSON(`/api/schedule/${enc(value)}${force ? "?force=1" : ""}`),
|
||||||
|
loadStatus(),
|
||||||
|
]);
|
||||||
|
renderGroupSchedule(data, status);
|
||||||
|
} else {
|
||||||
|
const [data, status] = await Promise.all([
|
||||||
|
fetchJSON(`/api/teacher/${enc(value)}`),
|
||||||
|
loadStatus(),
|
||||||
|
]);
|
||||||
|
renderTeacher(data, status);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = "Ошибка: " + err.message;
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
} finally {
|
||||||
|
loadingEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- общие элементы ---------- */
|
||||||
|
|
||||||
|
function statusChip(label, value) {
|
||||||
|
const chip = document.createElement("div");
|
||||||
|
chip.className = "status-chip";
|
||||||
|
const lbl = document.createElement("span");
|
||||||
|
lbl.className = "status-label";
|
||||||
|
lbl.textContent = label;
|
||||||
|
const val = document.createElement("span");
|
||||||
|
val.className = "status-value";
|
||||||
|
val.textContent = value;
|
||||||
|
chip.appendChild(lbl);
|
||||||
|
chip.appendChild(val);
|
||||||
|
return chip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusRowHTML(status) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "schedule-status";
|
||||||
|
if (status) {
|
||||||
|
const modified = formatDT(status.table_modified);
|
||||||
|
const synced = formatDT(status.last_updated);
|
||||||
|
if (modified) row.appendChild(statusChip("Изменение таблицы", modified));
|
||||||
|
if (synced) row.appendChild(statusChip("Синхронизация", synced));
|
||||||
|
}
|
||||||
|
if (currentParity) {
|
||||||
|
const chip = document.createElement("div");
|
||||||
|
chip.className = "status-chip current-week";
|
||||||
|
const lbl = document.createElement("span");
|
||||||
|
lbl.className = "status-label";
|
||||||
|
lbl.textContent = "Сейчас";
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "badge active " + (currentParity === "ч" ? "badge-ch" : "badge-zn");
|
||||||
|
badge.textContent = PARITY_LABEL[currentParity];
|
||||||
|
chip.appendChild(lbl);
|
||||||
|
chip.appendChild(badge);
|
||||||
|
row.appendChild(chip);
|
||||||
|
}
|
||||||
|
return row.childNodes.length ? row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDayBlock(day) {
|
||||||
|
const block = document.createElement("div");
|
||||||
|
block.className = "day-block" + (day === todayName() ? " today" : "");
|
||||||
|
const head = document.createElement("div");
|
||||||
|
head.className = "day-head";
|
||||||
|
head.innerHTML = `<span class="day-title">${day}</span>${day === todayName() ? '<span class="today-badge">сегодня</span>' : ""}`;
|
||||||
|
block.appendChild(head);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parityColHTML(label, bodyHtml) {
|
||||||
|
return `<div class="parity-col">${bodyHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alwaysColHTML(lessonsHtml) {
|
||||||
|
return `<div class="parity-col">${lessonsHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OKNO_HTML = '<div class="lesson window">Окно</div>';
|
||||||
|
const FREE_HTML = '<div class="lesson window">Свободна</div>';
|
||||||
|
|
||||||
|
function addPeriodRow(block, num, time, lessonsHTML) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "period";
|
||||||
|
row.innerHTML = `<div><div class="period-num">Пара ${num}</div><div class="period-time">${escapeHTML(time || "")}</div></div>`;
|
||||||
|
const col = document.createElement("div");
|
||||||
|
col.innerHTML = lessonsHTML;
|
||||||
|
row.appendChild(col);
|
||||||
|
block.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityLessonHTML(parity, entry) {
|
||||||
|
const active = parity === currentParity ? " active" : "";
|
||||||
|
const details = [];
|
||||||
|
if (entry.room) details.push(entry.room);
|
||||||
|
if (entry.teacher) details.push(entry.teacher);
|
||||||
|
if (entry.groups && entry.groups.length) details.push(entry.groups.join(", "));
|
||||||
|
return `
|
||||||
|
<div class="lesson">
|
||||||
|
<div>
|
||||||
|
<span class="${PARITY_CLASS[parity]}${active}">${PARITY_LABEL[parity]}</span>
|
||||||
|
${entry.marker ? `<span class="badge badge-kv">${escapeHTML(entry.marker)}</span>` : ""}
|
||||||
|
<span class="subject">${escapeHTML(entry.subject)}</span>
|
||||||
|
</div>
|
||||||
|
${details.length ? `<div class="details">${escapeHTML(details.join(" · "))}</div>` : ""}
|
||||||
|
${entry.note ? `<div class="lesson-note">${escapeHTML(entry.note)}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleLessonHTML(badgeKey, lesson, full) {
|
||||||
|
const config = {
|
||||||
|
always: { label: "Обе недели", cls: "badge badge-always" },
|
||||||
|
ч: { label: "Числитель", cls: "badge badge-ch" },
|
||||||
|
з: { label: "Знаменатель", cls: "badge badge-zn" },
|
||||||
|
};
|
||||||
|
const { label, cls } = config[badgeKey];
|
||||||
|
const active = badgeKey !== "always" && badgeKey === currentParity ? " active" : "";
|
||||||
|
const details = [lesson.teacher, lesson.room].filter(Boolean).join(" · ");
|
||||||
|
return `
|
||||||
|
<div class="lesson${full ? " full" : ""}">
|
||||||
|
<div>
|
||||||
|
<span class="${cls}${active}">${label}</span>
|
||||||
|
${lesson.marker ? `<span class="badge badge-kv">${escapeHTML(lesson.marker)}</span>` : ""}
|
||||||
|
<span class="subject">${escapeHTML(lesson.subject)}</span>
|
||||||
|
</div>
|
||||||
|
${details ? `<div class="details">${escapeHTML(details)}</div>` : ""}
|
||||||
|
${lesson.note ? `<div class="lesson-note">${escapeHTML(lesson.note)}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- рендер расписания группы ---------- */
|
||||||
|
|
||||||
|
function renderGroupSchedule(data, status) {
|
||||||
|
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||||||
|
className: "schedule-title",
|
||||||
|
textContent: `Расписание группы ${data.group}`,
|
||||||
|
}));
|
||||||
|
if (data.semester_until) {
|
||||||
|
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||||||
|
className: "schedule-meta",
|
||||||
|
textContent: `Семестр до ${data.semester_until}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const top = statusRowHTML(status);
|
||||||
|
if (top) scheduleEl.appendChild(top);
|
||||||
|
|
||||||
|
const daysContainer = document.createElement("div");
|
||||||
|
scheduleEl.appendChild(daysContainer);
|
||||||
|
let hasAnyLessons = false;
|
||||||
|
|
||||||
|
DAY_ORDER.forEach((day) => {
|
||||||
|
const periods = data.days[day];
|
||||||
|
if (!periods) return;
|
||||||
|
const block = makeDayBlock(day);
|
||||||
|
|
||||||
|
const rows = Object.entries(periods)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([num, p]) => {
|
||||||
|
const always = p.ч && p.з && p.ч.raw === p.з.raw;
|
||||||
|
return { num, p, always, hasLesson: Boolean(always || p.ч || p.з) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastWithLesson = rows.reduce((acc, r, i) => (r.hasLesson ? i : acc), -1);
|
||||||
|
if (lastWithLesson === -1) {
|
||||||
|
block.appendChild(Object.assign(document.createElement("div"), {
|
||||||
|
className: "lesson empty",
|
||||||
|
textContent: "Занятий нет",
|
||||||
|
}));
|
||||||
|
daysContainer.appendChild(block);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.slice(0, lastWithLesson + 1).forEach(({ num, p, always }) => {
|
||||||
|
let inner;
|
||||||
|
if (always) {
|
||||||
|
inner = `<div class="parities">${alwaysColHTML(p.ч.lessons.map((l) => singleLessonHTML("always", l)).join(""))}</div>`;
|
||||||
|
hasAnyLessons = true;
|
||||||
|
} else {
|
||||||
|
const chHtml = p.ч ? p.ч.lessons.map((l) => singleLessonHTML("ч", l)).join("") : null;
|
||||||
|
const znHtml = p.з ? p.з.lessons.map((l) => singleLessonHTML("з", l)).join("") : null;
|
||||||
|
if (!chHtml && !znHtml) {
|
||||||
|
inner = `<div class="parities">${parityColHTML("Числитель", OKNO_HTML)}</div>`;
|
||||||
|
} else {
|
||||||
|
inner = `<div class="parities">${parityColHTML("Числитель", chHtml || OKNO_HTML)}${parityColHTML("Знаменатель", znHtml || OKNO_HTML)}</div>`;
|
||||||
|
hasAnyLessons = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addPeriodRow(block, num, p.time, inner);
|
||||||
|
});
|
||||||
|
|
||||||
|
daysContainer.appendChild(block);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasAnyLessons) {
|
||||||
|
daysContainer.innerHTML = "";
|
||||||
|
daysContainer.appendChild(Object.assign(document.createElement("div"), {
|
||||||
|
className: "empty-day",
|
||||||
|
textContent: "Расписание пустое",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEACHER_DAYS = DAY_ORDER.slice(0, 6);
|
||||||
|
|
||||||
|
/* ---------- рендер преподавателя ---------- */
|
||||||
|
|
||||||
|
function renderTeacher(data, status) {
|
||||||
|
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||||||
|
className: "schedule-title",
|
||||||
|
textContent: `Преподаватель: ${data.full_name || data.name}`,
|
||||||
|
}));
|
||||||
|
const top = statusRowHTML(status);
|
||||||
|
if (top) scheduleEl.appendChild(top);
|
||||||
|
|
||||||
|
const daysContainer = document.createElement("div");
|
||||||
|
scheduleEl.appendChild(daysContainer);
|
||||||
|
let hasAny = false;
|
||||||
|
|
||||||
|
TEACHER_DAYS.forEach((day) => {
|
||||||
|
const periods = data.days[day];
|
||||||
|
const occupied = periods && Object.values(periods).some((s) => (s.ч || []).length || (s.з || []).length);
|
||||||
|
const block = makeDayBlock(day);
|
||||||
|
if (!occupied) {
|
||||||
|
block.appendChild(Object.assign(document.createElement("div"), {
|
||||||
|
className: "lesson empty",
|
||||||
|
textContent: "Занятий нет",
|
||||||
|
}));
|
||||||
|
daysContainer.appendChild(block);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.entries(periods)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.forEach(([num, slot]) => {
|
||||||
|
const chHtml = (slot.ч || []).map((entry) => { hasAny = true; return entityLessonHTML("ч", entry); }).join("");
|
||||||
|
const znHtml = (slot.з || []).map((entry) => { hasAny = true; return entityLessonHTML("з", entry); }).join("");
|
||||||
|
if (!chHtml && !znHtml) return;
|
||||||
|
const inner = `<div class="parities">${parityColHTML("Числитель", chHtml || OKNO_HTML)}${parityColHTML("Знаменатель", znHtml || OKNO_HTML)}</div>`;
|
||||||
|
addPeriodRow(block, num, slot.time, inner);
|
||||||
|
});
|
||||||
|
daysContainer.appendChild(block);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasAny) {
|
||||||
|
daysContainer.innerHTML = "";
|
||||||
|
daysContainer.appendChild(Object.assign(document.createElement("div"), {
|
||||||
|
className: "empty-day",
|
||||||
|
textContent: "Занятий нет",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- аудитории: занятость по выбранному дню ---------- */
|
||||||
|
|
||||||
|
const FREE_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
|
||||||
|
|
||||||
|
async function renderRoomsView(day) {
|
||||||
|
const chosenDay = FREE_WEEKDAYS.includes(day) ? day : (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник");
|
||||||
|
scheduleEl.innerHTML = "";
|
||||||
|
errorEl.classList.add("hidden");
|
||||||
|
loadingEl.classList.remove("hidden");
|
||||||
|
try {
|
||||||
|
const status = await loadStatus();
|
||||||
|
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||||||
|
className: "schedule-title",
|
||||||
|
textContent: "Занятость аудиторий",
|
||||||
|
}));
|
||||||
|
const top = statusRowHTML(status);
|
||||||
|
if (top) scheduleEl.appendChild(top);
|
||||||
|
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "rooms-wrap";
|
||||||
|
scheduleEl.appendChild(wrap);
|
||||||
|
|
||||||
|
const data = await fetchJSON(`/api/rooms-day?day=${enc(chosenDay)}`);
|
||||||
|
wrap.appendChild(roomsDayTable(data));
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = "Ошибка: " + err.message;
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
} finally {
|
||||||
|
loadingEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomsDayTable(data) {
|
||||||
|
const periodNums = [];
|
||||||
|
data.rooms.forEach((r) => {
|
||||||
|
Object.keys(r.periods || {}).forEach((p) => {
|
||||||
|
if (!periodNums.includes(p)) periodNums.push(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
periodNums.sort((a, b) => Number(a) - Number(b));
|
||||||
|
return buildRoomsList(data, periodNums);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoomsList(data, periodNums) {
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "rooms-list";
|
||||||
|
data.rooms.forEach((room) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "room-card";
|
||||||
|
const head = document.createElement("div");
|
||||||
|
head.className = "day-head";
|
||||||
|
head.innerHTML = `<div class="room-title"><span class="day-title">${escapeHTML(room.room)}</span>${room.info ? `<div class="room-info">${escapeHTML(room.info)}</div>` : ""}</div>`;
|
||||||
|
card.appendChild(head);
|
||||||
|
const periods = room.periods || {};
|
||||||
|
periodNums.forEach((p) => {
|
||||||
|
const slot = periods[p] || {};
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "room-row";
|
||||||
|
const left = document.createElement("div");
|
||||||
|
left.className = "room-row-num";
|
||||||
|
left.innerHTML = `Пара ${p}${slot.time ? `<div class="period-time">${escapeHTML(slot.time)}</div>` : ""}`;
|
||||||
|
row.appendChild(left);
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "room-row-body";
|
||||||
|
const chEntries = slot.ч || [];
|
||||||
|
const znEntries = slot.з || [];
|
||||||
|
if (!chEntries.length && !znEntries.length) {
|
||||||
|
body.innerHTML = `<div class="parities">${parityColHTML("", OKNO_HTML)}</div>`;
|
||||||
|
} else {
|
||||||
|
const chHtml = chEntries.map((e) => entityLessonHTML("ч", e)).join("");
|
||||||
|
const znHtml = znEntries.map((e) => entityLessonHTML("з", e)).join("");
|
||||||
|
body.innerHTML = `<div class="parities">${parityColHTML("", chHtml || OKNO_HTML)}${parityColHTML("", znHtml || OKNO_HTML)}</div>`;
|
||||||
|
}
|
||||||
|
row.appendChild(body);
|
||||||
|
card.appendChild(row);
|
||||||
|
});
|
||||||
|
list.appendChild(card);
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomPeriodCell(slot) {
|
||||||
|
const td = document.createElement("td");
|
||||||
|
td.className = "r-cell";
|
||||||
|
const all = [...((slot && slot.ч) || []), ...((slot && slot.з) || [])];
|
||||||
|
if (!all.length) {
|
||||||
|
td.classList.add("r-free");
|
||||||
|
td.textContent = "окно";
|
||||||
|
return td;
|
||||||
|
}
|
||||||
|
td.classList.add("r-busy");
|
||||||
|
const seen = new Set();
|
||||||
|
const lines = [];
|
||||||
|
all.forEach((e) => {
|
||||||
|
if (!e.subject || seen.has(e.subject)) return;
|
||||||
|
seen.add(e.subject);
|
||||||
|
lines.push(e.subject);
|
||||||
|
});
|
||||||
|
lines.forEach((x) => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "r-subj";
|
||||||
|
div.textContent = x;
|
||||||
|
td.appendChild(div);
|
||||||
|
});
|
||||||
|
td.title = lines.join("; ");
|
||||||
|
return td;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(s) {
|
||||||
|
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
localStorage.setItem("theme", theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme() {
|
||||||
|
const saved = localStorage.getItem("theme");
|
||||||
|
applyTheme(saved === "dark" ? "dark" : "light");
|
||||||
|
}
|
||||||
|
|
||||||
|
themeToggle.addEventListener("click", () => {
|
||||||
|
const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||||||
|
applyTheme(current === "dark" ? "light" : "dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
initTheme();
|
||||||
|
|
||||||
|
document.querySelectorAll(".side-menu a").forEach((link) => {
|
||||||
|
link.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMode(link.dataset.view).catch((err) => {
|
||||||
|
errorEl.textContent = "Ошибка: " + err.message;
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
groupSelect.addEventListener("change", () => {
|
||||||
|
if (mode === "rooms") {
|
||||||
|
localStorage.setItem("schedule-room-day", groupSelect.value);
|
||||||
|
renderRoomsView(groupSelect.value).catch((err) => {
|
||||||
|
errorEl.textContent = "Ошибка: " + err.message;
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderView();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadWeek().finally(() => {
|
||||||
|
const savedMode = localStorage.getItem("schedule-mode");
|
||||||
|
const initial = ["groups", "teachers", "rooms"].includes(savedMode) ? savedMode : "groups";
|
||||||
|
setMode(initial).catch((err) => {
|
||||||
|
errorEl.textContent = "Ошибка: " + err.message;
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
src/frontend/static/img/favicon.png
Normal file
BIN
src/frontend/static/img/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 315 B |
198
src/frontend/static/img/owl-white.svg
Normal file
198
src/frontend/static/img/owl-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 37 KiB |
61
src/frontend/static/img/owl.svg
Normal file
61
src/frontend/static/img/owl.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
58
src/frontend/static/index.html
Normal file
58
src/frontend/static/index.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Расписание · Факультет ИВТ ЯрГУ</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/img/owl-white.svg">
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="topbar-brand">
|
||||||
|
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener">
|
||||||
|
<img class="topbar-logo" src="/img/owl.svg" alt="ЯрГУ им. П.Г. Демидова">
|
||||||
|
</a>
|
||||||
|
<div class="topbar-text">
|
||||||
|
<a href="https://www.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-university">Ярославский государственный университет им. П.Г. Демидова</a>
|
||||||
|
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и вычислительной техники</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="theme-toggle" class="theme-toggle" title="Переключить тему">
|
||||||
|
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
|
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="side">
|
||||||
|
<div class="side-head">
|
||||||
|
<a href="/" class="side-logo-link"><img class="side-logo" src="/img/owl-white.svg" alt="Факультет ИВТ"></a>
|
||||||
|
<div class="side-name">Расписание занятий</div>
|
||||||
|
</div>
|
||||||
|
<div class="side-nav">
|
||||||
|
<nav class="side-menu">
|
||||||
|
<a href="#" class="active" data-view="groups">Расписание групп</a>
|
||||||
|
<a href="#" data-view="teachers">Преподаватели</a>
|
||||||
|
<a href="#" data-view="rooms">Аудитории</a>
|
||||||
|
</nav>
|
||||||
|
<div class="side-label" id="entity-label">Группа</div>
|
||||||
|
<select id="group-select" aria-label="Выбор группы"></select>
|
||||||
|
</div>
|
||||||
|
<div class="side-footer">
|
||||||
|
<a href="https://vk.com/ivt7k" target="_blank" rel="noopener">Сообщество ВКонтакте</a>
|
||||||
|
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener">Сайт факультета</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="error" class="error hidden"></div>
|
||||||
|
<div id="loading" class="loading hidden">Загрузка…</div>
|
||||||
|
<div id="schedule" class="schedule"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
683
src/frontend/static/style.css
Normal file
683
src/frontend/static/style.css
Normal file
@@ -0,0 +1,683 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f1f5f9;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f7fafc;
|
||||||
|
--surface-3: #eef2f8;
|
||||||
|
--border: #dde5ef;
|
||||||
|
--border-2: #c3d1e2;
|
||||||
|
--text: #17223a;
|
||||||
|
--muted: #57677f;
|
||||||
|
--muted-2: #8c9bb0;
|
||||||
|
|
||||||
|
--nav-1: #1b2dd8;
|
||||||
|
--nav-2: #167cf2;
|
||||||
|
--accent: #0241e3;
|
||||||
|
--accent-blue: #1f4fd0;
|
||||||
|
--accent-red: #d01838;
|
||||||
|
--accent-red-bright: #e61d43;
|
||||||
|
--accent-blue-bg: #e5ecfc;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
|
||||||
|
--error-bg: #fbe9ea;
|
||||||
|
--error-border: #f3c1c5;
|
||||||
|
--error-text: #b00d15;
|
||||||
|
--shadow: rgba(10, 50, 110, 0.12);
|
||||||
|
|
||||||
|
--badge-ch-bg: #e3ebfc;
|
||||||
|
--badge-ch-text: #1f4fd0;
|
||||||
|
--badge-zn-bg: #fbe3e7;
|
||||||
|
--badge-zn-text: #c40f30;
|
||||||
|
--badge-kv-bg: #edf1f7;
|
||||||
|
--badge-kv-text: #43536b;
|
||||||
|
--badge-always-bg: #e6edf6;
|
||||||
|
--badge-always-text: #2e3d55;
|
||||||
|
--badge-ch-active-bg: linear-gradient(90deg, #1b2dd8, #167cf2);
|
||||||
|
--badge-ch-active-text: #ffffff;
|
||||||
|
--badge-zn-active-bg: #e61d43;
|
||||||
|
--badge-zn-active-text: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #0a1120;
|
||||||
|
--surface: #111a2e;
|
||||||
|
--surface-2: #16223a;
|
||||||
|
--surface-3: #1d2e4c;
|
||||||
|
--border: #25375a;
|
||||||
|
--border-2: #3a4f78;
|
||||||
|
--text: #e7eef8;
|
||||||
|
--muted: #96adca;
|
||||||
|
--muted-2: #647e9f;
|
||||||
|
|
||||||
|
--nav-1: #1d2f72;
|
||||||
|
--nav-2: #1358b0;
|
||||||
|
--accent: #5ba3f7;
|
||||||
|
--accent-blue: #86b8ff;
|
||||||
|
--accent-red: #e96a7e;
|
||||||
|
--accent-red-bright: #ff6b7f;
|
||||||
|
--accent-blue-bg: #1a2d52;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
|
||||||
|
--error-bg: #4d1c22;
|
||||||
|
--error-border: #7c2a34;
|
||||||
|
--error-text: #ffc2c9;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.45);
|
||||||
|
|
||||||
|
--badge-ch-bg: #1a2c55;
|
||||||
|
--badge-ch-text: #8cb8ff;
|
||||||
|
--badge-zn-bg: #54222d;
|
||||||
|
--badge-zn-text: #ff9dae;
|
||||||
|
--badge-kv-bg: #1e2c43;
|
||||||
|
--badge-kv-text: #b6c6da;
|
||||||
|
--badge-always-bg: #23314d;
|
||||||
|
--badge-always-text: #d9e3f0;
|
||||||
|
--badge-ch-active-bg: #3d86f2;
|
||||||
|
--badge-ch-active-text: #ffffff;
|
||||||
|
--badge-zn-active-bg: #ff6479;
|
||||||
|
--badge-zn-active-text: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Roboto, -apple-system, "Segoe UI", Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { color: var(--accent-red); }
|
||||||
|
|
||||||
|
/* ---- Верхняя белая полоса с цветной совой (как на сайте) ---- */
|
||||||
|
.topbar {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 8px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-logo {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-text { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
|
||||||
|
|
||||||
|
.topbar-university {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.topbar-university:hover { color: var(--accent-red); }
|
||||||
|
|
||||||
|
.topbar-faculty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.topbar-faculty:hover { color: var(--accent-red); }
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.theme-toggle .icon { width: 18px; height: 18px; }
|
||||||
|
|
||||||
|
.icon-sun { display: none; }
|
||||||
|
.icon-moon { display: block; }
|
||||||
|
|
||||||
|
[data-theme="dark"] .icon-sun { display: block; }
|
||||||
|
[data-theme="dark"] .icon-moon { display: none; }
|
||||||
|
|
||||||
|
/* ---- Двухколоночный layout: синяя панель + контент ---- */
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: calc(100vh - 63px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: linear-gradient(to bottom, var(--nav-1), var(--nav-2));
|
||||||
|
color: var(--on-accent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 26px 22px 22px;
|
||||||
|
position: sticky;
|
||||||
|
top: 63px;
|
||||||
|
align-self: flex-start;
|
||||||
|
height: calc(100vh - 63px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-head { text-align: center; padding-bottom: 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.22); }
|
||||||
|
|
||||||
|
.side-logo-link { display: inline-block; }
|
||||||
|
.side-logo { width: 92px; height: 92px; }
|
||||||
|
|
||||||
|
.side-name {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav {
|
||||||
|
padding: 18px 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu a {
|
||||||
|
display: block;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu a:hover { color: #ffffff; background: rgba(0, 0, 0, 0.12); }
|
||||||
|
|
||||||
|
.side-menu a.active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-left-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#group-select {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: none;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #141c2b;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#group-select option { background: #ffffff; color: #141c2b; }
|
||||||
|
|
||||||
|
[data-theme="dark"] #group-select {
|
||||||
|
background: rgba(10, 19, 40, 0.6);
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] #group-select option {
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-footer {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
padding-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-footer a { color: rgba(255, 255, 255, 0.92); }
|
||||||
|
.side-footer a:hover { color: #ffffff; text-decoration: underline; }
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 26px 28px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule { max-width: 900px; }
|
||||||
|
|
||||||
|
.schedule-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-title .badge { margin-left: 10px; font-size: 13px; vertical-align: middle; }
|
||||||
|
|
||||||
|
.schedule-meta { color: var(--muted); font-size: 14px; margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.schedule-status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 170px;
|
||||||
|
min-height: 54px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.current-week { align-items: flex-start; }
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-week .badge { margin-right: 0; font-size: 12px; }
|
||||||
|
|
||||||
|
.day-block {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-block.today {
|
||||||
|
border-color: var(--accent-red-bright);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent-red-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-title { font-size: 15px; font-weight: 700; text-transform: capitalize; color: var(--text); }
|
||||||
|
|
||||||
|
.today-badge {
|
||||||
|
background: var(--accent-red-bright);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period {
|
||||||
|
padding: 8px 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.period-num { font-weight: 700; color: var(--text); }
|
||||||
|
|
||||||
|
.period-time { font-size: 13px; color: var(--muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
.parities {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parity-col {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parity-col .lesson {
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parity-col .window { justify-content: center; text-align: center; }
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.parities { flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 7px 11px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.lesson .subject { font-weight: 700; font-size: 14px; }
|
||||||
|
|
||||||
|
.lesson .details { font-size: 13px; color: var(--muted); margin-top: 1px; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-ch { background: var(--badge-ch-bg); color: var(--badge-ch-text); }
|
||||||
|
.badge-zn { background: var(--badge-zn-bg); color: var(--badge-zn-text); }
|
||||||
|
.badge-kv { background: var(--badge-kv-bg); color: var(--badge-kv-text); }
|
||||||
|
.badge-always { background: var(--badge-always-bg); color: var(--badge-always-text); }
|
||||||
|
|
||||||
|
.badge-ch.active { background: var(--badge-ch-active-bg); color: var(--badge-ch-active-text); }
|
||||||
|
.badge-zn.active { background: var(--badge-zn-active-bg); color: var(--badge-zn-active-text); }
|
||||||
|
|
||||||
|
.lesson.full { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
.empty-day { color: var(--muted-2); font-size: 14px; padding: 10px 14px; }
|
||||||
|
|
||||||
|
.lesson.empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface-3);
|
||||||
|
border: 1px dashed var(--border-2);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window {
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--muted-2);
|
||||||
|
background: var(--surface);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- поиск свободной аудитории ---- */
|
||||||
|
.free-widget {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-hint { font-size: 13px; color: var(--muted); }
|
||||||
|
|
||||||
|
.free-controls select {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-item {
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-item:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.free-room { font-weight: 700; }
|
||||||
|
|
||||||
|
.free-info { font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
.free-loading, .free-none { color: var(--muted); font-size: 14px; padding: 6px 0; }
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: var(--error-bg);
|
||||||
|
color: var(--error-text);
|
||||||
|
border: 1px solid var(--error-border);
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading { color: var(--muted); text-align: center; padding: 30px; }
|
||||||
|
|
||||||
|
.hidden { display: none; }
|
||||||
|
|
||||||
|
/* ---- Мобильная версия: панель сверху ---- */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.layout { flex-direction: column; }
|
||||||
|
|
||||||
|
.side {
|
||||||
|
width: 100%;
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
padding: 14px 16px;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-head {
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-logo { width: 52px; height: 52px; }
|
||||||
|
.side-name { margin-top: 0; font-size: 15px; }
|
||||||
|
.side-nav { padding: 0; flex: 1; min-width: 180px; }
|
||||||
|
.side-label { display: none; }
|
||||||
|
.side-footer { display: none; }
|
||||||
|
|
||||||
|
.side-menu {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.side-menu a {
|
||||||
|
border-left: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.side-menu a.active { border-bottom-color: #ffffff; }
|
||||||
|
|
||||||
|
main { padding: 16px 12px 30px; }
|
||||||
|
.period { grid-template-columns: 80px 1fr; gap: 10px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- занятость аудиторий по дню ---- */
|
||||||
|
.rooms-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms-controls select {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms-scroll { overflow-x: auto; }
|
||||||
|
|
||||||
|
.rooms-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 640px;
|
||||||
|
table-layout: fixed;
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms-table th:first-child,
|
||||||
|
.rooms-table td:first-child { width: 170px; }
|
||||||
|
|
||||||
|
.rooms-table th,
|
||||||
|
.rooms-table td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 9px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms-table th {
|
||||||
|
background: var(--surface-3);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.r-name {
|
||||||
|
font-weight: 700;
|
||||||
|
width: 170px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.r-name-info { font-weight: 400; color: var(--muted); font-size: 12px; margin-top: 2px; line-height: 1.35; }
|
||||||
|
|
||||||
|
.r-cell { min-width: 90px; }
|
||||||
|
|
||||||
|
.r-cell.r-free {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted-2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.r-cell.r-busy { background: var(--badge-ch-bg); }
|
||||||
|
|
||||||
|
.r-subj { font-weight: 600; color: var(--text); line-height: 1.3; }
|
||||||
|
|
||||||
|
/* ---- аудитории: список карточками (мобильная версия) ---- */
|
||||||
|
.rooms-list { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.room-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-title { line-height: 1.3; }
|
||||||
|
|
||||||
|
.room-info {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-row-num { font-weight: 700; color: var(--text); }
|
||||||
|
|
||||||
|
.room-row-body { min-width: 0; }
|
||||||
|
|
||||||
|
.room-lesson {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-lesson:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.lesson-note {
|
||||||
|
color: var(--accent-red-bright);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 4px;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user