init here
This commit is contained in:
36
frontend/app.js
Normal file
36
frontend/app.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { routerNavigate, route } from "./router.js";
|
||||
import {
|
||||
loadSession,
|
||||
clearSession,
|
||||
setTopUser,
|
||||
setLogoutVisible,
|
||||
} from "./utils.js";
|
||||
import "./views/auth.js";
|
||||
import "./views/teacher.js";
|
||||
import "./views/student.js";
|
||||
|
||||
route("/404", async ({ view }) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "card";
|
||||
card.innerHTML = `<div class="card-header">Страница не найдена</div><div class="card-body">Нет такой страницы</div>`;
|
||||
view.appendChild(card);
|
||||
});
|
||||
|
||||
function initTop() {
|
||||
const s = loadSession();
|
||||
if (s) {
|
||||
setTopUser(s.username);
|
||||
setLogoutVisible(true);
|
||||
}
|
||||
document.getElementById("btn-logout").addEventListener("click", () => {
|
||||
clearSession();
|
||||
setTopUser("");
|
||||
setLogoutVisible(false);
|
||||
location.hash = "#/auth/login";
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
initTop();
|
||||
routerNavigate();
|
||||
});
|
||||
53
frontend/index.html
Normal file
53
frontend/index.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Электронная школа — SRAB</title>
|
||||
<link rel="icon" type="image/jpeg" href="srab.jpg" />
|
||||
<link rel="apple-touch-icon" href="srab.jpg" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="logo">
|
||||
<img src="srab.jpg" width="50" />
|
||||
</span>
|
||||
<span>Электронная Школа</span>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<div
|
||||
class="avg"
|
||||
id="top-avg"
|
||||
title="Средний балл (по выбранному)"
|
||||
></div>
|
||||
<div class="user" id="top-user"></div>
|
||||
<button id="btn-logout" class="linklike" hidden>Выйти</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<nav class="sidebar">
|
||||
<div class="menu-title">Меню</div>
|
||||
<ul id="menu">
|
||||
<li><a href="#/dashboard">Дневник</a></li>
|
||||
<li><a href="#/schedule">Расписание</a></li>
|
||||
<li><a href="#/grades">Оценки</a></li>
|
||||
<li><a href="#/school">Школа</a></li>
|
||||
<li><a href="#/homework">Домашнее задание</a></li>
|
||||
<li><a href="#/portfolio">Портфолио</a></li>
|
||||
<li class="sep"></li>
|
||||
<li><a href="#/auth/login">Войти</a></li>
|
||||
<li><a href="#/auth/register">Регистрация учителя</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main id="view" class="content"></main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
140
frontend/kek.js
Normal file
140
frontend/kek.js
Normal file
@@ -0,0 +1,140 @@
|
||||
const API_BASE = (localStorage.getItem("srab.apiBase") || "").replace(
|
||||
/\/$/,
|
||||
""
|
||||
);
|
||||
|
||||
function buildAuthHeader(session) {
|
||||
if (!session || !session.username || !session.password) return {};
|
||||
return { Authorization: `Basic ${session.username} ${session.password}` };
|
||||
}
|
||||
|
||||
async function apiFetch(
|
||||
path,
|
||||
{ method = "GET", body, session, headers = {} } = {}
|
||||
) {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const h = {
|
||||
"Content-Type": "application/json",
|
||||
...buildAuthHeader(session),
|
||||
...headers,
|
||||
};
|
||||
const opts = { method, headers: h };
|
||||
if (body !== undefined)
|
||||
opts.body = typeof body === "string" ? body : JSON.stringify(body);
|
||||
|
||||
const res = await fetch(url, opts);
|
||||
let data = null;
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
data = await res.text();
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch (e) {}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = new Error(
|
||||
(data && (data.message || data.error)) || `HTTP ${res.status}`
|
||||
);
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
export const Api = {
|
||||
base: API_BASE,
|
||||
setBase(url) {
|
||||
localStorage.setItem("srab.apiBase", url);
|
||||
},
|
||||
|
||||
async login({ username, password }) {
|
||||
const session = { username, password };
|
||||
const { data } = await apiFetch("/api/users/login", {
|
||||
method: "POST",
|
||||
session,
|
||||
body: { "имя пользователя": username, пароль: password },
|
||||
});
|
||||
return { session, message: data };
|
||||
},
|
||||
async registerTeacher(payload) {
|
||||
return apiFetch("/api/users", { method: "POST", body: payload });
|
||||
},
|
||||
async changePassword(session, newPassword) {
|
||||
return apiFetch("/api/users/password", {
|
||||
method: "PUT",
|
||||
session,
|
||||
body: { "новый пароль": newPassword },
|
||||
});
|
||||
},
|
||||
|
||||
async listTeachers(session, page) {
|
||||
return apiFetch(
|
||||
`/api/teachers${page ? `?страница=${encodeURIComponent(page)}` : ""}`,
|
||||
{ session }
|
||||
);
|
||||
},
|
||||
|
||||
async createStudent(session, payload) {
|
||||
return apiFetch("/api/students", {
|
||||
method: "POST",
|
||||
session,
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
async listStudents(session, teacherId) {
|
||||
const q = teacherId ? `?учитель=${encodeURIComponent(teacherId)}` : "";
|
||||
return apiFetch(`/api/students${q}`, { session });
|
||||
},
|
||||
async getStudent(session, id) {
|
||||
return apiFetch(`/api/students/${id}`, { session });
|
||||
},
|
||||
async deleteStudent(session, id) {
|
||||
return apiFetch(`/api/students/${id}`, { method: "DELETE", session });
|
||||
},
|
||||
|
||||
async listClasses(session, teacherId) {
|
||||
const q = teacherId ? `?учитель=${encodeURIComponent(teacherId)}` : "";
|
||||
return apiFetch(`/api/classes${q}`, { session });
|
||||
},
|
||||
async createClass(session, payload) {
|
||||
return apiFetch("/api/classes", { method: "POST", session, body: payload });
|
||||
},
|
||||
async deleteClass(session, classId) {
|
||||
return apiFetch(`/api/classes/${classId}`, { method: "DELETE", session });
|
||||
},
|
||||
async addStudentToClass(session, classId, studentId) {
|
||||
return apiFetch(`/api/classes/${classId}/students/${studentId}`, {
|
||||
method: "POST",
|
||||
session,
|
||||
});
|
||||
},
|
||||
async removeStudentFromClass(session, classId, studentId) {
|
||||
return apiFetch(`/api/classes/${classId}/students/${studentId}`, {
|
||||
method: "DELETE",
|
||||
session,
|
||||
});
|
||||
},
|
||||
|
||||
async createLesson(session, classId, payload) {
|
||||
return apiFetch(`/api/classes/${classId}/lessons`, {
|
||||
method: "POST",
|
||||
session,
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
async listLessons(session, classId, date) {
|
||||
const q = date ? `?date=${encodeURIComponent(date)}` : "";
|
||||
return apiFetch(`/api/classes/${classId}/lessons${q}`, { session });
|
||||
},
|
||||
async listLessonsByDate(session, classId, date) {
|
||||
return apiFetch(`/api/classes/${classId}/lessons/date/${date}`, {
|
||||
session,
|
||||
});
|
||||
},
|
||||
async deleteLessonById(session, classId, lessonId) {
|
||||
return apiFetch(`/api/classes/${classId}/lessons/${lessonId}`, {
|
||||
method: "DELETE",
|
||||
session,
|
||||
});
|
||||
},
|
||||
};
|
||||
23
frontend/router.js
Normal file
23
frontend/router.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const routes = {};
|
||||
export function route(path, handler) {
|
||||
routes[path] = handler;
|
||||
}
|
||||
|
||||
function parseHash() {
|
||||
const h = location.hash.replace(/^#/, "");
|
||||
const parts = h.split("?");
|
||||
const path = parts[0] || "/dashboard";
|
||||
const search = new URLSearchParams(parts[1] || "");
|
||||
return { path, search };
|
||||
}
|
||||
|
||||
export async function routerNavigate() {
|
||||
const { path, search } = parseHash();
|
||||
const view = document.getElementById("view");
|
||||
const handler = routes[path] || routes["/404"];
|
||||
view.innerHTML = "";
|
||||
await handler({ view, search });
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", routerNavigate);
|
||||
BIN
frontend/srab.jpg
Normal file
BIN
frontend/srab.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
236
frontend/styles.css
Normal file
236
frontend/styles.css
Normal file
@@ -0,0 +1,236 @@
|
||||
:root {
|
||||
--bg: #f6f1df;
|
||||
--accent: #d6b87a;
|
||||
--accent-strong: #c39a3a;
|
||||
--text: #3a3a3a;
|
||||
--muted: #8a8a8a;
|
||||
--panel: #fffaf0;
|
||||
--border: #e0d7c4;
|
||||
--ok: #3aa76d;
|
||||
--warn: #c97c2a;
|
||||
--danger: #c43e3e;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell,
|
||||
"Noto Sans", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-bottom: 2px solid var(--accent-strong);
|
||||
}
|
||||
.topbar .brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.topbar .logo {
|
||||
filter: grayscale(20%);
|
||||
}
|
||||
.topbar .top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.topbar .linklike {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
min-height: calc(100vh - 44px);
|
||||
}
|
||||
.sidebar {
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 10px 0;
|
||||
}
|
||||
.sidebar .menu-title {
|
||||
padding: 10px 16px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sidebar ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.sidebar li {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sidebar li.sep {
|
||||
height: 10px;
|
||||
border: none;
|
||||
}
|
||||
.sidebar a {
|
||||
display: block;
|
||||
padding: 12px 16px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar a:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
.card .card-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fffdf7;
|
||||
font-weight: 600;
|
||||
}
|
||||
.card .card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.input label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.input input,
|
||||
.input select,
|
||||
.input textarea {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.btn {
|
||||
background: var(--accent-strong);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.secondary {
|
||||
background: #8b8b8b;
|
||||
}
|
||||
.btn.danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
.table th {
|
||||
background: #fffdf7;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
background: #eee;
|
||||
}
|
||||
.badge.ok {
|
||||
background: #e6f6ed;
|
||||
color: var(--ok);
|
||||
}
|
||||
.badge.warn {
|
||||
background: #fff1e3;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #fffbe6;
|
||||
}
|
||||
.error {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 6px;
|
||||
background: #fff0f0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tabs a {
|
||||
padding: 8px 12px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.tabs a.active {
|
||||
background: #fff;
|
||||
border-color: var(--border);
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
112
frontend/utils.js
Normal file
112
frontend/utils.js
Normal file
@@ -0,0 +1,112 @@
|
||||
export const $ = (sel, root = document) => root.querySelector(sel);
|
||||
export const $$ = (sel, root = document) =>
|
||||
Array.from(root.querySelectorAll(sel));
|
||||
|
||||
export function el(tag, attrs = {}, children = []) {
|
||||
const node = document.createElement(tag);
|
||||
Object.entries(attrs).forEach(([k, v]) => {
|
||||
if (k === "class") node.className = v;
|
||||
else if (k.startsWith("on") && typeof v === "function")
|
||||
node.addEventListener(k.slice(2), v);
|
||||
else if (v !== undefined && v !== null) node.setAttribute(k, v);
|
||||
});
|
||||
for (const ch of Array.isArray(children) ? children : [children]) {
|
||||
if (typeof ch === "string") node.appendChild(document.createTextNode(ch));
|
||||
else if (ch) node.appendChild(ch);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
export function formToJSON(form) {
|
||||
const data = {};
|
||||
const coerceNumberIfNeeded = (key, value) => {
|
||||
const field = form.elements.namedItem(key);
|
||||
let isNumber = false;
|
||||
let integerOnly = false;
|
||||
|
||||
const inspect = (el) => {
|
||||
if (!el) return;
|
||||
if (el.getAttribute && el.getAttribute("data-type") === "number") {
|
||||
isNumber = true;
|
||||
}
|
||||
if (el.getAttribute && el.getAttribute("data-integer") === "true") {
|
||||
isNumber = true;
|
||||
integerOnly = true;
|
||||
}
|
||||
if (el.tagName === "INPUT") {
|
||||
const type = (el.type || "").toLowerCase();
|
||||
if (type === "number") {
|
||||
isNumber = true;
|
||||
const step = el.step;
|
||||
if (
|
||||
step &&
|
||||
(step === "1" || (!step.includes(".") && step !== "any"))
|
||||
) {
|
||||
integerOnly = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
field &&
|
||||
typeof RadioNodeList !== "undefined" &&
|
||||
field instanceof RadioNodeList
|
||||
) {
|
||||
inspect(field[0]);
|
||||
} else {
|
||||
inspect(field);
|
||||
}
|
||||
|
||||
if (isNumber) {
|
||||
if (value === "" || value === null) return null;
|
||||
const n = Number(value);
|
||||
if (Number.isNaN(n)) return null;
|
||||
return integerOnly ? Math.trunc(n) : n;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
new FormData(form).forEach((v, k) => {
|
||||
const val = coerceNumberIfNeeded(k, v);
|
||||
if (data[k] !== undefined) {
|
||||
if (!Array.isArray(data[k])) data[k] = [data[k]];
|
||||
data[k].push(val);
|
||||
} else data[k] = val;
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export function saveSession(session) {
|
||||
localStorage.setItem("srab.session", JSON.stringify(session));
|
||||
}
|
||||
export function loadSession() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("srab.session")) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function clearSession() {
|
||||
localStorage.removeItem("srab.session");
|
||||
}
|
||||
|
||||
export function setTopUser(text) {
|
||||
$("#top-user").textContent = text || "";
|
||||
}
|
||||
export function setLogoutVisible(vis) {
|
||||
$("#btn-logout").hidden = !vis;
|
||||
}
|
||||
|
||||
export function setAvg(text) {
|
||||
const n = $("#top-avg");
|
||||
n.textContent = text || "";
|
||||
n.className = "avg badge ok";
|
||||
}
|
||||
|
||||
export function fmtDateInput(d = new Date()) {
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
86
frontend/views/auth.js
Normal file
86
frontend/views/auth.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { route } from "../router.js";
|
||||
import { Api } from "../kek.js";
|
||||
import {
|
||||
saveSession,
|
||||
setTopUser,
|
||||
setLogoutVisible,
|
||||
formToJSON,
|
||||
} from "../utils.js";
|
||||
|
||||
function loginView() {
|
||||
const card = document.createElement("div");
|
||||
card.className = "card";
|
||||
card.innerHTML = `
|
||||
<div class="card-header">Вход</div>
|
||||
<div class="card-body">
|
||||
<form id="login-form" class="form-grid" autocomplete="on">
|
||||
<div class="input"><label>Имя пользователя</label><input name="username" required /></div>
|
||||
<div class="input"><label>Пароль</label><input name="password" type="password" required /></div>
|
||||
<div class="actions">
|
||||
<button class="btn" type="submit">Войти</button>
|
||||
<a class="btn secondary" href="#/auth/register">Регистрация учителя</a>
|
||||
</div>
|
||||
<div id="login-error" class="error hidden"></div>
|
||||
</form>
|
||||
</div>`;
|
||||
const form = card.querySelector("#login-form");
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const { username, password } = Object.fromEntries(new FormData(form));
|
||||
try {
|
||||
const { session } = await Api.login({ username, password });
|
||||
saveSession(session);
|
||||
setTopUser(username);
|
||||
setLogoutVisible(true);
|
||||
location.hash = "#/dashboard";
|
||||
} catch (err) {
|
||||
const box = card.querySelector("#login-error");
|
||||
box.classList.remove("hidden");
|
||||
box.textContent = err.message || "Не удалось войти";
|
||||
}
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function registerView() {
|
||||
const card = document.createElement("div");
|
||||
card.className = "card";
|
||||
card.innerHTML = `
|
||||
<div class="card-header">Регистрация учителя</div>
|
||||
<div class="card-body">
|
||||
<form id="reg-form" class="form-grid">
|
||||
<div class="input"><label>Имя</label><input name="имя" required></div>
|
||||
<div class="input"><label>Фамилия</label><input name="фамилия" required></div>
|
||||
<div class="input"><label>Отчество</label><input name="отчество" required></div>
|
||||
<div class="input"><label>Образование</label><input name="образование" required></div>
|
||||
<div class="input"><label>Пароль</label><input type="password" name="пароль" required></div>
|
||||
<div class="input"><label>Повтор пароля</label><input type="password" name="повтор пароля" required></div>
|
||||
<div class="actions"><button class="btn" type="submit">Создать</button></div>
|
||||
<div id="reg-msg" class="notice hidden"></div>
|
||||
<div id="reg-err" class="error hidden"></div>
|
||||
</form>
|
||||
</div>`;
|
||||
card.querySelector("#reg-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = formToJSON(e.target);
|
||||
try {
|
||||
await Api.registerTeacher(payload);
|
||||
const msg = card.querySelector("#reg-msg");
|
||||
msg.classList.remove("hidden");
|
||||
msg.textContent = "Учитель создан. Теперь можно войти.";
|
||||
card.querySelector("#reg-err").classList.add("hidden");
|
||||
} catch (err) {
|
||||
const errBox = card.querySelector("#reg-err");
|
||||
errBox.classList.remove("hidden");
|
||||
errBox.textContent = err.message || "Ошибка регистрации";
|
||||
}
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
route("/auth/login", async ({ view }) => {
|
||||
view.appendChild(loginView());
|
||||
});
|
||||
route("/auth/register", async ({ view }) => {
|
||||
view.appendChild(registerView());
|
||||
});
|
||||
98
frontend/views/student.js
Normal file
98
frontend/views/student.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { route } from "../router.js";
|
||||
import { Api } from "../kek.js";
|
||||
import { el, $, loadSession } from "../utils.js";
|
||||
|
||||
function needSession() {
|
||||
const s = loadSession();
|
||||
if (!s) location.hash = "#/auth/login";
|
||||
return s;
|
||||
}
|
||||
|
||||
function studentDashboard(session) {
|
||||
const card = el("div", { class: "card" }, [
|
||||
el("div", { class: "card-header" }, "Дневник ученика"),
|
||||
el("div", { class: "card-body" }, [
|
||||
(() => {
|
||||
const box = el("div", { class: "form-grid" });
|
||||
box.innerHTML = `
|
||||
<div class="input"><label>ID класса</label><input id="st-class-id" type="number" min="1"></div>
|
||||
<div class="input"><label>Дата</label><input id="st-date" type="date"></div>
|
||||
<div class="actions" style="align-self:end"><button id="st-load" class="btn" type="button">Показать</button></div>`;
|
||||
return box;
|
||||
})(),
|
||||
el("table", { class: "table", id: "st-lessons" }, [
|
||||
el(
|
||||
"thead",
|
||||
{},
|
||||
el("tr", {}, [
|
||||
el("th", {}, "Дата"),
|
||||
el("th", {}, "Предмет/тема"),
|
||||
el("th", {}, "Домашнее задание"),
|
||||
])
|
||||
),
|
||||
el("tbody"),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
async function refresh() {
|
||||
const classId = Number($("#st-class-id", card)?.value || 0);
|
||||
const date = $("#st-date", card)?.value || undefined;
|
||||
if (!classId) return;
|
||||
const { data } = await Api.listLessons(session, classId, date);
|
||||
const tbody = card.querySelector("tbody");
|
||||
tbody.innerHTML = "";
|
||||
for (const l of data?.["уроки"] || []) {
|
||||
const tr = el("tr");
|
||||
tr.appendChild(el("td", {}, l["дата"] || ""));
|
||||
tr.appendChild(el("td", {}, l["название"] || ""));
|
||||
tr.appendChild(el("td", {}, l["домашнее задание"] || ""));
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
card.querySelector("#st-load")?.addEventListener("click", refresh);
|
||||
return card;
|
||||
}
|
||||
|
||||
function changePasswordView(session) {
|
||||
const card = el("div", { class: "card" }, [
|
||||
el("div", { class: "card-header" }, "Смена пароля"),
|
||||
el("div", { class: "card-body" }, [
|
||||
(() => {
|
||||
const form = el("form", { class: "form-grid" });
|
||||
form.innerHTML = `
|
||||
<div class="input"><label>Новый пароль</label><input name="password" type="password" required></div>
|
||||
<div class="actions"><button class="btn">Изменить</button></div>
|
||||
<div id="pw-msg" class="notice hidden"></div>
|
||||
<div id="pw-err" class="error hidden"></div>`;
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const newPassword = new FormData(form).get("password");
|
||||
try {
|
||||
await Api.changePassword(session, newPassword);
|
||||
const msg = form.querySelector("#pw-msg");
|
||||
msg.classList.remove("hidden");
|
||||
msg.textContent = "Пароль изменён";
|
||||
form.querySelector("#pw-err").classList.add("hidden");
|
||||
} catch (err) {
|
||||
const box = form.querySelector("#pw-err");
|
||||
box.classList.remove("hidden");
|
||||
box.textContent = err.message || "Не удалось изменить пароль";
|
||||
}
|
||||
});
|
||||
return form;
|
||||
})(),
|
||||
]),
|
||||
]);
|
||||
return card;
|
||||
}
|
||||
|
||||
route("/student/dashboard", async ({ view }) => {
|
||||
const s = needSession();
|
||||
view.appendChild(studentDashboard(s));
|
||||
});
|
||||
route("/student/password", async ({ view }) => {
|
||||
const s = needSession();
|
||||
view.appendChild(changePasswordView(s));
|
||||
});
|
||||
406
frontend/views/teacher.js
Normal file
406
frontend/views/teacher.js
Normal file
@@ -0,0 +1,406 @@
|
||||
import { route } from "../router.js";
|
||||
import { Api } from "../kek.js";
|
||||
import { el, formToJSON, $, fmtDateInput, loadSession } from "../utils.js";
|
||||
|
||||
function needSession() {
|
||||
const s = loadSession();
|
||||
if (!s) location.hash = "#/auth/login";
|
||||
return s;
|
||||
}
|
||||
|
||||
function sectionTabs(active) {
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "tabs";
|
||||
const items = [
|
||||
["#/dashboard", "Сводная"],
|
||||
["#/teacher/classes", "Классы"],
|
||||
["#/teacher/students", "Ученики"],
|
||||
["#/teacher/lessons", "Уроки"],
|
||||
];
|
||||
for (const [href, title] of items) {
|
||||
const a = document.createElement("a");
|
||||
a.href = href;
|
||||
a.textContent = title;
|
||||
if (href.endsWith(active)) a.classList.add("active");
|
||||
tabs.appendChild(a);
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
function classesView(session) {
|
||||
const wrap = el("div", { class: "grid-two" });
|
||||
|
||||
const formCard = el("div", { class: "card" }, [
|
||||
el("div", { class: "card-header" }, "Создать класс"),
|
||||
el("div", { class: "card-body" }, [
|
||||
(() => {
|
||||
const form = el("form", { class: "form-grid" });
|
||||
form.innerHTML = `
|
||||
<div class="input"><label>Номер</label><input name="номер" type="number" required min="1" max="11"></div>
|
||||
<div class="input"><label>Буква</label><input name="буква" maxlength="2" required></div>
|
||||
<div class="actions"><button class="btn">Создать</button></div>
|
||||
<div class="small">Ограничение: однобуквенное обозначение параллели.</div>
|
||||
<div id="cls-err" class="error hidden"></div>`;
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = formToJSON(form);
|
||||
try {
|
||||
await Api.createClass(session, payload);
|
||||
await refresh();
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
const box = form.querySelector("#cls-err");
|
||||
box.classList.remove("hidden");
|
||||
box.textContent = err.message || "Ошибка создания класса";
|
||||
}
|
||||
});
|
||||
return form;
|
||||
})(),
|
||||
]),
|
||||
]);
|
||||
|
||||
const listCard = el("div", { class: "card" }, [
|
||||
el("div", { class: "card-header" }, "Ваши классы"),
|
||||
el("div", { class: "card-body" }, [
|
||||
(() => {
|
||||
const box = el("div", {
|
||||
class: "form-grid",
|
||||
style: "margin-bottom:8px",
|
||||
});
|
||||
box.innerHTML = `
|
||||
<div class="input"><label>ID ученика (для добавления/удаления)</label><input id="st-to-add" type="number" min="1"></div>
|
||||
<div class="small">Выберите класс в таблице и используйте кнопки «Добавить ученика»/«Убрать ученика»</div>`;
|
||||
return box;
|
||||
})(),
|
||||
el("table", { class: "table", id: "class-table" }, [
|
||||
el(
|
||||
"thead",
|
||||
{},
|
||||
el("tr", {}, [
|
||||
el("th", {}, "ID"),
|
||||
el("th", {}, "Класс"),
|
||||
el("th", {}, "Создатель"),
|
||||
el("th", {}, ""),
|
||||
])
|
||||
),
|
||||
el("tbody"),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
wrap.appendChild(formCard);
|
||||
wrap.appendChild(listCard);
|
||||
|
||||
async function refresh() {
|
||||
const { data } = await Api.listClasses(session);
|
||||
const tbody = listCard.querySelector("tbody");
|
||||
tbody.innerHTML = "";
|
||||
for (const c of data?.["классы"] || []) {
|
||||
const tr = el("tr");
|
||||
tr.appendChild(el("td", {}, String(c["идентификатор"])));
|
||||
tr.appendChild(
|
||||
el("td", {}, `${c["номер"] || ""}${c["буква"] ? "-" + c["буква"] : ""}`)
|
||||
);
|
||||
tr.appendChild(el("td", {}, String(c["создатель"] ?? "")));
|
||||
const actions = el("td");
|
||||
const btnAdd = el("button", { class: "btn" }, "Добавить ученика");
|
||||
btnAdd.addEventListener("click", async () => {
|
||||
const sid = Number($("#st-to-add", listCard)?.value || 0);
|
||||
if (!sid) return alert("Укажите ID ученика");
|
||||
try {
|
||||
await Api.addStudentToClass(session, c["идентификатор"], sid);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(e.message || "Не удалось добавить ученика");
|
||||
}
|
||||
});
|
||||
const btnRm = el("button", { class: "btn secondary" }, "Убрать ученика");
|
||||
btnRm.addEventListener("click", async () => {
|
||||
const sid = Number($("#st-to-add", listCard)?.value || 0);
|
||||
if (!sid) return alert("Укажите ID ученика");
|
||||
try {
|
||||
await Api.removeStudentFromClass(session, c["идентификатор"], sid);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(e.message || "Не удалось удалить ученика");
|
||||
}
|
||||
});
|
||||
const btnDel = el("button", { class: "btn danger" }, "Удалить класс");
|
||||
btnDel.addEventListener("click", async () => {
|
||||
if (!confirm("Удалить класс?")) return;
|
||||
await Api.deleteClass(session, c["идентификатор"]);
|
||||
await refresh();
|
||||
});
|
||||
actions.appendChild(btnAdd);
|
||||
actions.appendChild(btnRm);
|
||||
actions.appendChild(btnDel);
|
||||
tr.appendChild(actions);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
refresh().catch(console.error);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function studentsView(session) {
|
||||
const wrap = el("div", { class: "grid-two" });
|
||||
|
||||
const formCard = el("div", { class: "card" }, [
|
||||
el("div", { class: "card-header" }, "Создать ученика"),
|
||||
el("div", { class: "card-body" }, [
|
||||
(() => {
|
||||
const form = el("form", { class: "form-grid" });
|
||||
form.innerHTML = `
|
||||
<div class="input"><label>Имя</label><input name="имя" required></div>
|
||||
<div class="input"><label>Фамилия</label><input name="фамилия" required></div>
|
||||
<div class="input"><label>Отчество</label><input name="отчество" required></div>
|
||||
<div class="input"><label>СНИЛС</label><input name="снилс" required></div>
|
||||
<div class="input"><label>Паспорт</label><input name="паспорт" required></div>
|
||||
<div class="input"><label>Пароль</label><input type="password" name="пароль" required></div>
|
||||
<div class="input"><label>Повтор пароля</label><input type="password" name="повтор пароля" required></div>
|
||||
<div class="actions"><button class="btn">Создать</button></div>
|
||||
<div id="st-err" class="error hidden"></div>`;
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = formToJSON(form);
|
||||
try {
|
||||
await Api.createStudent(session, payload);
|
||||
await refresh();
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
const box = form.querySelector("#st-err");
|
||||
box.classList.remove("hidden");
|
||||
box.textContent = err.message || "Ошибка создания ученика";
|
||||
}
|
||||
});
|
||||
return form;
|
||||
})(),
|
||||
]),
|
||||
]);
|
||||
|
||||
const listCard = el("div", { class: "card" }, [
|
||||
el("div", { class: "card-header" }, "Ученики"),
|
||||
el("div", { class: "card-body" }, [
|
||||
el("table", { class: "table", id: "st-table" }, [
|
||||
el(
|
||||
"thead",
|
||||
{},
|
||||
el("tr", {}, [
|
||||
el("th", {}, "ID"),
|
||||
el("th", {}, "ФИО"),
|
||||
el("th", {}, "Имя пользователя"),
|
||||
el("th", {}, ""),
|
||||
])
|
||||
),
|
||||
el("tbody"),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
wrap.appendChild(formCard);
|
||||
wrap.appendChild(listCard);
|
||||
|
||||
async function refresh() {
|
||||
const { data } = await Api.listStudents(session);
|
||||
const tbody = listCard.querySelector("tbody");
|
||||
tbody.innerHTML = "";
|
||||
for (const s of data?.["ученики"] || []) {
|
||||
const tr = el("tr");
|
||||
tr.appendChild(el("td", {}, String(s["идентификатор"])));
|
||||
tr.appendChild(
|
||||
el(
|
||||
"td",
|
||||
{},
|
||||
`${s["фамилия"] || ""} ${s["имя"] || ""} ${
|
||||
s["отчество"] || ""
|
||||
}`.trim()
|
||||
)
|
||||
);
|
||||
tr.appendChild(el("td", {}, s["имя пользователя"] || ""));
|
||||
const actions = el("td");
|
||||
const btnDel = el("button", { class: "btn danger" }, "Удалить");
|
||||
btnDel.addEventListener("click", async () => {
|
||||
if (!confirm("Удалить ученика?")) return;
|
||||
await Api.deleteStudent(session, s["идентификатор"]);
|
||||
await refresh();
|
||||
});
|
||||
actions.appendChild(btnDel);
|
||||
tr.appendChild(actions);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
refresh().catch(console.error);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function lessonsView(session) {
|
||||
const wrap = el("div", { class: "grid-two" });
|
||||
|
||||
const formCard = el("div", { class: "card" }, [
|
||||
el("div", { class: "card-header" }, "Создать урок"),
|
||||
el("div", { class: "card-body" }, [
|
||||
(() => {
|
||||
const form = el("form", { class: "form-grid" });
|
||||
form.innerHTML = `
|
||||
<div class="input"><label>ID класса</label><input name="classId" required type="number" min="1"></div>
|
||||
<div class="input"><label>Дата</label><input name="дата" type="date" value="${fmtDateInput()}" required></div>
|
||||
<div class="input"><label>Тема</label><input name="тема" required></div>
|
||||
<div class="input" style="grid-column: 1/-1"><label>Домашнее задание</label><textarea name="домашка" rows="3"></textarea></div>
|
||||
<div class="actions"><button class="btn">Создать</button></div>
|
||||
<div id="lsn-err" class="error hidden"></div>`;
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(form);
|
||||
const classId = Number(f.get("classId"));
|
||||
const payload = {
|
||||
дата: f.get("дата"),
|
||||
тема: f.get("тема"),
|
||||
домашка: f.get("домашка"),
|
||||
};
|
||||
try {
|
||||
await Api.createLesson(session, classId, payload);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
const box = form.querySelector("#lsn-err");
|
||||
box.classList.remove("hidden");
|
||||
box.textContent = err.message || "Ошибка создания урока";
|
||||
}
|
||||
});
|
||||
return form;
|
||||
})(),
|
||||
]),
|
||||
]);
|
||||
|
||||
const listCard = el("div", { class: "card" }, [
|
||||
el("div", { class: "card-header" }, "Уроки класса"),
|
||||
el("div", { class: "card-body" }, [
|
||||
(() => {
|
||||
const filter = el("div", {
|
||||
class: "form-grid",
|
||||
style: "margin-bottom:8px",
|
||||
});
|
||||
filter.innerHTML = `
|
||||
<div class="input"><label>ID класса</label><input id="flt-class" type="number" min="1"></div>
|
||||
<div class="input"><label>Дата</label><input id="flt-date" type="date"></div>
|
||||
<div class="actions" style="align-self:end"><button id="btn-load" class="btn" type="button">Загрузить</button></div>`;
|
||||
return filter;
|
||||
})(),
|
||||
el("table", { class: "table", id: "lsn-table" }, [
|
||||
el(
|
||||
"thead",
|
||||
{},
|
||||
el("tr", {}, [
|
||||
el("th", {}, "ID"),
|
||||
el("th", {}, "Класс"),
|
||||
el("th", {}, "Дата"),
|
||||
el("th", {}, "Тема"),
|
||||
el("th", {}, "Д/З"),
|
||||
el("th", {}, ""),
|
||||
])
|
||||
),
|
||||
el("tbody"),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
wrap.appendChild(formCard);
|
||||
wrap.appendChild(listCard);
|
||||
|
||||
async function refresh() {
|
||||
const classId = Number($("#flt-class", listCard)?.value || 0);
|
||||
const date = $("#flt-date", listCard)?.value || undefined;
|
||||
if (!classId) return;
|
||||
const { data } = await Api.listLessons(session, classId, date);
|
||||
const tbody = listCard.querySelector("tbody");
|
||||
tbody.innerHTML = "";
|
||||
for (const l of data?.["уроки"] || []) {
|
||||
const tr = el("tr");
|
||||
tr.appendChild(el("td", {}, String(l["идентификатор"])));
|
||||
tr.appendChild(el("td", {}, String(l["идентификатор класса"])));
|
||||
tr.appendChild(el("td", {}, l["дата"] || ""));
|
||||
tr.appendChild(el("td", {}, l["название"] || ""));
|
||||
tr.appendChild(el("td", {}, l["домашнее задание"] || ""));
|
||||
const actions = el("td");
|
||||
const btnDel = el("button", { class: "btn danger" }, "Удалить");
|
||||
btnDel.addEventListener("click", async () => {
|
||||
if (!confirm("Удалить урок?")) return;
|
||||
await Api.deleteLessonById(
|
||||
session,
|
||||
l["идентификатор класса"],
|
||||
l["идентификатор"]
|
||||
);
|
||||
await refresh();
|
||||
});
|
||||
actions.appendChild(btnDel);
|
||||
tr.appendChild(actions);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
$("#btn-load", listCard)?.addEventListener("click", refresh);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function dashboardView() {
|
||||
const card = el("div", { class: "card" }, [
|
||||
el("div", { class: "card-header" }, "Сводная"),
|
||||
el("div", { class: "card-body" }, [
|
||||
el(
|
||||
"p",
|
||||
{},
|
||||
"Добро пожаловать! Используйте боковое меню, чтобы работать с данными."
|
||||
),
|
||||
el(
|
||||
"p",
|
||||
{ class: "small" },
|
||||
`Стоят пассажиры в аэропорту, посадочный досмотр проходят. Дошла очередь до мужика с чемоданом.
|
||||
— Пожалуйста, приготовьте чемодан к осмотру.
|
||||
— Не могу.
|
||||
— Почему?
|
||||
— А у меня там бипки!
|
||||
— А что это?
|
||||
— Ну, пропатчите сервис на тривиле — покажу!
|
||||
Служба безопасности шутку не поняла, вызвала наряд полиции:
|
||||
— Гражданин, открывайте чемодан.
|
||||
— Но я не могу!
|
||||
— Почему не можете?
|
||||
— Потому что у меня там бипки!
|
||||
— «Бипки»? Что это?
|
||||
— Так реализуйте гарбадж коллектор мне, и я покажу!
|
||||
Отобрали чемодан, а открыть никто не может. Повезли мужика в СИЗО. В камере сидельцы расспрашивают:
|
||||
— Братуха, за что тебя?
|
||||
— Да чемодан отказался открывать.
|
||||
— А что там?
|
||||
— Да бипки там.
|
||||
— Какие еще «бипки»?
|
||||
— Ну документацию обновите мне, тогда и расскажу.
|
||||
И вот угодил мужик в лазарет, с побоями да синяками по всему телу, весь перебинтован, еле дышит. Следователи вызвали группу специалистов для вскрытия чемодана. Час, два, три пыхтели. Кое-как разворотили, смотрят — а там бипки.`
|
||||
),
|
||||
]),
|
||||
]);
|
||||
return card;
|
||||
}
|
||||
|
||||
route("/dashboard", async ({ view }) => {
|
||||
const s = needSession();
|
||||
view.appendChild(sectionTabs("dashboard"));
|
||||
view.appendChild(dashboardView(s));
|
||||
});
|
||||
route("/teacher/classes", async ({ view }) => {
|
||||
const s = needSession();
|
||||
view.appendChild(sectionTabs("classes"));
|
||||
view.appendChild(classesView(s));
|
||||
});
|
||||
route("/teacher/students", async ({ view }) => {
|
||||
const s = needSession();
|
||||
view.appendChild(sectionTabs("students"));
|
||||
view.appendChild(studentsView(s));
|
||||
});
|
||||
route("/teacher/lessons", async ({ view }) => {
|
||||
const s = needSession();
|
||||
view.appendChild(sectionTabs("lessons"));
|
||||
view.appendChild(lessonsView(s));
|
||||
});
|
||||
Reference in New Issue
Block a user