Initial commit
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
160
.gitignore
vendored
Normal file
160
.gitignore
vendored
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
2
README.md
Normal file
2
README.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# kspsuti-teacher-schedule
|
||||||
|
Teacher's schedule of KS PSUTI
|
||||||
274
app.py
Normal file
274
app.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
from flask import Flask, render_template, jsonify, request
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import re
|
||||||
|
import yaml
|
||||||
|
import os
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# ── Загрузка конфига ─────────────────────────────────────────────────────────
|
||||||
|
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.yaml")
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
BASE_URL = config["base_url"]
|
||||||
|
DEFAULT_TEACHER = str(config.get("default_teacher", ""))
|
||||||
|
TEACHERS = {str(t["id"]): t for t in config.get("teachers", [])}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Парсер ───────────────────────────────────────────────────────────────────
|
||||||
|
def fetch_schedule(obj=None, wk=None):
|
||||||
|
if obj is None:
|
||||||
|
obj = DEFAULT_TEACHER
|
||||||
|
|
||||||
|
params = {"mn": "3", "obj": obj}
|
||||||
|
if wk:
|
||||||
|
params["wk"] = wk
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(BASE_URL, params=params, timeout=10)
|
||||||
|
resp.encoding = "utf-8"
|
||||||
|
html = resp.text
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e), "days": [], "week_range": "", "prev_wk": None, "next_wk": None}
|
||||||
|
|
||||||
|
return parse_schedule(html)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_schedule(html):
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
result = {
|
||||||
|
"days": [],
|
||||||
|
"week_range": "",
|
||||||
|
"prev_wk": None,
|
||||||
|
"next_wk": None,
|
||||||
|
"error": None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Диапазон недели
|
||||||
|
for td in soup.find_all("td"):
|
||||||
|
text = td.get_text(strip=True)
|
||||||
|
if re.match(r"с \d{2}\.\d{2}\.\d{4} по \d{2}\.\d{2}\.\d{4}", text):
|
||||||
|
result["week_range"] = text
|
||||||
|
break
|
||||||
|
|
||||||
|
# Ссылки на соседние недели
|
||||||
|
seen_wk = set()
|
||||||
|
for a in soup.find_all("a", href=True):
|
||||||
|
href = a["href"]
|
||||||
|
if "wk=" not in href:
|
||||||
|
continue
|
||||||
|
wk_val = re.search(r"wk=(\d+)", href)
|
||||||
|
if not wk_val:
|
||||||
|
continue
|
||||||
|
wk_num = int(wk_val.group(1))
|
||||||
|
if wk_num in seen_wk:
|
||||||
|
continue
|
||||||
|
seen_wk.add(wk_num)
|
||||||
|
text = a.get_text(strip=True).lower()
|
||||||
|
if "предыдущая" in text and result["prev_wk"] is None:
|
||||||
|
result["prev_wk"] = wk_num
|
||||||
|
elif "следующая" in text and result["next_wk"] is None:
|
||||||
|
result["next_wk"] = wk_num
|
||||||
|
|
||||||
|
# Дни недели
|
||||||
|
day_anchors = soup.find_all("a", class_="t_wth")
|
||||||
|
|
||||||
|
for anchor in day_anchors:
|
||||||
|
day_text = anchor.get_text(strip=True)
|
||||||
|
m = re.match(r"(\w+)\s*(\d{2}\.\d{2}\.\d{4})/(\d+)\s+неделя", day_text)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
|
||||||
|
day_name, day_date, week_num = m.group(1), m.group(2), m.group(3)
|
||||||
|
|
||||||
|
# Находим родительскую таблицу с парами
|
||||||
|
parent_table = anchor
|
||||||
|
for _ in range(10):
|
||||||
|
parent_table = parent_table.parent
|
||||||
|
if parent_table and parent_table.name == "table" and parent_table.get("cellpadding") == "1":
|
||||||
|
break
|
||||||
|
|
||||||
|
lessons = []
|
||||||
|
if parent_table:
|
||||||
|
for row in parent_table.find_all("tr"):
|
||||||
|
tds = row.find_all("td", recursive=False)
|
||||||
|
if len(tds) != 4:
|
||||||
|
continue
|
||||||
|
|
||||||
|
num_td = tds[0].get_text(strip=True)
|
||||||
|
time_td = tds[1].get_text(strip=True)
|
||||||
|
subj_td = tds[2]
|
||||||
|
room_td = tds[3].get_text(strip=True)
|
||||||
|
|
||||||
|
if not re.match(r"^\d+$", num_td):
|
||||||
|
continue
|
||||||
|
|
||||||
|
info = {"subject": "", "group": "", "group_short": "", "lesson_type": "", "location": ""}
|
||||||
|
|
||||||
|
bold = subj_td.find("b")
|
||||||
|
if bold:
|
||||||
|
info["subject"] = bold.get_text(strip=True)
|
||||||
|
|
||||||
|
font_green = subj_td.find("font", class_="t_green_10")
|
||||||
|
if font_green:
|
||||||
|
info["location"] = font_green.get_text(strip=True)
|
||||||
|
|
||||||
|
raw = ""
|
||||||
|
if bold:
|
||||||
|
for node in bold.next_siblings:
|
||||||
|
if hasattr(node, "name"):
|
||||||
|
if node.name == "font":
|
||||||
|
break
|
||||||
|
if node.name == "br":
|
||||||
|
continue
|
||||||
|
raw += node.get_text(strip=True)
|
||||||
|
else:
|
||||||
|
raw += str(node).strip()
|
||||||
|
raw = raw.strip()
|
||||||
|
|
||||||
|
if raw:
|
||||||
|
info["group"] = raw
|
||||||
|
m_grp = re.search(r'\(([^)]+)\)', raw)
|
||||||
|
if m_grp:
|
||||||
|
info["group_short"] = m_grp.group(1)
|
||||||
|
|
||||||
|
after = raw[raw.find(")")+1:].strip() if ")" in raw else ""
|
||||||
|
if after:
|
||||||
|
unwrapped = re.sub(r'^\((.+)\)$', r'\1', after.strip())
|
||||||
|
inner = re.search(r'\(([^()]+)\)\s*$', unwrapped)
|
||||||
|
info["lesson_type"] = inner.group(1) if inner else unwrapped
|
||||||
|
|
||||||
|
lessons.append({
|
||||||
|
"num": num_td,
|
||||||
|
"time": time_td,
|
||||||
|
"subject": info["subject"],
|
||||||
|
"group": info["group"],
|
||||||
|
"group_short": info["group_short"],
|
||||||
|
"lesson_type": info["lesson_type"],
|
||||||
|
"location": info["location"],
|
||||||
|
"room": room_td,
|
||||||
|
"has_class": bool(info["subject"])
|
||||||
|
})
|
||||||
|
|
||||||
|
result["days"].append({
|
||||||
|
"name": day_name,
|
||||||
|
"date": day_date,
|
||||||
|
"week_num": week_num,
|
||||||
|
"lessons": lessons,
|
||||||
|
"has_classes": any(l["has_class"] for l in lessons)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── Кэш списка преподавателей ────────────────────────────────────────────────
|
||||||
|
_teachers_cache = {"data": None, "ts": 0}
|
||||||
|
CACHE_TTL = 3600 # обновлять раз в час
|
||||||
|
|
||||||
|
def fetch_all_teachers():
|
||||||
|
import time
|
||||||
|
now = time.time()
|
||||||
|
if _teachers_cache["data"] and now - _teachers_cache["ts"] < CACHE_TTL:
|
||||||
|
return _teachers_cache["data"]
|
||||||
|
try:
|
||||||
|
resp = requests.get(BASE_URL, params={"mn": "3"}, timeout=10)
|
||||||
|
resp.encoding = "utf-8"
|
||||||
|
soup = BeautifulSoup(resp.text, "lxml")
|
||||||
|
teachers = []
|
||||||
|
for a in soup.find_all("a", href=lambda h: h and "obj=" in h):
|
||||||
|
m = re.search(r"obj=(\d+)", a["href"])
|
||||||
|
bold = a.find("b")
|
||||||
|
if m and bold:
|
||||||
|
teachers.append({
|
||||||
|
"id": m.group(1),
|
||||||
|
"name": bold.get_text(strip=True)
|
||||||
|
})
|
||||||
|
_teachers_cache["data"] = teachers
|
||||||
|
_teachers_cache["ts"] = now
|
||||||
|
return teachers
|
||||||
|
except Exception as e:
|
||||||
|
return []
|
||||||
|
|
||||||
|
@app.route("/api/all-teachers")
|
||||||
|
def api_all_teachers():
|
||||||
|
"""Полный список преподавателей с сайта (с кэшем на 1 час)."""
|
||||||
|
teachers = fetch_all_teachers()
|
||||||
|
return jsonify({"teachers": teachers, "count": len(teachers)})
|
||||||
|
|
||||||
|
# ── Маршруты ─────────────────────────────────────────────────────────────────
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
@app.route("/api/teachers")
|
||||||
|
def api_teachers():
|
||||||
|
teachers_list = [
|
||||||
|
{"id": str(t["id"]), "name": t["name"], "short": t.get("short", t["name"])}
|
||||||
|
for t in config.get("teachers", [])
|
||||||
|
]
|
||||||
|
return jsonify({"teachers": teachers_list, "default": DEFAULT_TEACHER})
|
||||||
|
|
||||||
|
@app.route("/api/schedule")
|
||||||
|
def api_schedule():
|
||||||
|
obj = request.args.get("obj", DEFAULT_TEACHER)
|
||||||
|
wk = request.args.get("wk", None)
|
||||||
|
# Имя: сначала из конфига, потом из полного списка
|
||||||
|
teacher_info = TEACHERS.get(str(obj), {})
|
||||||
|
name = teacher_info.get("name", "")
|
||||||
|
if not name:
|
||||||
|
all_t = fetch_all_teachers()
|
||||||
|
match = next((t for t in all_t if t["id"] == str(obj)), None)
|
||||||
|
if match:
|
||||||
|
name = match["name"]
|
||||||
|
data = fetch_schedule(obj=obj, wk=wk)
|
||||||
|
data["teacher_name"] = name
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
@app.route("/api/reload-config")
|
||||||
|
def api_reload():
|
||||||
|
global config, BASE_URL, DEFAULT_TEACHER, TEACHERS
|
||||||
|
config = load_config()
|
||||||
|
BASE_URL = config["base_url"]
|
||||||
|
DEFAULT_TEACHER = str(config.get("default_teacher", ""))
|
||||||
|
TEACHERS = {str(t["id"]): t for t in config.get("teachers", [])}
|
||||||
|
return jsonify({"ok": True, "teachers_count": len(TEACHERS)})
|
||||||
|
|
||||||
|
|
||||||
|
#if __name__ == "__main__":
|
||||||
|
# app.run(debug=False, host="0.0.0.0", port=5609)
|
||||||
|
|
||||||
|
@app.route("/api/debug")
|
||||||
|
def api_debug():
|
||||||
|
"""Отладка: показывает что нашёл парсер."""
|
||||||
|
obj = request.args.get("obj", DEFAULT_TEACHER)
|
||||||
|
params = {"mn": "3", "obj": obj}
|
||||||
|
try:
|
||||||
|
resp = requests.get(BASE_URL, params=params, timeout=10)
|
||||||
|
resp.encoding = "utf-8"
|
||||||
|
html = resp.text
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)})
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
day_anchors = soup.find_all("a", class_="t_wth")
|
||||||
|
week_range = ""
|
||||||
|
for td in soup.find_all("td"):
|
||||||
|
text = td.get_text(strip=True)
|
||||||
|
if re.match(r"с \d{2}\.\d{2}\.\d{4} по \d{2}\.\d{2}\.\d{4}", text):
|
||||||
|
week_range = text
|
||||||
|
break
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"html_length": len(html),
|
||||||
|
"html_snippet": html[:300],
|
||||||
|
"week_range_found": week_range,
|
||||||
|
"day_anchors_count": len(day_anchors),
|
||||||
|
"day_anchors": [a.get_text(strip=True) for a in day_anchors],
|
||||||
|
"full_parse": parse_schedule(html),
|
||||||
|
})
|
||||||
37
config.yaml
Normal file
37
config.yaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Настройки приложения расписания
|
||||||
|
# ------------------------------------
|
||||||
|
# base_url: адрес сайта с расписанием (без слеша в конце)
|
||||||
|
# default_teacher: id преподавателя, который открывается по умолчанию
|
||||||
|
# teachers: список преподавателей для быстрого переключения
|
||||||
|
# - id: число из параметра obj= в адресной строке
|
||||||
|
# например: https://lk.ks.psuti.ru/?mn=3&obj=244 → id: 244
|
||||||
|
# - name: полное имя (отображается в шапке)
|
||||||
|
# - short: короткое имя для кнопки-таба
|
||||||
|
|
||||||
|
base_url: "https://lk.ks.psuti.ru/?mn=3"
|
||||||
|
|
||||||
|
default_teacher: 244
|
||||||
|
|
||||||
|
teachers:
|
||||||
|
- id: 244
|
||||||
|
name: "Комаров Илья Сергеевич"
|
||||||
|
short: "Комаров И.С."
|
||||||
|
|
||||||
|
- id: 229
|
||||||
|
name: "Шахматов Егор Денисович"
|
||||||
|
short: "Шахматов Е.Д."
|
||||||
|
|
||||||
|
- id: 13
|
||||||
|
name: "Гончарова Анастасия Александровна"
|
||||||
|
short: "Гончарова А.А."
|
||||||
|
|
||||||
|
# Добавь других преподавателей по аналогии — id берётся из ссылки:
|
||||||
|
# https://lk.ks.psuti.ru/?mn=3&obj=65 → id: 65
|
||||||
|
#
|
||||||
|
# - id: 65
|
||||||
|
# name: "Андреевская Наталья Владимировна"
|
||||||
|
# short: "Андреевская Н.В."
|
||||||
|
#
|
||||||
|
# - id: 3
|
||||||
|
# name: "Абалымова Людмила Павловна"
|
||||||
|
# short: "Абалымова Л.П."
|
||||||
25
install-service.sh
Normal file
25
install-service.sh
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Запускать с sudo: sudo bash install-service.sh
|
||||||
|
# Должен быть запущен из папки с проектом
|
||||||
|
|
||||||
|
set -e
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SERVICE_USER="${SUDO_USER:-$(whoami)}"
|
||||||
|
|
||||||
|
echo "==> Устанавливаем systemd сервис..."
|
||||||
|
echo " Папка проекта : $PROJECT_DIR"
|
||||||
|
echo " Пользователь : $SERVICE_USER"
|
||||||
|
|
||||||
|
sed \
|
||||||
|
-e "s|__PROJECT_DIR__|$PROJECT_DIR|g" \
|
||||||
|
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
||||||
|
"$PROJECT_DIR/schedule.service.template" \
|
||||||
|
> /etc/systemd/system/schedule.service
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable schedule
|
||||||
|
systemctl restart schedule
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ Сервис запущен!"
|
||||||
|
systemctl status schedule --no-pager -l
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
flask>=2.3.0
|
||||||
|
requests>=2.31.0
|
||||||
|
beautifulsoup4>=4.12.0
|
||||||
|
lxml>=4.9.0
|
||||||
|
pyyaml
|
||||||
|
gunicorn
|
||||||
18
schedule.service.template
Normal file
18
schedule.service.template
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Schedule Parser — расписание преподавателей
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=__SERVICE_USER__
|
||||||
|
WorkingDirectory=__PROJECT_DIR__
|
||||||
|
ExecStart=__PROJECT_DIR__/venv/bin/python app.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Логи доступны через: journalctl -u schedule -f
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
19
setup.sh
Executable file
19
setup.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Запускать из папки с проектом: bash setup.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "==> Создаём venv..."
|
||||||
|
python3 -m venv "$PROJECT_DIR/venv"
|
||||||
|
|
||||||
|
echo "==> Устанавливаем зависимости..."
|
||||||
|
"$PROJECT_DIR/venv/bin/pip" install --upgrade pip -q
|
||||||
|
"$PROJECT_DIR/venv/bin/pip" install -r "$PROJECT_DIR/requirements.txt" -q
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ venv готов: $PROJECT_DIR/venv"
|
||||||
|
echo " Python: $($PROJECT_DIR/venv/bin/python --version)"
|
||||||
|
echo ""
|
||||||
|
echo "Следующий шаг:"
|
||||||
|
echo " sudo bash install-service.sh"
|
||||||
729
templates/index.html
Normal file
729
templates/index.html
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Расписание преподавателей КС ПГУТИ</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@300;400;600;700&family=Onest:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d0f14;
|
||||||
|
--surface: #13161e;
|
||||||
|
--surface2: #1a1e28;
|
||||||
|
--border: #252a38;
|
||||||
|
--accent: #4f8ef7;
|
||||||
|
--accent-glow: rgba(79,142,247,0.15);
|
||||||
|
--text: #e8eaf0;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--text-dim: #9ca3af;
|
||||||
|
--success: #34d399;
|
||||||
|
--r: 14px;
|
||||||
|
--font-d: 'Unbounded', sans-serif;
|
||||||
|
--font-b: 'Onest', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-b);
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed; top: -20%; left: -10%;
|
||||||
|
width: 70vw; height: 70vw;
|
||||||
|
background: radial-gradient(circle, rgba(79,142,247,0.07) 0%, transparent 70%);
|
||||||
|
pointer-events: none; z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
max-width: 1400px; margin: 0 auto;
|
||||||
|
padding: 0 16px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
header {
|
||||||
|
padding: 24px 0 20px;
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.header-left h1 {
|
||||||
|
font-family: var(--font-d);
|
||||||
|
font-size: clamp(1rem, 4vw, 1.4rem);
|
||||||
|
font-weight: 600; letter-spacing: -0.02em; line-height: 1.2;
|
||||||
|
}
|
||||||
|
.header-left h1 span { color: var(--accent); }
|
||||||
|
.header-left .subtitle {
|
||||||
|
font-size: 0.78rem; color: var(--text-muted);
|
||||||
|
margin-top: 5px; font-weight: 300;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
max-width: 55vw;
|
||||||
|
}
|
||||||
|
.live-badge {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 100px; padding: 6px 12px;
|
||||||
|
font-size: 0.7rem; color: var(--text-dim);
|
||||||
|
white-space: nowrap; flex-shrink: 0; margin-top: 2px;
|
||||||
|
}
|
||||||
|
.live-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--success); box-shadow: 0 0 6px var(--success);
|
||||||
|
animation: pulse 2s infinite; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(1.3)} }
|
||||||
|
|
||||||
|
/* ── Teacher search ── */
|
||||||
|
.teachers-section { margin-bottom: 20px; }
|
||||||
|
.teachers-label {
|
||||||
|
font-size: 0.65rem; font-weight: 600; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase; color: var(--text-muted);
|
||||||
|
margin-bottom: 8px; font-family: var(--font-d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Поле поиска */
|
||||||
|
.search-wrap { position: relative; margin-bottom: 8px; }
|
||||||
|
.search-input {
|
||||||
|
width: 100%; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 10px 14px 10px 38px;
|
||||||
|
font-family: var(--font-b); font-size: 0.85rem; color: var(--text);
|
||||||
|
outline: none; transition: border-color .2s;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
.search-input::placeholder { color: var(--text-muted); }
|
||||||
|
.search-input:focus { border-color: var(--accent); }
|
||||||
|
.search-icon {
|
||||||
|
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
|
||||||
|
color: var(--text-muted); pointer-events: none;
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
}
|
||||||
|
.search-clear {
|
||||||
|
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; color: var(--text-muted); cursor: pointer;
|
||||||
|
padding: 2px 6px; font-size: 0.7rem; display: none; line-height: 1.4;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.search-clear:hover { color: var(--text); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Выпадающий список */
|
||||||
|
.search-dropdown {
|
||||||
|
position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 100;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; overflow: hidden;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.4);
|
||||||
|
max-height: 280px; overflow-y: auto;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.search-dropdown.open { display: block; }
|
||||||
|
.search-dropdown::-webkit-scrollbar { width: 4px; }
|
||||||
|
.search-dropdown::-webkit-scrollbar-track { background: var(--surface); }
|
||||||
|
.search-dropdown::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
|
|
||||||
|
.dd-item {
|
||||||
|
padding: 10px 14px; font-size: 0.82rem; cursor: pointer;
|
||||||
|
transition: background .12s; border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
}
|
||||||
|
.dd-item:last-child { border-bottom: none; }
|
||||||
|
.dd-item:hover, .dd-item.selected { background: var(--surface2); }
|
||||||
|
.dd-item.selected { color: var(--accent); }
|
||||||
|
.dd-item-name { flex: 1; min-width: 0; }
|
||||||
|
.dd-item-name mark {
|
||||||
|
background: rgba(79,142,247,.25); color: var(--accent);
|
||||||
|
border-radius: 2px; padding: 0 1px;
|
||||||
|
}
|
||||||
|
.dd-item-id {
|
||||||
|
font-family: var(--font-d); font-size: 0.6rem; color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dd-empty {
|
||||||
|
padding: 20px 14px; text-align: center;
|
||||||
|
color: var(--text-muted); font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.dd-loading {
|
||||||
|
padding: 16px 14px; text-align: center;
|
||||||
|
color: var(--text-muted); font-size: 0.8rem;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Таб избранных из конфига */
|
||||||
|
.fav-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.teacher-tab {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 7px 14px;
|
||||||
|
font-family: var(--font-b); font-size: 0.78rem; font-weight: 500;
|
||||||
|
color: var(--text-dim); cursor: pointer;
|
||||||
|
transition: all 0.18s; white-space: nowrap;
|
||||||
|
min-height: 34px; display: flex; align-items: center;
|
||||||
|
}
|
||||||
|
.teacher-tab:hover { background: var(--surface2); border-color: rgba(79,142,247,0.4); color: var(--text); }
|
||||||
|
.teacher-tab.active { background: var(--accent-glow); border-color: var(--accent); color: var(--accent); font-weight: 600; }
|
||||||
|
@keyframes shimmer { 0%,100%{opacity:.4} 50%{opacity:.8} }
|
||||||
|
.teacher-tab-skeleton {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; height: 34px; width: 120px;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Week nav — ключевое изменение ── */
|
||||||
|
.week-nav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.week-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 10px 14px;
|
||||||
|
font-family: var(--font-b); font-size: 0.8rem; font-weight: 500;
|
||||||
|
color: var(--text-dim); cursor: pointer; transition: all 0.2s;
|
||||||
|
min-height: 42px; width: 100%;
|
||||||
|
}
|
||||||
|
.week-btn:hover:not(:disabled) { background: var(--surface2); border-color: var(--accent); color: var(--accent); }
|
||||||
|
.week-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.week-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
|
||||||
|
/* Скрыть текст кнопок на узких экранах, оставить только стрелку */
|
||||||
|
.week-btn-text { display: inline; }
|
||||||
|
.week-label {
|
||||||
|
font-family: var(--font-d); font-size: clamp(0.6rem, 2.5vw, 0.8rem);
|
||||||
|
font-weight: 400; color: var(--text);
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 10px 10px;
|
||||||
|
text-align: center; line-height: 1.3;
|
||||||
|
min-height: 42px; display: flex; align-items: center; justify-content: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.week-btn-text { display: none; }
|
||||||
|
.week-btn { padding: 10px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats — горизонтальная полоса ── */
|
||||||
|
.stats-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.stats-bar { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 12px;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
.stat-icon {
|
||||||
|
width: 30px; height: 30px; border-radius: 8px;
|
||||||
|
background: var(--accent-glow);
|
||||||
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.stat-icon svg { width: 14px; height: 14px; color: var(--accent); }
|
||||||
|
.stat-num { font-family: var(--font-d); font-size: 1.1rem; font-weight: 700; line-height: 1; }
|
||||||
|
.stat-label { font-size: 0.65rem; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.stat { flex-direction: column; align-items: flex-start; gap: 6px; padding: 10px; }
|
||||||
|
.stat-icon { width: 26px; height: 26px; }
|
||||||
|
.stat-num { font-size: 1rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading ── */
|
||||||
|
.loading {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; padding: 80px 20px; gap: 16px;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border: 2px solid var(--border); border-top-color: var(--accent);
|
||||||
|
border-radius: 50%; animation: spin .8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.loading p { color: var(--text-muted); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.error-box {
|
||||||
|
background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.3);
|
||||||
|
border-radius: var(--r); padding: 20px; text-align: center; color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Days grid ── */
|
||||||
|
.days-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) { .days-grid { grid-template-columns: 1fr; gap: 10px; } }
|
||||||
|
|
||||||
|
/* ── Day card ── */
|
||||||
|
.day-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r); overflow: hidden;
|
||||||
|
transition: border-color .2s;
|
||||||
|
animation: fadeUp .35s ease both;
|
||||||
|
}
|
||||||
|
.day-card.has-classes { border-color: rgba(79,142,247,.2); }
|
||||||
|
.day-card.today {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent), 0 4px 20px rgba(79,142,247,.12);
|
||||||
|
}
|
||||||
|
/* hover только на не-тач устройствах */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.day-card:hover { border-color: rgba(79,142,247,.35); transform: translateY(-1px); }
|
||||||
|
}
|
||||||
|
@keyframes fadeUp { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} }
|
||||||
|
|
||||||
|
.day-header {
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border); background: var(--surface2);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.day-name {
|
||||||
|
font-family: var(--font-d); font-size: 0.72rem; font-weight: 600;
|
||||||
|
letter-spacing: .04em; text-transform: uppercase; line-height: 1;
|
||||||
|
}
|
||||||
|
.today .day-name { color: var(--accent); }
|
||||||
|
.day-date { font-size: 0.68rem; color: var(--text-muted); margin-top: 3px; }
|
||||||
|
.today-tag {
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
font-size: 0.58rem; font-weight: 600; font-family: var(--font-d);
|
||||||
|
letter-spacing: .04em; padding: 3px 7px; border-radius: 5px;
|
||||||
|
text-transform: uppercase; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lesson row ── */
|
||||||
|
.lesson {
|
||||||
|
display: grid;
|
||||||
|
/* номер | время | инфо | аудитория */
|
||||||
|
grid-template-columns: 28px 1fr auto;
|
||||||
|
align-items: start;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background .15s;
|
||||||
|
padding: 10px 14px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.lesson:last-child { border-bottom: none; }
|
||||||
|
@media (hover: hover) { .lesson:hover { background: var(--surface2); } }
|
||||||
|
.lesson.active-lesson { background: rgba(79,142,247,.07); }
|
||||||
|
|
||||||
|
.lesson-num {
|
||||||
|
font-family: var(--font-d); font-size: 0.65rem; font-weight: 600;
|
||||||
|
color: var(--text-muted); padding-top: 2px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.active-lesson .lesson-num { color: var(--accent); }
|
||||||
|
|
||||||
|
.lesson-info { min-width: 0; }
|
||||||
|
|
||||||
|
.lesson-time {
|
||||||
|
font-size: 0.68rem; color: var(--text-muted);
|
||||||
|
margin-bottom: 4px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lesson-subject {
|
||||||
|
font-size: 0.78rem; font-weight: 600; line-height: 1.35;
|
||||||
|
margin-bottom: 5px; word-break: break-word;
|
||||||
|
}
|
||||||
|
.lesson-group { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
|
||||||
|
.group-badge {
|
||||||
|
background: rgba(79,142,247,.12); border: 1px solid rgba(79,142,247,.25);
|
||||||
|
color: var(--accent); border-radius: 5px; padding: 2px 7px;
|
||||||
|
font-size: 0.65rem; font-weight: 600; font-family: var(--font-d);
|
||||||
|
}
|
||||||
|
.type-badge {
|
||||||
|
background: rgba(124,58,237,.1); border: 1px solid rgba(124,58,237,.2);
|
||||||
|
color: #a78bfa; border-radius: 5px; padding: 2px 7px;
|
||||||
|
font-size: 0.63rem; font-weight: 500;
|
||||||
|
}
|
||||||
|
.lesson-location {
|
||||||
|
display: flex; align-items: center; gap: 3px;
|
||||||
|
font-size: 0.66rem; color: var(--accent); margin-top: 5px;
|
||||||
|
}
|
||||||
|
.lesson-location svg { width: 9px; height: 9px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.lesson-room {
|
||||||
|
display: flex; align-items: flex-start; justify-content: flex-end;
|
||||||
|
padding-top: 2px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.room-badge {
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
border-radius: 7px; padding: 3px 7px;
|
||||||
|
font-family: var(--font-d); font-size: 0.63rem; font-weight: 600; color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-day {
|
||||||
|
padding: 24px 16px; text-align: center;
|
||||||
|
color: var(--text-muted); font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.empty-day-icon { font-size: 1.4rem; margin-bottom: 6px; opacity: .4; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>Расписание <span>преподавателя</span></h1>
|
||||||
|
<p class="subtitle" id="teacher-full-name">Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
<div class="live-badge">
|
||||||
|
<span class="live-dot"></span>
|
||||||
|
<span id="last-updated">...</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="teachers-section" id="teachers-section">
|
||||||
|
<div class="teachers-label">Преподаватель</div>
|
||||||
|
|
||||||
|
<!-- Поиск по всем преподавателям -->
|
||||||
|
<div class="search-wrap" id="search-wrap">
|
||||||
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input class="search-input" id="search-input" type="text"
|
||||||
|
placeholder="Поиск по ФИО..." autocomplete="off" spellcheck="false">
|
||||||
|
<button class="search-clear" id="search-clear" onclick="clearSearch()">✕</button>
|
||||||
|
<div class="search-dropdown" id="search-dropdown">
|
||||||
|
<div class="dd-loading">
|
||||||
|
<div class="spinner" style="width:16px;height:16px;border-width:2px"></div>
|
||||||
|
Загружаем список...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Избранные из config.yaml (если больше одного) -->
|
||||||
|
<div class="fav-tabs" id="teachers-tabs" style="margin-top:10px">
|
||||||
|
<div class="teacher-tab-skeleton"></div>
|
||||||
|
<div class="teacher-tab-skeleton" style="width:90px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="week-nav">
|
||||||
|
<button class="week-btn" id="prev-btn" disabled onclick="navigate('prev')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15,18 9,12 15,6"/></svg>
|
||||||
|
<span class="week-btn-text">Предыдущая</span>
|
||||||
|
</button>
|
||||||
|
<div class="week-label" id="week-label">—</div>
|
||||||
|
<button class="week-btn" id="next-btn" disabled onclick="navigate('next')">
|
||||||
|
<span class="week-btn-text">Следующая</span>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9,18 15,12 9,6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-bar" id="stats-bar" style="display:none">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2"/>
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div><div class="stat-num" id="stat-days">0</div><div class="stat-label">дней</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div><div class="stat-num" id="stat-lessons">0</div><div class="stat-label">пар</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="9" cy="7" r="4"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div><div class="stat-num" id="stat-groups">—</div><div class="stat-label">групп</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M12 6v6l4 2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div><div class="stat-num" id="stat-hours">—</div><div class="stat-label">часов</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedule-container">
|
||||||
|
<div class="loading"><div class="spinner"></div><p>Загружаем расписание...</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let currentObj = null;
|
||||||
|
let prevWk = null, nextWk = null;
|
||||||
|
const todayStr = getTodayStr();
|
||||||
|
|
||||||
|
function getTodayStr() {
|
||||||
|
const d = new Date();
|
||||||
|
return [String(d.getDate()).padStart(2,'0'), String(d.getMonth()+1).padStart(2,'0'), d.getFullYear()].join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowMin() { const d = new Date(); return d.getHours()*60+d.getMinutes(); }
|
||||||
|
|
||||||
|
function isActive(t) {
|
||||||
|
const m = t.match(/(\d{2}):(\d{2})\s*[–-]\s*(\d{2}):(\d{2})/);
|
||||||
|
if (!m) return false;
|
||||||
|
const n = nowMin();
|
||||||
|
return n >= +m[1]*60 + +m[2] && n <= +m[3]*60 + +m[4];
|
||||||
|
}
|
||||||
|
|
||||||
|
let allTeachers = []; // полный список с сайта
|
||||||
|
|
||||||
|
async function loadTeachers() {
|
||||||
|
try {
|
||||||
|
// Грузим избранных из конфига
|
||||||
|
const r = await fetch('/api/teachers');
|
||||||
|
const data = await r.json();
|
||||||
|
renderFavTabs(data.teachers, data.default);
|
||||||
|
currentObj = String(data.default);
|
||||||
|
loadSchedule(currentObj, null);
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('teachers-tabs').innerHTML =
|
||||||
|
`<span style="color:var(--text-muted);font-size:.8rem">Ошибка: ${e.message}</span>`;
|
||||||
|
}
|
||||||
|
// Фоном грузим полный список для поиска
|
||||||
|
loadAllTeachers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllTeachers() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/all-teachers');
|
||||||
|
const data = await r.json();
|
||||||
|
allTeachers = data.teachers;
|
||||||
|
} catch(e) {
|
||||||
|
allTeachers = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFavTabs(teachers, defaultId) {
|
||||||
|
const tabs = document.getElementById('teachers-tabs');
|
||||||
|
if (teachers.length <= 1) {
|
||||||
|
tabs.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabs.innerHTML = teachers.map(t => `
|
||||||
|
<button class="teacher-tab${String(t.id)===String(defaultId)?' active':''}"
|
||||||
|
data-id="${t.id}" title="${t.name}"
|
||||||
|
onclick="switchTeacher('${t.id}',this)">${t.short}</button>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTeacher(id, btn) {
|
||||||
|
if (String(id) === currentObj) return;
|
||||||
|
// Снять активный у табов
|
||||||
|
document.querySelectorAll('.teacher-tab').forEach(b => b.classList.remove('active'));
|
||||||
|
if (btn) btn.classList.add('active');
|
||||||
|
currentObj = String(id);
|
||||||
|
prevWk = null; nextWk = null;
|
||||||
|
loadSchedule(id, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Поиск ────────────────────────────────────────────────────────────────
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const searchClear = document.getElementById('search-clear');
|
||||||
|
const dropdown = document.getElementById('search-dropdown');
|
||||||
|
let ddSelected = -1;
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
const q = searchInput.value.trim();
|
||||||
|
searchClear.style.display = q ? 'block' : 'none';
|
||||||
|
if (!q) { closeDropdown(); return; }
|
||||||
|
renderDropdown(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('focus', () => {
|
||||||
|
if (searchInput.value.trim()) renderDropdown(searchInput.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('keydown', e => {
|
||||||
|
const items = dropdown.querySelectorAll('.dd-item');
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
ddSelected = Math.min(ddSelected + 1, items.length - 1);
|
||||||
|
highlightDD(items);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
ddSelected = Math.max(ddSelected - 1, 0);
|
||||||
|
highlightDD(items);
|
||||||
|
} else if (e.key === 'Enter' && ddSelected >= 0 && items[ddSelected]) {
|
||||||
|
items[ddSelected].click();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
closeDropdown();
|
||||||
|
searchInput.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (!document.getElementById('search-wrap').contains(e.target)) closeDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
function highlightDD(items) {
|
||||||
|
items.forEach((el, i) => el.classList.toggle('selected', i === ddSelected));
|
||||||
|
if (items[ddSelected]) items[ddSelected].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDropdown(q) {
|
||||||
|
if (!allTeachers.length) {
|
||||||
|
dropdown.innerHTML = '<div class="dd-loading"><div class="spinner" style="width:14px;height:14px;border-width:2px"></div>Загружаем список...</div>';
|
||||||
|
dropdown.classList.add('open');
|
||||||
|
// Попробуем ещё раз загрузить
|
||||||
|
loadAllTeachers().then(() => { if (searchInput.value.trim()) renderDropdown(searchInput.value.trim()); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = q.toLowerCase();
|
||||||
|
const filtered = allTeachers.filter(t => t.name.toLowerCase().includes(norm)).slice(0, 30);
|
||||||
|
ddSelected = -1;
|
||||||
|
|
||||||
|
if (!filtered.length) {
|
||||||
|
dropdown.innerHTML = `<div class="dd-empty">Не найдено: «${q}»</div>`;
|
||||||
|
} else {
|
||||||
|
dropdown.innerHTML = filtered.map(t => {
|
||||||
|
const hi = t.name.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'), '<mark>$1</mark>');
|
||||||
|
return `<div class="dd-item${String(t.id)===currentObj?' selected':''}" onclick="selectTeacher('${t.id}','${t.name.replace(/'/g,"\'")}')">
|
||||||
|
<span class="dd-item-name">${hi}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
dropdown.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTeacher(id, name) {
|
||||||
|
closeDropdown();
|
||||||
|
searchInput.value = name;
|
||||||
|
searchClear.style.display = 'block';
|
||||||
|
// Снять активный у табов
|
||||||
|
document.querySelectorAll('.teacher-tab').forEach(b => b.classList.remove('active'));
|
||||||
|
switchTeacher(id, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
searchInput.value = '';
|
||||||
|
searchClear.style.display = 'none';
|
||||||
|
closeDropdown();
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
ddSelected = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSchedule(obj, wk) {
|
||||||
|
document.getElementById('schedule-container').innerHTML =
|
||||||
|
`<div class="loading"><div class="spinner"></div><p>Загружаем расписание...</p></div>`;
|
||||||
|
document.getElementById('stats-bar').style.display = 'none';
|
||||||
|
const p = new URLSearchParams({ obj });
|
||||||
|
if (wk) p.set('wk', wk);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/schedule?' + p);
|
||||||
|
render(await r.json());
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('schedule-container').innerHTML =
|
||||||
|
`<div class="error-box">⚠️ ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
if (data.error && !data.days?.length) {
|
||||||
|
document.getElementById('schedule-container').innerHTML = `<div class="error-box">⚠️ ${data.error}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
prevWk = data.prev_wk; nextWk = data.next_wk;
|
||||||
|
document.getElementById('prev-btn').disabled = !prevWk;
|
||||||
|
document.getElementById('next-btn').disabled = !nextWk;
|
||||||
|
document.getElementById('week-label').textContent = data.week_range || '—';
|
||||||
|
if (data.teacher_name) document.getElementById('teacher-full-name').textContent = data.teacher_name;
|
||||||
|
|
||||||
|
const activeDays = data.days.filter(d => d.has_classes).length;
|
||||||
|
const totalLessons = data.days.reduce((a,d) => a + d.lessons.filter(l=>l.has_class).length, 0);
|
||||||
|
const groups = new Set(data.days.flatMap(d=>d.lessons).filter(l=>l.group_short).map(l=>l.group_short));
|
||||||
|
document.getElementById('stat-days').textContent = activeDays;
|
||||||
|
document.getElementById('stat-lessons').textContent = totalLessons;
|
||||||
|
document.getElementById('stat-groups').textContent = groups.size || '—';
|
||||||
|
// 1 пара = 1.5 акад. часа (90 мин), но по условию 1 пара = 2 часа
|
||||||
|
document.getElementById('stat-hours').textContent = totalLessons * 2;
|
||||||
|
document.getElementById('stats-bar').style.display = 'grid';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('last-updated').textContent =
|
||||||
|
`${now.getHours()}:${String(now.getMinutes()).padStart(2,'0')}`;
|
||||||
|
|
||||||
|
if (!data.days.length) {
|
||||||
|
document.getElementById('schedule-container').innerHTML =
|
||||||
|
`<div class="error-box" style="background:rgba(79,142,247,.07);border-color:var(--border);color:var(--text-muted)">Расписание не найдено</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pin = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="9" height="9"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>`;
|
||||||
|
|
||||||
|
let html = '<div class="days-grid">';
|
||||||
|
data.days.forEach((day, i) => {
|
||||||
|
const isToday = day.date === todayStr;
|
||||||
|
const cls = ['day-card', day.has_classes?'has-classes':'', isToday?'today':''].filter(Boolean).join(' ');
|
||||||
|
html += `<div class="${cls}" style="animation-delay:${i*.04}s">
|
||||||
|
<div class="day-header">
|
||||||
|
<div>
|
||||||
|
<div class="day-name">${day.name}</div>
|
||||||
|
<div class="day-date">${day.date}</div>
|
||||||
|
</div>
|
||||||
|
${isToday ? '<span class="today-tag">Сегодня</span>' : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (!day.has_classes) {
|
||||||
|
html += `<div class="empty-day"><div class="empty-day-icon">☕</div>Выходной / нет пар</div>`;
|
||||||
|
} else {
|
||||||
|
day.lessons.forEach(l => {
|
||||||
|
if (!l.has_class) return;
|
||||||
|
const active = isToday && isActive(l.time);
|
||||||
|
html += `<div class="lesson${active?' active-lesson':''}">
|
||||||
|
<div class="lesson-num">${l.num}</div>
|
||||||
|
<div class="lesson-info">
|
||||||
|
<div class="lesson-time">${l.time}</div>
|
||||||
|
<div class="lesson-subject">${l.subject}</div>
|
||||||
|
<div class="lesson-group">
|
||||||
|
${l.group_short ? `<span class="group-badge">${l.group_short}</span>` : ''}
|
||||||
|
${l.lesson_type ? `<span class="type-badge">${l.lesson_type}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${l.location ? `<div class="lesson-location">${pin}${l.location}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="lesson-room">${l.room ? `<div class="room-badge">${l.room}</div>` : ''}</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
document.getElementById('schedule-container').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(dir) {
|
||||||
|
const wk = dir === 'prev' ? prevWk : nextWk;
|
||||||
|
if (wk) loadSchedule(currentObj, wk);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTeachers();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user