Базовая функциональность

This commit is contained in:
2026-09-08 11:14:33 +03:00
commit dcd9b854a0
18 changed files with 2855 additions and 0 deletions

526
src/frontend/static/app.js Normal file
View 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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");
});
});