Базовая функциональность
This commit is contained in:
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