Базовая функциональность
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user