feat: WebSocket real-time + rAF render gate + guest board + screen picker
Classroom performance: - WebSocket server (ws-server.js) for low-latency cursor & stroke preview Replaces HTTP POST per event → eliminates per-message auth overhead Session member cache (30s TTL) avoids SQLite query per WS message Fallback to HTTP POST when WS not connected - Cursor throttle reduced 100ms → 33ms (~30fps) - Stroke preview throttle reduced 50ms → 20ms - whiteboard.js: render() is now rAF-gated (_doRender/_rafPending) Multiple render() calls within one frame collapse into one repaint document.hidden check — zero CPU when tab is in background visibilitychange listener restores canvas on tab focus Guest board: - guestClassroom.js route: public token-based read-only access - guest-board.html: name entry + read-only whiteboard + SSE - SSE: addGuestClient/removeGuestClient/emitToGuests Screen share picker: - Discord-style modal with tab switching (screen/window/tab) - Live video preview before confirming share - useExistingScreenStream() in ClassroomRTC Fullscreen exit overlay: - #cr-fs-exit-overlay button inside cr-board-wrap - Visible only via CSS :fullscreen selector (touchpad users) File sharing from library: - Teacher picks file from library, sends as styled card in chat - crDownloadLibraryFile() fetches with Bearer auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+25
-1
@@ -154,7 +154,31 @@
|
||||
"Bash(powershell -Command \"Stop-Process -Id 69696 -Force\")",
|
||||
"Bash(powershell -Command \"Start-Sleep 1\")",
|
||||
"Bash(powershell -Command \"\\(Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue\\).OwningProcess\")",
|
||||
"Bash(powershell -Command \"Stop-Process -Id 10880 -Force\")"
|
||||
"Bash(powershell -Command \"Stop-Process -Id 10880 -Force\")",
|
||||
"Bash(grep -v '\\\\.js$')",
|
||||
"Bash(curl -s -X POST http://localhost:3000/api/auth/login -H \"Content-Type: application/json\" -d '{\"login\":\"admin\",\"password\":\"admin123\"}')",
|
||||
"Bash(curl -s -w '\\\\nHTTP_STATUS:%{http_code}' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Miwicm9sZSI6ImFkbWluIiwidmVyc2lvbiI6MCwiaWF0IjoxNzc1OTgyNzc2LCJleHAiOjE3NzYwNjkxNzZ9.FJ3Ya9X_Qg5fEUagPc1l8KrDnj2BaKrXarA-KRVr_QM' http://localhost:3000/api/classroom/6/pages)",
|
||||
"Bash(curl -s -w '\\\\nHTTP:%{http_code}' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Miwicm9sZSI6ImFkbWluIiwidmVyc2lvbiI6MCwiaWF0IjoxNzc1OTgyNzc2LCJleHAiOjE3NzYwNjkxNzZ9.FJ3Ya9X_Qg5fEUagPc1l8KrDnj2BaKrXarA-KRVr_QM' 'http://localhost:3000/api/classroom/6/strokes?page_num=1')",
|
||||
"Bash(wmic process:*)",
|
||||
"Bash(taskkill /F /PID 67276)",
|
||||
"Bash(cmd /c \"taskkill /F /PID 67276\")",
|
||||
"Bash(cmd /c \"taskkill /F /PID 67276 && echo killed\")",
|
||||
"Bash(cmd /c \"wmic process where ProcessId=67276 delete\")",
|
||||
"Bash(powershell -Command \"Stop-Process -Id 67276 -Force\")",
|
||||
"Bash(powershell -Command \"Start-Sleep -Milliseconds 1500; \\(Invoke-WebRequest -Uri 'http://localhost:3000/api/health' -UseBasicParsing\\).Content\")",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/lesson-history)",
|
||||
"Bash(curl -s http://localhost:3000/api/classroom/my/history -H \"Authorization: Bearer test\")",
|
||||
"Bash(curl -s -X DELETE http://localhost:3000/api/classroom/999/history -H \"Authorization: Bearer bad\")",
|
||||
"Bash(pkill -f \"node.*server\")",
|
||||
"Bash(grep -n \"</div>.*app-layout\\\\|<!-- app-layout\\\\|^</div>$\" frontend/classroom.html)",
|
||||
"Bash(taskkill /PID 1336 /F)",
|
||||
"Bash(taskkill /PID 55392 /F)",
|
||||
"Bash(taskkill /PID 60564 /F)",
|
||||
"Bash(ping -n 3 127.0.0.1)",
|
||||
"Bash(cmd /c \"taskkill /PID 60564 /F\")",
|
||||
"Bash(cmd /c \"taskkill /F /PID 60564 2>&1\")",
|
||||
"Bash(kill -9 60564)",
|
||||
"Bash(kill -9 9313)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"\\tmp"
|
||||
|
||||
Generated
+23
-1
@@ -15,7 +15,8 @@
|
||||
"express": "^4.18.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"sharp": "^0.34.5"
|
||||
"sharp": "^0.34.5",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
@@ -2038,6 +2039,27 @@
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.8.1",
|
||||
"compression": "^1.8.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"sharp": "^0.34.5"
|
||||
"sharp": "^0.34.5",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
|
||||
@@ -324,7 +324,7 @@ function getFeatures(_req, res) {
|
||||
/* ── PATCH /api/admin/features ──────────────────────────────────────── */
|
||||
function updateFeatures(req, res) {
|
||||
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
|
||||
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz'];
|
||||
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom'];
|
||||
const updates = req.body;
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
||||
const changed = [];
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const db = require('../db/db');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { emit, emitToClass, getOnlineUserIds } = require('../sse');
|
||||
const crypto = require('crypto');
|
||||
const { emit, emitToClass, getOnlineUserIds, emitToGuests } = require('../sse');
|
||||
|
||||
/* ── chat attachment uploads dir ─────────────────────────────────────── */
|
||||
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../uploads/chat');
|
||||
@@ -15,6 +16,14 @@ function canDraw(sessionId, userId, session) {
|
||||
).get(sessionId, userId);
|
||||
}
|
||||
|
||||
/* Events forwarded to read-only guest viewers (whiteboard + lifecycle only) */
|
||||
const GUEST_EVENTS = new Set([
|
||||
'classroom_strokes', 'classroom_stroke_preview', 'classroom_stroke_deleted',
|
||||
'classroom_stroke_updated', 'classroom_page_added', 'classroom_page_changed',
|
||||
'classroom_template_changed', 'classroom_page_cleared', 'classroom_page_renamed',
|
||||
'classroom_page_duplicated', 'classroom_page_deleted', 'classroom_ended',
|
||||
]);
|
||||
|
||||
/* ── Helper: broadcast to all session participants ─────────────────────── */
|
||||
function emitToSession(sessionId, data) {
|
||||
const session = db.prepare('SELECT class_id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
@@ -29,6 +38,9 @@ function emitToSession(sessionId, data) {
|
||||
const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
|
||||
for (const { user_id } of invites) emit(user_id, data);
|
||||
}
|
||||
|
||||
// Forward whitelisted events to guest viewers
|
||||
if (GUEST_EVENTS.has(data.type)) emitToGuests(sessionId, data);
|
||||
}
|
||||
|
||||
/* ── Helper: check if user has access to session ──────────────────────── */
|
||||
@@ -50,6 +62,12 @@ function createSession(req, res) {
|
||||
const { class_id, user_ids, title = '' } = req.body;
|
||||
const teacher = req.user;
|
||||
|
||||
// Check if classroom module is enabled
|
||||
const classroomEnabled = db.prepare("SELECT value FROM app_settings WHERE key='feature_classroom_enabled'").get();
|
||||
if (classroomEnabled?.value === '0') {
|
||||
return res.status(403).json({ error: 'Модуль онлайн-уроков отключён администратором' });
|
||||
}
|
||||
|
||||
if (!class_id && (!user_ids || !user_ids.length)) {
|
||||
return res.status(400).json({ error: 'Укажите class_id или user_ids' });
|
||||
}
|
||||
@@ -585,10 +603,11 @@ function getStrokes(req, res) {
|
||||
|
||||
const strokes = rows.map(r => ({ ...r, data: JSON.parse(r.data) }));
|
||||
|
||||
const pageRow = db.prepare('SELECT template FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, pageNum);
|
||||
const pageRow = db.prepare('SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, pageNum);
|
||||
const template = pageRow?.template || 'blank';
|
||||
const name = pageRow?.name || null;
|
||||
|
||||
res.json({ strokes, template });
|
||||
res.json({ strokes, template, name });
|
||||
}
|
||||
|
||||
/* PATCH /api/classroom/:id/strokes/:strokeId — update image position/size */
|
||||
@@ -656,6 +675,103 @@ function clearPage(req, res) {
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* GET /api/classroom/:id/pages — list all pages with names/templates */
|
||||
function getPages(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Не найдено' });
|
||||
if (!hasAccess(session, req.user.id, req.user.role))
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
const maxS = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_strokes WHERE session_id=?').get(sessionId).m;
|
||||
const maxP = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_pages WHERE session_id=?').get(sessionId).m;
|
||||
const total = Math.max(session.current_page, maxS, maxP, 1);
|
||||
|
||||
const rows = db.prepare('SELECT page_num, template, name FROM classroom_pages WHERE session_id=?').all(sessionId);
|
||||
const map = {};
|
||||
rows.forEach(r => { map[r.page_num] = r; });
|
||||
|
||||
const pages = [];
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push({ page_num: i, template: map[i]?.template || 'blank', name: map[i]?.name || null });
|
||||
}
|
||||
res.json({ pages, currentPage: session.current_page });
|
||||
}
|
||||
|
||||
/* PATCH /api/classroom/:id/pages/:pageNum/name — rename page */
|
||||
function renamePage(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const pageNum = Number(req.params.pageNum);
|
||||
const { name } = req.body;
|
||||
|
||||
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template) VALUES (?,?,?)').run(sessionId, pageNum, 'blank');
|
||||
db.prepare('UPDATE classroom_pages SET name=? WHERE session_id=? AND page_num=?').run(name || null, sessionId, pageNum);
|
||||
emitToSession(sessionId, { type: 'classroom_page_renamed', sessionId, pageNum, name: name || null });
|
||||
res.json({ ok: true, pageNum, name: name || null });
|
||||
}
|
||||
|
||||
/* POST /api/classroom/:id/pages/:pageNum/duplicate — duplicate a page */
|
||||
function duplicatePage(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const srcPage = Number(req.params.pageNum);
|
||||
|
||||
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
const srcRow = db.prepare('SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, srcPage);
|
||||
const srcTpl = srcRow?.template || 'blank';
|
||||
const newName = srcRow?.name ? srcRow.name + ' (копия)' : null;
|
||||
|
||||
const maxS = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_strokes WHERE session_id=?').get(sessionId).m;
|
||||
const maxP = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_pages WHERE session_id=?').get(sessionId).m;
|
||||
const newPage = Math.max(session.current_page, maxS, maxP) + 1;
|
||||
|
||||
db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template, name) VALUES (?,?,?,?)').run(sessionId, newPage, srcTpl, newName);
|
||||
|
||||
const strokes = db.prepare('SELECT tool, data FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq').all(sessionId, srcPage);
|
||||
const ins = db.prepare('INSERT INTO classroom_strokes (session_id, page_num, tool, data) VALUES (?,?,?,?)');
|
||||
db.transaction(() => { strokes.forEach(s => ins.run(sessionId, newPage, s.tool, s.data)); })();
|
||||
|
||||
db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(newPage, sessionId);
|
||||
emitToSession(sessionId, { type: 'classroom_page_duplicated', sessionId, srcPage, newPage, template: srcTpl, name: newName });
|
||||
res.json({ ok: true, newPage, template: srcTpl, name: newName });
|
||||
}
|
||||
|
||||
/* DELETE /api/classroom/:id/pages/:pageNum — delete a page */
|
||||
function deletePage(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const pageNum = Number(req.params.pageNum);
|
||||
|
||||
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
const maxS = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_strokes WHERE session_id=?').get(sessionId).m;
|
||||
const maxP = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_pages WHERE session_id=?').get(sessionId).m;
|
||||
const total = Math.max(session.current_page, maxS, maxP, 1);
|
||||
if (total <= 1) return res.status(400).json({ error: 'Нельзя удалить единственную страницу' });
|
||||
|
||||
db.prepare('DELETE FROM classroom_strokes WHERE session_id=? AND page_num=?').run(sessionId, pageNum);
|
||||
db.prepare('DELETE FROM classroom_pages WHERE session_id=? AND page_num=?').run(sessionId, pageNum);
|
||||
db.prepare('UPDATE classroom_strokes SET page_num=page_num-1 WHERE session_id=? AND page_num>?').run(sessionId, pageNum);
|
||||
db.prepare('UPDATE classroom_pages SET page_num=page_num-1 WHERE session_id=? AND page_num>?').run(sessionId, pageNum);
|
||||
|
||||
let newCurrent = session.current_page;
|
||||
if (newCurrent > pageNum) newCurrent--;
|
||||
else if (newCurrent === pageNum) newCurrent = Math.max(1, pageNum - 1);
|
||||
db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(newCurrent, sessionId);
|
||||
emitToSession(sessionId, { type: 'classroom_page_deleted', sessionId, pageNum, newCurrent });
|
||||
res.json({ ok: true, pageNum, newCurrent });
|
||||
}
|
||||
|
||||
/* POST /api/classroom/:id/mute — teacher mutes a student */
|
||||
function mutePeer(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
@@ -797,6 +913,66 @@ function screenStop(req, res) {
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── Simulation integration ──────────────────────────────────────────────── */
|
||||
|
||||
/* POST /api/classroom/:id/sim — teacher opens simulation for everyone */
|
||||
function simOpen(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
const { simId, title } = req.body;
|
||||
if (!simId || typeof simId !== 'string' || !/^[a-z0-9_-]{1,40}$/.test(simId))
|
||||
return res.status(400).json({ error: 'Неверный simId' });
|
||||
|
||||
emitToSession(sessionId, { type: 'classroom_sim_open', sessionId, simId, title: (title || simId).slice(0, 80) });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* POST /api/classroom/:id/sim/state — teacher relays sim state to students */
|
||||
function simState(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
const { state } = req.body;
|
||||
if (!state || typeof state !== 'object') return res.status(400).json({ error: 'Нет state' });
|
||||
|
||||
emitToSession(sessionId, { type: 'classroom_sim_state', sessionId, state });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* POST /api/classroom/:id/sim/mode — teacher sets sim mode (demo/free) */
|
||||
function simMode(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
const { mode } = req.body;
|
||||
if (mode !== 'demo' && mode !== 'free') return res.status(400).json({ error: 'mode must be demo|free' });
|
||||
|
||||
emitToSession(sessionId, { type: 'classroom_sim_mode', sessionId, mode });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* DELETE /api/classroom/:id/sim — teacher closes simulation */
|
||||
function simClose(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
emitToSession(sessionId, { type: 'classroom_sim_close', sessionId });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── Chat: upload image attachment ──────────────────────────────────────── */
|
||||
function uploadChatAttachment(req, res) {
|
||||
if (!req.file) return res.status(400).json({ error: 'Файл не получен' });
|
||||
@@ -870,7 +1046,341 @@ function saveNotes(req, res) {
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── Lesson history ─────────────────────────────────────────────────────── */
|
||||
|
||||
/* GET /api/classroom/class/:classId/history */
|
||||
function getClassHistory(req, res) {
|
||||
const classId = Number(req.params.classId);
|
||||
const page = Math.max(1, Number(req.query.page) || 1);
|
||||
const limit = Math.min(50, Number(req.query.limit) || 20);
|
||||
const search = (req.query.search || '').trim();
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Access check: admin sees all; teacher must own the class; student must be member
|
||||
const cls = db.prepare('SELECT * FROM classes WHERE id=?').get(classId);
|
||||
if (!cls) return res.status(404).json({ error: 'Класс не найден' });
|
||||
if (req.user.role === 'teacher') {
|
||||
if (cls.teacher_id !== req.user.id) return res.status(403).json({ error: 'Нет доступа' });
|
||||
} else if (req.user.role === 'student' || req.user.role === 'free_student') {
|
||||
const member = db.prepare('SELECT 1 FROM class_members WHERE class_id=? AND user_id=?').get(classId, req.user.id);
|
||||
if (!member) return res.status(403).json({ error: 'Нет доступа' });
|
||||
}
|
||||
|
||||
const whereSearch = search ? `AND (s.title LIKE '%'||?||'%')` : '';
|
||||
const params = search ? [classId, search] : [classId];
|
||||
|
||||
const total = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM classroom_sessions s
|
||||
WHERE s.class_id=? AND s.status='ended' ${whereSearch}
|
||||
`).get(...params).n;
|
||||
|
||||
const sessions = db.prepare(`
|
||||
SELECT s.id, s.title, s.created_at, s.ended_at, s.teacher_id,
|
||||
u.name AS teacher_name,
|
||||
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count,
|
||||
(SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count,
|
||||
(COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1)) AS page_count,
|
||||
(SELECT COUNT(*) FROM classroom_strokes WHERE session_id=s.id) AS stroke_count
|
||||
FROM classroom_sessions s
|
||||
JOIN users u ON u.id = s.teacher_id
|
||||
WHERE s.class_id=? AND s.status='ended' ${whereSearch}
|
||||
ORDER BY s.ended_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset);
|
||||
|
||||
res.json({ sessions, total, page, pages: Math.ceil(total / limit) });
|
||||
}
|
||||
|
||||
/* GET /api/classroom/my/history */
|
||||
function getMyHistory(req, res) {
|
||||
const page = Math.max(1, Number(req.query.page) || 1);
|
||||
const limit = Math.min(50, Number(req.query.limit) || 20);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let sessions, total;
|
||||
if (req.user.role === 'teacher' || req.user.role === 'admin') {
|
||||
total = db.prepare(`SELECT COUNT(*) AS n FROM classroom_sessions WHERE teacher_id=? AND status='ended'`).get(req.user.id).n;
|
||||
sessions = db.prepare(`
|
||||
SELECT s.id, s.title, s.created_at, s.ended_at, s.class_id, s.teacher_id,
|
||||
u.name AS teacher_name,
|
||||
c.name AS class_name,
|
||||
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count,
|
||||
(SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count,
|
||||
(COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1)) AS page_count
|
||||
FROM classroom_sessions s
|
||||
JOIN users u ON u.id = s.teacher_id
|
||||
LEFT JOIN classes c ON c.id = s.class_id
|
||||
WHERE s.teacher_id=? AND s.status='ended'
|
||||
ORDER BY s.ended_at DESC LIMIT ? OFFSET ?
|
||||
`).all(req.user.id, limit, offset);
|
||||
} else {
|
||||
// student: sessions they have attendance records for
|
||||
total = db.prepare(`
|
||||
SELECT COUNT(DISTINCT s.id) AS n FROM classroom_sessions s
|
||||
JOIN classroom_attendance a ON a.session_id=s.id
|
||||
WHERE a.user_id=? AND s.status='ended'
|
||||
`).get(req.user.id).n;
|
||||
sessions = db.prepare(`
|
||||
SELECT s.id, s.title, s.created_at, s.ended_at, s.class_id,
|
||||
u.name AS teacher_name,
|
||||
c.name AS class_name,
|
||||
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count,
|
||||
(SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count,
|
||||
(COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1)) AS page_count
|
||||
FROM classroom_sessions s
|
||||
JOIN users u ON u.id = s.teacher_id
|
||||
LEFT JOIN classes c ON c.id = s.class_id
|
||||
JOIN classroom_attendance a ON a.session_id=s.id
|
||||
WHERE a.user_id=? AND s.status='ended'
|
||||
GROUP BY s.id ORDER BY s.ended_at DESC LIMIT ? OFFSET ?
|
||||
`).all(req.user.id, limit, offset);
|
||||
}
|
||||
|
||||
res.json({ sessions, total, page, pages: Math.ceil(total / limit) });
|
||||
}
|
||||
|
||||
/* GET /api/classroom/:id/summary */
|
||||
function getSessionSummary(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Не найдено' });
|
||||
if (!hasAccess(session, req.user.id, req.user.role))
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
const teacherName = db.prepare('SELECT name FROM users WHERE id=?').get(session.teacher_id)?.name || '';
|
||||
const className = session.class_id
|
||||
? db.prepare('SELECT name FROM classes WHERE id=?').get(session.class_id)?.name || null
|
||||
: null;
|
||||
|
||||
const durationSec = session.ended_at
|
||||
? Math.round((new Date(session.ended_at) - new Date(session.created_at)) / 1000)
|
||||
: null;
|
||||
|
||||
const stats = {
|
||||
duration_sec: durationSec,
|
||||
participant_count: db.prepare('SELECT COUNT(*) AS n FROM classroom_attendance WHERE session_id=?').get(sessionId).n,
|
||||
message_count: db.prepare('SELECT COUNT(*) AS n FROM classroom_chat WHERE session_id=?').get(sessionId).n,
|
||||
page_count: db.prepare('SELECT COALESCE(MAX(page_num),1) AS n FROM classroom_pages WHERE session_id=?').get(sessionId).n,
|
||||
stroke_count: db.prepare('SELECT COUNT(*) AS n FROM classroom_strokes WHERE session_id=?').get(sessionId).n,
|
||||
};
|
||||
|
||||
const attendance = db.prepare(`
|
||||
SELECT a.user_id, u.name AS user_name, a.joined_at, a.left_at
|
||||
FROM classroom_attendance a
|
||||
JOIN users u ON u.id = a.user_id
|
||||
WHERE a.session_id=? ORDER BY a.joined_at
|
||||
`).all(sessionId).map(r => ({
|
||||
...r,
|
||||
duration_sec: (r.joined_at && r.left_at)
|
||||
? Math.round((new Date(r.left_at) - new Date(r.joined_at)) / 1000)
|
||||
: null,
|
||||
}));
|
||||
|
||||
const pages = db.prepare(`
|
||||
SELECT p.page_num, p.template, p.name,
|
||||
(SELECT COUNT(*) FROM classroom_strokes WHERE session_id=p.session_id AND page_num=p.page_num) AS stroke_count
|
||||
FROM classroom_pages p
|
||||
WHERE p.session_id=? ORDER BY p.page_num
|
||||
`).all(sessionId);
|
||||
|
||||
// Ensure at least page 1 appears if no pages row exists
|
||||
if (!pages.length) pages.push({ page_num: 1, template: 'blank', name: null, stroke_count: stats.stroke_count });
|
||||
|
||||
res.json({
|
||||
session: { ...session, teacher_name: teacherName, class_name: className },
|
||||
stats,
|
||||
attendance,
|
||||
pages,
|
||||
});
|
||||
}
|
||||
|
||||
/* GET /api/classroom/:id/chat/export — plaintext chat transcript (teacher/admin) */
|
||||
function exportChat(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Не найдено' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
const messages = db.prepare(`
|
||||
SELECT c.created_at, u.name AS user_name, c.message, c.attachment_url
|
||||
FROM classroom_chat c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.session_id=? ORDER BY c.id ASC
|
||||
`).all(sessionId);
|
||||
|
||||
const title = session.title || `Урок #${sessionId}`;
|
||||
const date = session.created_at ? new Date(session.created_at).toLocaleDateString('ru-RU') : '';
|
||||
let text = `Чат урока: ${title}\nДата: ${date}\n${'─'.repeat(50)}\n\n`;
|
||||
|
||||
messages.forEach(m => {
|
||||
const ts = m.created_at ? new Date(m.created_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
text += `[${ts}] ${m.user_name}: ${m.message || ''}`;
|
||||
if (m.attachment_url) text += ` [вложение]`;
|
||||
text += '\n';
|
||||
});
|
||||
|
||||
const filename = `chat_${sessionId}_${date.replace(/\./g, '-')}.txt`;
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(text);
|
||||
}
|
||||
|
||||
/* GET /api/classroom/:id/notes/all — all student notes (teacher/admin) */
|
||||
function getAllNotes(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Не найдено' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
const notes = db.prepare(`
|
||||
SELECT n.content, n.updated_at, u.name AS user_name, u.id AS user_id
|
||||
FROM classroom_notes n
|
||||
JOIN users u ON u.id = n.user_id
|
||||
WHERE n.session_id=? AND n.content != '' ORDER BY u.name
|
||||
`).all(sessionId);
|
||||
|
||||
res.json({ notes });
|
||||
}
|
||||
|
||||
/* ── Lesson templates ───────────────────────────────────────────────────── */
|
||||
/* DELETE /api/classroom/:id/history — permanently delete an ended session record */
|
||||
function deleteHistorySession(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Не найдено' });
|
||||
if (session.status !== 'ended') return res.status(400).json({ error: 'Можно удалять только завершённые сессии' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
db.prepare('DELETE FROM classroom_chat_reactions WHERE chat_id IN (SELECT id FROM classroom_chat WHERE session_id=?)').run(sessionId);
|
||||
db.prepare('DELETE FROM classroom_chat WHERE session_id=?').run(sessionId);
|
||||
db.prepare('DELETE FROM classroom_strokes WHERE session_id=?').run(sessionId);
|
||||
db.prepare('DELETE FROM classroom_pages WHERE session_id=?').run(sessionId);
|
||||
db.prepare('DELETE FROM classroom_attendance WHERE session_id=?').run(sessionId);
|
||||
db.prepare('DELETE FROM classroom_notes WHERE session_id=?').run(sessionId);
|
||||
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
|
||||
db.prepare('DELETE FROM classroom_sessions WHERE id=?').run(sessionId);
|
||||
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* GET /api/classroom/admin/active — all currently active sessions (admin only) */
|
||||
function adminGetActiveSessions(req, res) {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
const sessions = db.prepare(`
|
||||
SELECT s.id, s.title, s.created_at, s.class_id, s.teacher_id,
|
||||
u.name AS teacher_name,
|
||||
c.name AS class_name,
|
||||
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id AND left_at IS NULL) AS online_count,
|
||||
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS total_joined,
|
||||
(SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count
|
||||
FROM classroom_sessions s
|
||||
JOIN users u ON u.id = s.teacher_id
|
||||
LEFT JOIN classes c ON c.id = s.class_id
|
||||
WHERE s.status='active'
|
||||
ORDER BY s.created_at DESC
|
||||
`).all();
|
||||
|
||||
res.json({ sessions });
|
||||
}
|
||||
|
||||
/* GET /api/classroom/admin/sessions — all sessions paginated (admin only) */
|
||||
function adminGetAllSessions(req, res) {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
const page = Math.max(1, Number(req.query.page) || 1);
|
||||
const limit = Math.min(50, Number(req.query.limit) || 20);
|
||||
const search = (req.query.search || '').trim();
|
||||
const teacherId = (req.query.teacher || '').trim();
|
||||
const classId = (req.query.class_id || '').trim();
|
||||
const dateFrom = (req.query.date_from || '').trim();
|
||||
const dateTo = (req.query.date_to || '').trim();
|
||||
const sort = req.query.sort || 'newest';
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let where = "s.status='ended'";
|
||||
const params = [];
|
||||
if (search) { where += ` AND (s.title LIKE '%'||?||'%' OR u.name LIKE '%'||?||'%' OR c.name LIKE '%'||?||'%')`; params.push(search, search, search); }
|
||||
if (teacherId) { where += ` AND s.teacher_id=?`; params.push(Number(teacherId)); }
|
||||
if (classId) { where += ` AND s.class_id=?`; params.push(Number(classId)); }
|
||||
if (dateFrom) { where += ` AND date(s.created_at)>=?`; params.push(dateFrom); }
|
||||
if (dateTo) { where += ` AND date(s.created_at)<=?`; params.push(dateTo); }
|
||||
|
||||
const orderBy = sort === 'oldest' ? 's.ended_at ASC'
|
||||
: sort === 'longest' ? '(julianday(s.ended_at)-julianday(s.created_at)) DESC'
|
||||
: sort === 'most_students' ? 'participant_count DESC'
|
||||
: 's.ended_at DESC';
|
||||
|
||||
const total = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM classroom_sessions s
|
||||
JOIN users u ON u.id = s.teacher_id
|
||||
LEFT JOIN classes c ON c.id = s.class_id
|
||||
WHERE ${where}
|
||||
`).get(...params).n;
|
||||
|
||||
const sessions = db.prepare(`
|
||||
SELECT s.id, s.title, s.created_at, s.ended_at, s.class_id, s.teacher_id,
|
||||
u.name AS teacher_name,
|
||||
c.name AS class_name,
|
||||
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count,
|
||||
(SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count,
|
||||
COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1) AS page_count,
|
||||
(SELECT COUNT(*) FROM classroom_strokes WHERE session_id=s.id) AS stroke_count
|
||||
FROM classroom_sessions s
|
||||
JOIN users u ON u.id = s.teacher_id
|
||||
LEFT JOIN classes c ON c.id = s.class_id
|
||||
WHERE ${where}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset);
|
||||
|
||||
// Server-side aggregates (filtered)
|
||||
const agg = db.prepare(`
|
||||
SELECT COUNT(*) AS total_sessions,
|
||||
COUNT(DISTINCT s.teacher_id) AS total_teachers,
|
||||
COALESCE(SUM(CASE WHEN s.ended_at IS NOT NULL AND s.created_at IS NOT NULL
|
||||
THEN CAST((julianday(s.ended_at) - julianday(s.created_at)) * 86400 AS INTEGER)
|
||||
ELSE 0 END), 0) AS total_duration_sec
|
||||
FROM classroom_sessions s
|
||||
JOIN users u ON u.id = s.teacher_id
|
||||
LEFT JOIN classes c ON c.id = s.class_id
|
||||
WHERE ${where}
|
||||
`).get(...params);
|
||||
|
||||
const aggPart = db.prepare(`
|
||||
SELECT COALESCE(SUM(sub.pc),0) AS total_participants,
|
||||
COALESCE(SUM(sub.mc),0) AS total_messages
|
||||
FROM (
|
||||
SELECT s.id,
|
||||
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS pc,
|
||||
(SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS mc
|
||||
FROM classroom_sessions s
|
||||
JOIN users u ON u.id = s.teacher_id
|
||||
LEFT JOIN classes c ON c.id = s.class_id
|
||||
WHERE ${where}
|
||||
) sub
|
||||
`).get(...params);
|
||||
|
||||
res.json({ sessions, total, page, pages: Math.ceil(total / limit),
|
||||
agg: { ...agg, ...aggPart } });
|
||||
}
|
||||
|
||||
/* GET /api/classroom/admin/teachers-list — teachers who have sessions (admin only) */
|
||||
function adminGetTeachersList(req, res) {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' });
|
||||
const teachers = db.prepare(`
|
||||
SELECT DISTINCT u.id, u.name
|
||||
FROM classroom_sessions s
|
||||
JOIN users u ON u.id = s.teacher_id
|
||||
WHERE s.status='ended'
|
||||
ORDER BY u.name
|
||||
`).all();
|
||||
res.json({ teachers });
|
||||
}
|
||||
|
||||
function getTemplates(req, res) {
|
||||
const templates = db.prepare(
|
||||
'SELECT id, title, description, created_at FROM classroom_templates WHERE teacher_id=? ORDER BY created_at DESC'
|
||||
@@ -953,6 +1463,41 @@ function loadTemplate(req, res) {
|
||||
res.json({ ok: true, pages: pagesData.length });
|
||||
}
|
||||
|
||||
/* ── Guest token: generate / revoke ───────────────────────────────────── */
|
||||
function generateGuestToken(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const session = db.prepare('SELECT id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Not found' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
db.prepare('UPDATE classroom_sessions SET guest_token=? WHERE id=?').run(token, sessionId);
|
||||
res.json({ token, url: `/guest-board.html?token=${token}` });
|
||||
}
|
||||
|
||||
function revokeGuestToken(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const session = db.prepare('SELECT id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Not found' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
|
||||
db.prepare('UPDATE classroom_sessions SET guest_token=NULL WHERE id=?').run(sessionId);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
function getGuestToken(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const session = db.prepare('SELECT id, teacher_id, guest_token FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Not found' });
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
|
||||
if (!session.guest_token) return res.json({ token: null, url: null });
|
||||
res.json({ token: session.guest_token, url: `/guest-board.html?token=${session.guest_token}` });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSession,
|
||||
getSession,
|
||||
@@ -975,12 +1520,20 @@ module.exports = {
|
||||
addPage,
|
||||
changePage,
|
||||
updatePageTemplate,
|
||||
getPages,
|
||||
renamePage,
|
||||
duplicatePage,
|
||||
deletePage,
|
||||
raiseHand,
|
||||
lowerHand,
|
||||
getHands,
|
||||
mutePeer,
|
||||
screenStart,
|
||||
screenStop,
|
||||
simOpen,
|
||||
simClose,
|
||||
simState,
|
||||
simMode,
|
||||
clearPage,
|
||||
previewStroke,
|
||||
broadcastCursor,
|
||||
@@ -995,4 +1548,16 @@ module.exports = {
|
||||
saveTemplate,
|
||||
deleteTemplate,
|
||||
loadTemplate,
|
||||
getClassHistory,
|
||||
getMyHistory,
|
||||
getSessionSummary,
|
||||
exportChat,
|
||||
getAllNotes,
|
||||
deleteHistorySession,
|
||||
adminGetActiveSessions,
|
||||
adminGetAllSessions,
|
||||
adminGetTeachersList,
|
||||
generateGuestToken,
|
||||
revokeGuestToken,
|
||||
getGuestToken,
|
||||
};
|
||||
|
||||
@@ -862,7 +862,8 @@ db.exec(`
|
||||
('feature_knowledge_map_enabled', '1'),
|
||||
('feature_board_enabled', '1'),
|
||||
('feature_biochem_enabled', '1'),
|
||||
('feature_live_quiz_enabled', '1');
|
||||
('feature_live_quiz_enabled', '1'),
|
||||
('feature_classroom_enabled', '1');
|
||||
`);
|
||||
|
||||
/* ── Performance indexes ───────────────────────────────────────────────── */
|
||||
@@ -2773,8 +2774,9 @@ db.exec(`
|
||||
UNIQUE(session_id, page_num)
|
||||
)
|
||||
`);
|
||||
// Add template column to existing classroom_pages tables (idempotent)
|
||||
// Add template + name columns to existing classroom_pages tables (idempotent)
|
||||
try { db.exec(`ALTER TABLE classroom_pages ADD COLUMN template TEXT NOT NULL DEFAULT 'blank'`); } catch (_) { /* already exists */ }
|
||||
try { db.exec(`ALTER TABLE classroom_pages ADD COLUMN name TEXT`); } catch (_) { /* already exists */ }
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS classroom_strokes (
|
||||
@@ -2854,6 +2856,9 @@ db.exec(`
|
||||
)
|
||||
`);
|
||||
|
||||
// Guest token for classroom sessions (public read-only whiteboard access)
|
||||
try { db.exec("ALTER TABLE classroom_sessions ADD COLUMN guest_token TEXT UNIQUE"); } catch {}
|
||||
|
||||
// Persistent draw permissions (survives server restart)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS classroom_draw_permissions (
|
||||
|
||||
@@ -30,9 +30,16 @@ const auth = [authMiddleware];
|
||||
const chatLimiter = rateLimit({ windowMs: 5_000, max: 5, message: 'Слишком много сообщений, подождите' });
|
||||
|
||||
// Template library — MUST be before /:id to avoid shadowing
|
||||
router.get('/admin/active', ...teacher, c.adminGetActiveSessions);
|
||||
router.get('/admin/sessions', ...teacher, c.adminGetAllSessions);
|
||||
router.get('/admin/teachers-list', ...teacher, c.adminGetTeachersList);
|
||||
router.get('/templates', ...teacher, c.getTemplates);
|
||||
router.delete('/templates/:tid', ...teacher, c.deleteTemplate);
|
||||
|
||||
// History — MUST be before /:id to avoid shadowing
|
||||
router.get('/my/history', ...auth, c.getMyHistory);
|
||||
router.get('/class/:classId/history', ...auth, c.getClassHistory);
|
||||
|
||||
// Session lifecycle
|
||||
router.post('/', ...teacher, c.createSession);
|
||||
router.get('/online-students', ...teacher, c.getOnlineStudents);
|
||||
@@ -65,9 +72,13 @@ router.patch('/:id/strokes/:strokeId', ...auth, c.updateStroke);
|
||||
router.post('/:id/stroke-preview', ...auth, c.previewStroke);
|
||||
|
||||
// Multi-page
|
||||
router.post('/:id/pages', ...teacher, c.addPage);
|
||||
router.put('/:id/page', ...teacher, c.changePage);
|
||||
router.patch('/:id/page-template', ...teacher, c.updatePageTemplate);
|
||||
router.get('/:id/pages', ...auth, c.getPages);
|
||||
router.post('/:id/pages', ...teacher, c.addPage);
|
||||
router.put('/:id/page', ...teacher, c.changePage);
|
||||
router.patch('/:id/page-template', ...teacher, c.updatePageTemplate);
|
||||
router.patch('/:id/pages/:pageNum/name', ...teacher, c.renamePage);
|
||||
router.post('/:id/pages/:pageNum/duplicate', ...teacher, c.duplicatePage);
|
||||
router.delete('/:id/pages/:pageNum', ...teacher, c.deletePage);
|
||||
|
||||
// Hand raise
|
||||
router.post('/:id/hand', ...auth, c.raiseHand);
|
||||
@@ -82,6 +93,12 @@ router.post('/:id/mute', ...teacher, c.mutePeer);
|
||||
router.post('/:id/screen', ...teacher, c.screenStart);
|
||||
router.delete('/:id/screen', ...teacher, c.screenStop);
|
||||
|
||||
// Simulation: open/close/state/mode for all participants
|
||||
router.post('/:id/sim', ...teacher, c.simOpen);
|
||||
router.delete('/:id/sim', ...teacher, c.simClose);
|
||||
router.post('/:id/sim/state', ...teacher, c.simState);
|
||||
router.post('/:id/sim/mode', ...teacher, c.simMode);
|
||||
|
||||
// Cursor broadcast (all participants)
|
||||
router.post('/:id/cursor', ...auth, c.broadcastCursor);
|
||||
|
||||
@@ -95,10 +112,21 @@ router.delete('/:id/allow-draw/:userId', ...teacher, c.revokeDraw);
|
||||
// Session notes (per user)
|
||||
router.get('/:id/notes', ...auth, c.getNotes);
|
||||
router.put('/:id/notes', ...auth, c.saveNotes);
|
||||
router.get('/:id/notes/all', ...teacher, c.getAllNotes);
|
||||
|
||||
// Session summary & history detail
|
||||
router.get('/:id/summary', ...auth, c.getSessionSummary);
|
||||
router.get('/:id/chat/export', ...teacher, c.exportChat);
|
||||
router.delete('/:id/history', ...teacher, c.deleteHistorySession);
|
||||
|
||||
// Save current session as template
|
||||
router.post('/:id/save-template', ...teacher, c.saveTemplate);
|
||||
// Load template into current session
|
||||
router.post('/:id/load-template', ...teacher, c.loadTemplate);
|
||||
|
||||
// Guest token (generate/revoke/get)
|
||||
router.get('/:id/guest-token', ...teacher, c.getGuestToken);
|
||||
router.post('/:id/guest-token', ...teacher, c.generateGuestToken);
|
||||
router.delete('/:id/guest-token', ...teacher, c.revokeGuestToken);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Public (no-auth) guest classroom routes.
|
||||
* Guests access the whiteboard read-only via a token in the URL.
|
||||
*
|
||||
* GET /api/classroom/guest/:token — session info (pre-join)
|
||||
* POST /api/classroom/guest/:token/join — choose display name, get guestId
|
||||
* GET /api/classroom/guest/:token/strokes — strokes for ?page_num=N
|
||||
* GET /api/classroom/guest/:token/stream — SSE stream (?guestId=X)
|
||||
* POST /api/classroom/guest/:token/leave — notify departure (optional)
|
||||
*/
|
||||
|
||||
const router = require('express').Router();
|
||||
const crypto = require('crypto');
|
||||
const db = require('../db/db');
|
||||
const { addGuestClient, removeGuestClient, emitToGuests, emit } = require('../sse');
|
||||
|
||||
/* ── In-memory guest registry (cleared on server restart — fine for guests) */
|
||||
const guests = new Map(); // guestId → { name, sessionId, connectedAt }
|
||||
|
||||
/* Helper: look up session by token (active only) */
|
||||
function sessionByToken(token) {
|
||||
return db.prepare(
|
||||
"SELECT id, title, status, current_page, teacher_id FROM classroom_sessions WHERE guest_token=?"
|
||||
).get(token);
|
||||
}
|
||||
|
||||
/* Helper: emit guest-joined/left to the real participants of the session */
|
||||
function notifySession(sessionId, data) {
|
||||
const session = db.prepare('SELECT class_id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
if (!session) return;
|
||||
const { emit: _emit, emitToClass } = require('../sse');
|
||||
if (session.class_id) {
|
||||
emitToClass(session.class_id, data);
|
||||
_emit(session.teacher_id, data);
|
||||
} else {
|
||||
_emit(session.teacher_id, data);
|
||||
const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
|
||||
for (const { user_id } of invites) _emit(user_id, data);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── GET /api/classroom/guest/:token ─── session info (pre-join screen) */
|
||||
router.get('/:token', (req, res) => {
|
||||
const session = sessionByToken(req.params.token);
|
||||
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
|
||||
|
||||
const pageCount = db.prepare(
|
||||
'SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?'
|
||||
).get(session.id).c || 1;
|
||||
|
||||
res.json({
|
||||
id: session.id,
|
||||
title: session.title || 'Онлайн-урок',
|
||||
status: session.status,
|
||||
current_page: session.current_page || 1,
|
||||
page_count: pageCount,
|
||||
});
|
||||
});
|
||||
|
||||
/* ── POST /api/classroom/guest/:token/join ─── choose name, get guestId */
|
||||
router.post('/:token/join', (req, res) => {
|
||||
const session = sessionByToken(req.params.token);
|
||||
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
|
||||
if (session.status !== 'active')
|
||||
return res.status(403).json({ error: 'Урок ещё не начался или уже завершён' });
|
||||
|
||||
const rawName = (req.body?.name || '').trim().slice(0, 40);
|
||||
const name = rawName || 'Гость';
|
||||
|
||||
const guestId = 'g_' + crypto.randomBytes(12).toString('base64url');
|
||||
guests.set(guestId, { name, sessionId: session.id, connectedAt: Date.now() });
|
||||
|
||||
// Notify real participants that a guest joined
|
||||
notifySession(session.id, {
|
||||
type: 'classroom_guest_joined',
|
||||
sessionId: session.id,
|
||||
guestId,
|
||||
guestName: name,
|
||||
});
|
||||
|
||||
const pageCount = db.prepare(
|
||||
'SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?'
|
||||
).get(session.id).c || 1;
|
||||
|
||||
res.json({
|
||||
guestId,
|
||||
sessionId: session.id,
|
||||
title: session.title || 'Онлайн-урок',
|
||||
current_page: session.current_page || 1,
|
||||
page_count: pageCount,
|
||||
});
|
||||
});
|
||||
|
||||
/* ── GET /api/classroom/guest/:token/strokes ─── whiteboard strokes */
|
||||
router.get('/:token/strokes', (req, res) => {
|
||||
const session = sessionByToken(req.params.token);
|
||||
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
|
||||
if (session.status !== 'active')
|
||||
return res.status(403).json({ error: 'Урок не активен' });
|
||||
|
||||
const pageNum = Math.max(1, Number(req.query.page_num) || 1);
|
||||
const sinceSeq = Number(req.query.since_seq) || 0;
|
||||
|
||||
const strokes = sinceSeq > 0
|
||||
? db.prepare(
|
||||
'SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? AND seq > ? ORDER BY seq'
|
||||
).all(session.id, pageNum, sinceSeq)
|
||||
: db.prepare(
|
||||
'SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq'
|
||||
).all(session.id, pageNum);
|
||||
|
||||
const pageRow = db.prepare(
|
||||
'SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?'
|
||||
).get(session.id, pageNum);
|
||||
|
||||
const parsed = strokes.map(s => ({ ...s, data: JSON.parse(s.data || '{}') }));
|
||||
res.json({ strokes: parsed, template: pageRow?.template || 'blank', seq: parsed.at(-1)?.seq || 0 });
|
||||
});
|
||||
|
||||
/* ── GET /api/classroom/guest/:token/stream ─── SSE */
|
||||
router.get('/:token/stream', (req, res) => {
|
||||
const session = sessionByToken(req.params.token);
|
||||
if (!session) return res.status(404).end();
|
||||
if (session.status !== 'active') return res.status(403).end();
|
||||
|
||||
const guestId = req.query.guestId;
|
||||
const guest = guestId ? guests.get(guestId) : null;
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // nginx: disable buffering
|
||||
res.flushHeaders();
|
||||
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
|
||||
|
||||
addGuestClient(session.id, res);
|
||||
|
||||
req.on('close', () => {
|
||||
removeGuestClient(session.id, res);
|
||||
if (guest) {
|
||||
guests.delete(guestId);
|
||||
notifySession(session.id, {
|
||||
type: 'classroom_guest_left',
|
||||
sessionId: session.id,
|
||||
guestId,
|
||||
guestName: guest.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ── POST /api/classroom/guest/:token/leave ─── explicit goodbye */
|
||||
router.post('/:token/leave', (req, res) => {
|
||||
const guestId = req.body?.guestId;
|
||||
const guest = guestId ? guests.get(guestId) : null;
|
||||
if (guest) {
|
||||
guests.delete(guestId);
|
||||
notifySession(guest.sessionId, {
|
||||
type: 'classroom_guest_left',
|
||||
sessionId: guest.sessionId,
|
||||
guestId,
|
||||
guestName: guest.name,
|
||||
});
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+11
-6
@@ -32,8 +32,9 @@ const flashcardRoutes = require('./routes/flashcards');
|
||||
const settingsRoutes = require('./routes/settings');
|
||||
const analyticsRoutes = require('./routes/analytics');
|
||||
const liveRoutes = require('./routes/live');
|
||||
const classroomRoutes = require('./routes/classroom');
|
||||
const gamesRoutes = require('./routes/games');
|
||||
const classroomRoutes = require('./routes/classroom');
|
||||
const guestClassroomRoutes = require('./routes/guestClassroom');
|
||||
const gamesRoutes = require('./routes/games');
|
||||
const knowledgeMapRoutes = require('./routes/knowledgeMap');
|
||||
const petRoutes = require('./routes/pet');
|
||||
const collectionRoutes = require('./routes/collection');
|
||||
@@ -77,8 +78,8 @@ app.use((_req, res, next) => {
|
||||
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " +
|
||||
"img-src 'self' data: blob: https:; " +
|
||||
"connect-src 'self' https://cdn.jsdelivr.net https://stun.l.google.com; " +
|
||||
"frame-src https://www.youtube.com https://rutube.ru https://player.vimeo.com; " +
|
||||
"frame-ancestors 'none'" +
|
||||
"frame-src 'self' https://www.youtube.com https://rutube.ru https://player.vimeo.com; " +
|
||||
"frame-ancestors 'self'" +
|
||||
(isProd ? "; upgrade-insecure-requests" : "")
|
||||
);
|
||||
if (isProd) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
@@ -143,8 +144,9 @@ app.use('/api/search', searchRoutes);
|
||||
app.use('/api/flashcards', flashcardRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/analytics', analyticsRoutes);
|
||||
app.use('/api/live', liveRoutes);
|
||||
app.use('/api/classroom', classroomRoutes);
|
||||
app.use('/api/live', liveRoutes);
|
||||
app.use('/api/classroom/guest', guestClassroomRoutes); // public — MUST be before /api/classroom
|
||||
app.use('/api/classroom', classroomRoutes);
|
||||
app.use('/api/games', gamesRoutes);
|
||||
app.use('/api/knowledge-map', knowledgeMapRoutes);
|
||||
app.use('/api/pet', petRoutes);
|
||||
@@ -308,6 +310,9 @@ app.use((_req, res) => res.status(404).sendFile(path.join(frontendDir, '404.html
|
||||
|
||||
const server = app.listen(PORT, () => logger.info(`Server running on port ${PORT}`, { env: config.NODE_ENV }));
|
||||
|
||||
/* ── WebSocket server for low-latency classroom events (cursor + preview) ── */
|
||||
require('./ws-server').attach(server);
|
||||
|
||||
/* ── Graceful shutdown ── */
|
||||
function shutdown(signal) {
|
||||
logger.info(`${signal} received — shutting down gracefully`);
|
||||
|
||||
+37
-2
@@ -1,5 +1,6 @@
|
||||
/* ── SSE registry — shared between controllers ─────────────────────────── */
|
||||
const clients = new Map(); // userId -> Set<res>
|
||||
const clients = new Map(); // userId -> Set<res>
|
||||
const guestClients = new Map(); // sessionId -> Set<res>
|
||||
const db = require('./db/db');
|
||||
|
||||
function addClient(userId, res) {
|
||||
@@ -34,6 +35,15 @@ setInterval(() => {
|
||||
}
|
||||
if (conns.size === 0) clients.delete(userId);
|
||||
}
|
||||
for (const [sessionId, conns] of guestClients) {
|
||||
for (const res of conns) {
|
||||
try {
|
||||
if (res.writableEnded || res.destroyed) { conns.delete(res); continue; }
|
||||
res.write(': heartbeat\n\n');
|
||||
} catch { conns.delete(res); }
|
||||
}
|
||||
if (conns.size === 0) guestClients.delete(sessionId);
|
||||
}
|
||||
}, 30_000).unref();
|
||||
|
||||
/* Broadcast to all members of a class */
|
||||
@@ -42,9 +52,34 @@ function emitToClass(classId, data) {
|
||||
for (const { user_id } of members) emit(user_id, data);
|
||||
}
|
||||
|
||||
/* ── Guest SSE (session-scoped, no userId) ── */
|
||||
function addGuestClient(sessionId, res) {
|
||||
if (!guestClients.has(sessionId)) guestClients.set(sessionId, new Set());
|
||||
guestClients.get(sessionId).add(res);
|
||||
}
|
||||
|
||||
function removeGuestClient(sessionId, res) {
|
||||
const set = guestClients.get(sessionId);
|
||||
if (!set) return;
|
||||
set.delete(res);
|
||||
if (set.size === 0) guestClients.delete(sessionId);
|
||||
}
|
||||
|
||||
function emitToGuests(sessionId, data) {
|
||||
const set = guestClients.get(sessionId);
|
||||
if (!set?.size) return;
|
||||
const payload = `data: ${JSON.stringify(data)}\n\n`;
|
||||
for (const res of set) {
|
||||
try { res.write(payload); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/* Returns array of user IDs currently connected via SSE */
|
||||
function getOnlineUserIds() {
|
||||
return [...clients.keys()];
|
||||
}
|
||||
|
||||
module.exports = { addClient, removeClient, emit, emitToClass, getOnlineUserIds };
|
||||
module.exports = {
|
||||
addClient, removeClient, emit, emitToClass, getOnlineUserIds,
|
||||
addGuestClient, removeGuestClient, emitToGuests,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* WebSocket server for low-latency real-time classroom events.
|
||||
*
|
||||
* Handles two message types from clients:
|
||||
* { type:'cursor', sessionId, x, y, pageNum }
|
||||
* { type:'preview', sessionId, liveId, tool, data, pageNum, cancel }
|
||||
*
|
||||
* Auth: JWT token in ?token= query param on upgrade request.
|
||||
* Forwards events via SSE to session participants (no DB write).
|
||||
*
|
||||
* This replaces the HTTP POST /cursor and /stroke-preview endpoints
|
||||
* for connected clients, reducing per-event latency from ~50-120ms to ~2-5ms.
|
||||
*/
|
||||
const { WebSocketServer } = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('./db/db');
|
||||
const { emit, emitToGuests } = require('./sse');
|
||||
|
||||
/* ── Session member cache (avoids DB query per WS message) ────────────── */
|
||||
const _cache = new Map(); // sessionId → { teacherId, classId, userIds, ts }
|
||||
const CACHE_TTL = 30_000;
|
||||
|
||||
function _getMembers(sessionId) {
|
||||
const c = _cache.get(sessionId);
|
||||
if (c && Date.now() - c.ts < CACHE_TTL) return c;
|
||||
|
||||
const session = db.prepare(
|
||||
"SELECT class_id, teacher_id FROM classroom_sessions WHERE id=? AND status='active'"
|
||||
).get(sessionId);
|
||||
if (!session) { _cache.delete(sessionId); return null; }
|
||||
|
||||
let userIds;
|
||||
if (session.class_id) {
|
||||
const members = db.prepare('SELECT user_id FROM class_members WHERE class_id=?').all(session.class_id);
|
||||
userIds = [session.teacher_id, ...members.map(m => m.user_id)];
|
||||
} else {
|
||||
const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
|
||||
userIds = [session.teacher_id, ...invites.map(i => i.user_id)];
|
||||
}
|
||||
|
||||
const entry = { teacherId: session.teacher_id, classId: session.class_id, userIds, ts: Date.now() };
|
||||
_cache.set(sessionId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function _invalidateSession(sessionId) {
|
||||
_cache.delete(sessionId);
|
||||
}
|
||||
|
||||
/* Forward serialized SSE payload to all session members */
|
||||
function _broadcast(sessionId, data, includeGuests) {
|
||||
const members = _getMembers(sessionId);
|
||||
if (!members) return;
|
||||
for (const uid of members.userIds) emit(uid, data);
|
||||
if (includeGuests) emitToGuests(sessionId, data);
|
||||
}
|
||||
|
||||
/* Check draw permissions (teacher always can; students need explicit grant) */
|
||||
const _drawCache = new Map(); // `${sessionId}:${userId}` → { allowed, ts }
|
||||
const DRAW_TTL = 10_000;
|
||||
|
||||
function _canDraw(sessionId, userId, members) {
|
||||
if (!members) return false;
|
||||
if (members.teacherId === userId) return true;
|
||||
const key = `${sessionId}:${userId}`;
|
||||
const c = _drawCache.get(key);
|
||||
if (c && Date.now() - c.ts < DRAW_TTL) return c.allowed;
|
||||
const allowed = !!db.prepare(
|
||||
'SELECT 1 FROM classroom_draw_permissions WHERE session_id=? AND user_id=?'
|
||||
).get(sessionId, userId);
|
||||
_drawCache.set(key, { allowed, ts: Date.now() });
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/* ── WebSocket server ──────────────────────────────────────────────────── */
|
||||
function attach(httpServer) {
|
||||
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
/* ── Auth ── */
|
||||
let user = null;
|
||||
try {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const token = url.searchParams.get('token') || '';
|
||||
user = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
} catch {
|
||||
ws.close(4001, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
ws.userId = user.id;
|
||||
ws.userName = user.name || user.email || '';
|
||||
ws.isAlive = true;
|
||||
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
ws.on('message', raw => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw); } catch { return; }
|
||||
|
||||
const { type, sessionId } = msg;
|
||||
if (!sessionId || typeof sessionId !== 'number') return;
|
||||
|
||||
/* ── cursor broadcast ── */
|
||||
if (type === 'cursor') {
|
||||
const members = _getMembers(sessionId);
|
||||
if (!members || !members.userIds.includes(ws.userId)) return;
|
||||
|
||||
_broadcast(sessionId, {
|
||||
type: 'classroom_cursor',
|
||||
sessionId,
|
||||
x: msg.x,
|
||||
y: msg.y,
|
||||
pageNum: msg.pageNum || 1,
|
||||
userId: ws.userId,
|
||||
userName: ws.userName,
|
||||
}, true);
|
||||
|
||||
/* ── stroke preview broadcast ── */
|
||||
} else if (type === 'preview') {
|
||||
const members = _getMembers(sessionId);
|
||||
if (!members) return;
|
||||
if (!_canDraw(sessionId, ws.userId, members)) return;
|
||||
|
||||
const liveId = msg.liveId;
|
||||
if (!liveId && !msg.cancel) return;
|
||||
|
||||
_broadcast(sessionId, {
|
||||
type: 'classroom_stroke_preview',
|
||||
sessionId,
|
||||
pageNum: msg.pageNum || 1,
|
||||
liveId,
|
||||
tool: msg.tool,
|
||||
data: msg.data,
|
||||
cancel: msg.cancel || false,
|
||||
userId: ws.userId,
|
||||
userName: ws.userName,
|
||||
}, true);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', () => {});
|
||||
ws.on('close', () => {});
|
||||
});
|
||||
|
||||
/* ── Ping/pong keepalive ── */
|
||||
const pingTimer = setInterval(() => {
|
||||
for (const ws of wss.clients) {
|
||||
if (!ws.isAlive) { ws.terminate(); continue; }
|
||||
ws.isAlive = false;
|
||||
try { ws.ping(); } catch {}
|
||||
}
|
||||
}, 30_000);
|
||||
wss.on('close', () => clearInterval(pingTimer));
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
module.exports = { attach, invalidateSession: _invalidateSession };
|
||||
@@ -709,6 +709,124 @@
|
||||
}
|
||||
.sl-filter-select:focus { border-color: var(--violet); outline: none; }
|
||||
.sl-count { font-size: 0.78rem; color: #8898AA; font-weight: 600; }
|
||||
|
||||
/* ══════════ CLASSROOM ADMIN TAB ══════════ */
|
||||
.cr-admin-section { margin-bottom: 40px; }
|
||||
.cr-admin-section-title {
|
||||
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
|
||||
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.07em;
|
||||
margin-bottom: 16px; display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.cr-admin-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
/* Active session card */
|
||||
.cr-live-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.cr-live-card {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
background: var(--surface); border: 1.5px solid var(--border);
|
||||
border-left: 4px solid #EF4444; border-radius: 16px;
|
||||
padding: 14px 18px; transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
.cr-live-card:hover { box-shadow: 0 4px 20px rgba(15,23,42,0.1); transform: translateX(2px); }
|
||||
.cr-live-pulse {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: #EF4444; flex-shrink: 0;
|
||||
animation: pulse-live 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-live {
|
||||
0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.5); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(239,68,68,0); }
|
||||
}
|
||||
.cr-live-info { flex: 1; min-width: 0; }
|
||||
.cr-live-title { font-size: 0.96rem; font-weight: 700; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cr-live-meta { font-size: 0.81rem; color: var(--text-3); }
|
||||
.cr-live-badges { display: flex; gap: 6px; flex-shrink: 0; align-items: center; }
|
||||
.cr-badge {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 4px 10px; border-radius: 99px; font-size: 0.76rem; font-weight: 700;
|
||||
}
|
||||
.cr-badge-online { background: rgba(6,214,100,0.12); color: #059652; }
|
||||
.cr-badge-msgs { background: rgba(6,214,224,0.12); color: #05aab3; }
|
||||
.cr-badge-dur { background: rgba(155,93,229,0.1); color: var(--violet); }
|
||||
.cr-live-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
/* History session row */
|
||||
.cr-hist-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.cr-hist-row {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 12px 16px; cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.cr-hist-row:hover { border-color: var(--violet); box-shadow: 0 2px 12px rgba(109,40,217,0.07); }
|
||||
.cr-hist-row.open { border-color: var(--violet); background: rgba(155,93,229,0.03); border-radius: 14px 14px 0 0; border-bottom: none; }
|
||||
.cr-hist-icon { width: 38px; height: 38px; border-radius: 10px; background: rgba(155,93,229,0.1); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.cr-hist-main { flex: 1; min-width: 0; }
|
||||
.cr-hist-title { font-size: 0.94rem; font-weight: 700; margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cr-hist-meta { font-size: 0.79rem; color: var(--text-3); }
|
||||
.cr-hist-chips { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.cr-hist-chevron { width: 18px; height: 18px; color: var(--text-3); transition: transform 0.2s; flex-shrink: 0; }
|
||||
.cr-hist-row.open .cr-hist-chevron { transform: rotate(180deg); color: var(--violet); }
|
||||
|
||||
/* Session detail drawer */
|
||||
.cr-detail-drawer {
|
||||
overflow: hidden; max-height: 0; transition: max-height 0.35s ease;
|
||||
border: 1px solid var(--violet); border-top: none;
|
||||
border-radius: 0 0 14px 14px; background: rgba(238,242,255,0.5);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.cr-detail-drawer.open { max-height: 3000px; margin-bottom: 8px; }
|
||||
.cr-detail-inner { padding: 20px 24px; }
|
||||
.cr-detail-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
|
||||
@media(max-width:700px) { .cr-detail-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
.cr-detail-stat {
|
||||
background: #fff; border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 14px 16px; text-align: center;
|
||||
}
|
||||
.cr-detail-val { font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800; color: var(--violet); margin-bottom: 4px; }
|
||||
.cr-detail-label { font-size: 0.72rem; color: var(--text-3); font-weight: 700; text-transform: uppercase; }
|
||||
.cr-attend-list { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
|
||||
.cr-attend-row {
|
||||
display: flex; align-items: center; gap: 12px; padding: 8px 12px;
|
||||
border: 1px solid var(--border); border-radius: 10px; background: #fff;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.cr-attend-name { flex: 1; font-weight: 600; }
|
||||
.cr-attend-time { color: var(--text-3); font-size: 0.8rem; }
|
||||
.cr-attend-dur { color: var(--cyan); font-weight: 700; font-size: 0.8rem; min-width: 60px; text-align: right; }
|
||||
.cr-pages-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px,1fr)); gap: 8px; margin-top: 10px; }
|
||||
.cr-page-chip {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: #fff; border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 8px 12px; font-size: 0.82rem;
|
||||
}
|
||||
.cr-page-num { font-weight: 700; color: var(--violet); }
|
||||
.cr-page-cnt { color: var(--text-3); font-size: 0.76rem; }
|
||||
.cr-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.btn-cr-export { padding: 8px 18px; border: 1.5px solid var(--cyan); border-radius: 99px; background: rgba(6,214,224,0.06); color: #05aab3; font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: all 0.15s; }
|
||||
.btn-cr-export:hover { background: rgba(6,214,224,0.15); }
|
||||
.btn-cr-del { padding: 8px 18px; border: 1.5px solid rgba(241,91,181,0.4); border-radius: 99px; background: transparent; color: var(--pink); font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: all 0.15s; }
|
||||
.btn-cr-del:hover { background: rgba(241,91,181,0.08); border-color: var(--pink); }
|
||||
.btn-cr-end { padding: 8px 18px; border: none; border-radius: 99px; background: #EF4444; color: #fff; font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: opacity 0.15s; }
|
||||
.btn-cr-end:hover { opacity: 0.85; }
|
||||
|
||||
/* Pagination */
|
||||
.cr-pagination { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 24px; flex-wrap: wrap; }
|
||||
.cr-page-btn {
|
||||
min-width: 36px; height: 36px; padding: 0 12px; border: 1.5px solid var(--border);
|
||||
border-radius: 10px; background: var(--surface); font-family:'Manrope',sans-serif;
|
||||
font-size:0.85rem; font-weight:700; color:var(--text-2); cursor:pointer; transition:all 0.14s;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.cr-page-btn:hover:not(:disabled) { border-color:var(--violet); color:var(--violet); }
|
||||
.cr-page-btn.active { background:var(--violet); border-color:var(--violet); color:#fff; }
|
||||
.cr-page-btn:disabled { opacity:0.4; cursor:default; }
|
||||
.cr-page-info { font-size:0.82rem; color:var(--text-3); font-weight:600; }
|
||||
|
||||
/* toolbar for classroom history */
|
||||
.cr-hist-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.cr-hist-search { flex: 1; min-width: 180px; padding: 9px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); font-family:'Manrope',sans-serif; font-size:0.88rem; background:var(--surface); color:var(--text); }
|
||||
.cr-hist-search:focus { outline:none; border-color:var(--violet); }
|
||||
.cr-hist-count { font-size:0.85rem; color:var(--text-3); font-weight:600; white-space:nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -733,6 +851,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<span class="sb-link active"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></span>
|
||||
</nav>
|
||||
@@ -762,6 +881,9 @@
|
||||
<button class="admin-nav-item" data-tab="sessions" onclick="switchTab(this)">
|
||||
<i data-lucide="clock" style="width:15px;height:15px"></i> История сессий
|
||||
</button>
|
||||
<button class="admin-nav-item" data-tab="classroom" onclick="switchTab(this)">
|
||||
<i data-lucide="video" style="width:15px;height:15px"></i> Онлайн-уроки
|
||||
</button>
|
||||
|
||||
<div class="admin-nav-sep"></div>
|
||||
<div class="admin-nav-label">Контент</div>
|
||||
@@ -967,6 +1089,51 @@
|
||||
<div id="t-body"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Онлайн-уроки ── -->
|
||||
<div class="tab-pane" id="tab-classroom">
|
||||
|
||||
<!-- Module master toggle -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1.5px solid var(--border-h);border-radius:var(--r-lg);padding:20px 24px;margin-bottom:32px">
|
||||
<div>
|
||||
<div style="font-size:0.97rem;font-weight:700;margin-bottom:4px">Модуль онлайн-уроков</div>
|
||||
<div class="perm-desc" style="margin:0">Если отключить, учителя не смогут создавать новые уроки. Уже активные сессии продолжат работу до завершения.</div>
|
||||
</div>
|
||||
<label class="perm-toggle" id="cr-master-lbl" title="Включить / выключить модуль" style="margin-left:24px;flex-shrink:0">
|
||||
<input type="checkbox" id="cr-master-chk" onchange="crMasterToggle(this.checked)" checked />
|
||||
<span class="perm-track"></span>
|
||||
<span class="perm-thumb"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Active sessions -->
|
||||
<div class="cr-admin-section">
|
||||
<div class="cr-admin-section-title">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>
|
||||
Активные уроки
|
||||
<span id="cr-live-refresh-btn" style="font-size:0.76rem;font-weight:600;color:var(--violet);cursor:pointer;text-transform:none;letter-spacing:0;margin-left:-4px" onclick="loadCrActiveSessions()">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
Обновить
|
||||
</span>
|
||||
</div>
|
||||
<div id="cr-live-list"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Session history -->
|
||||
<div class="cr-admin-section">
|
||||
<div class="cr-admin-section-title">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
История уроков
|
||||
</div>
|
||||
<div class="cr-hist-toolbar">
|
||||
<input class="cr-hist-search" id="cr-hist-q" type="text" placeholder="Поиск по теме или учителю…" oninput="crHistDebounce()">
|
||||
<span class="cr-hist-count" id="cr-hist-count"></span>
|
||||
</div>
|
||||
<div id="cr-hist-list"><div class="spinner"></div></div>
|
||||
<div id="cr-hist-pagination"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Права доступа ── -->
|
||||
<div class="tab-pane" id="tab-permissions">
|
||||
<div class="perm-header">
|
||||
@@ -4321,9 +4488,13 @@
|
||||
{ id: 'magnetic', cat: 'Физика', title: 'Магнитное поле токов' },
|
||||
{ id: 'circuit', cat: 'Физика', title: 'Электрические цепи' },
|
||||
{ id: 'coulomb', cat: 'Физика', title: 'Закон Кулона' },
|
||||
{ id: 'hydrostatics', cat: 'Физика', title: 'Гидростатика' },
|
||||
{ id: 'dynamics', cat: 'Физика', title: 'Динамика' },
|
||||
{ id: 'thinlens', cat: 'Физика', title: 'Тонкая линза' },
|
||||
{ id: 'refraction', cat: 'Физика', title: 'Преломление света' },
|
||||
{ id: 'mirrors', cat: 'Физика', title: 'Зеркала' },
|
||||
{ id: 'isoprocess', cat: 'Физика', title: 'Изопроцессы' },
|
||||
{ id: 'waves', cat: 'Физика', title: 'Волны и звук' },
|
||||
{ id: 'molphys', cat: 'Химия', title: 'Молекулярная физика' },
|
||||
{ id: 'chemistry', cat: 'Химия', title: 'Химические реакции' },
|
||||
{ id: 'equilibrium', cat: 'Химия', title: 'Химическое равновесие' },
|
||||
@@ -4814,6 +4985,260 @@
|
||||
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
ОНЛАЙН-УРОКИ (classroom admin)
|
||||
════════════════════════════════════════════════ */
|
||||
let _crHistPage = 1, _crHistTotal = 0, _crHistPages = 0, _crHistSearch = '';
|
||||
let _crOpenDetailId = null, _crHistDebTimer = null;
|
||||
|
||||
async function loadCrModuleState() {
|
||||
try {
|
||||
const features = await LS.api('/api/admin/features');
|
||||
const chk = document.getElementById('cr-master-chk');
|
||||
if (chk) chk.checked = features.classroom !== false;
|
||||
} catch(e) { /* silent */ }
|
||||
}
|
||||
|
||||
async function crMasterToggle(enabled) {
|
||||
try {
|
||||
await LS.api('/api/admin/features', { method: 'PATCH', body: JSON.stringify({ classroom: enabled }) });
|
||||
LS.toast(enabled ? 'Модуль онлайн-уроков включён' : 'Модуль онлайн-уроков отключён', enabled ? 'success' : 'warning', 3000);
|
||||
} catch(e) {
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
// revert checkbox
|
||||
const chk = document.getElementById('cr-master-chk');
|
||||
if (chk) chk.checked = !enabled;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDuration(sec) {
|
||||
if (!sec || sec < 0) return '—';
|
||||
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
||||
if (h) return `${h}ч ${m}м`;
|
||||
if (m) return `${m} мин ${s} сек`;
|
||||
return `${s} сек`;
|
||||
}
|
||||
function fmtLiveDuration(createdAt) {
|
||||
const sec = Math.round((Date.now() - new Date(createdAt).getTime()) / 1000);
|
||||
return fmtDuration(sec);
|
||||
}
|
||||
|
||||
async function loadCrActiveSessions() {
|
||||
const el = document.getElementById('cr-live-list');
|
||||
try {
|
||||
const { sessions } = await LS.api('/api/classroom/admin/active');
|
||||
if (!sessions.length) {
|
||||
el.innerHTML = '<div class="empty">Нет активных уроков</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = sessions.map(s => {
|
||||
const dur = fmtLiveDuration(s.created_at);
|
||||
const title = s.title || `Урок #${s.id}`;
|
||||
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
|
||||
return `<div class="cr-live-card">
|
||||
<div class="cr-live-pulse"></div>
|
||||
<div class="cr-live-info">
|
||||
<div class="cr-live-title">${esc(title)}</div>
|
||||
<div class="cr-live-meta">${esc(s.teacher_name)} · ${cls}</div>
|
||||
</div>
|
||||
<div class="cr-live-badges">
|
||||
<span class="cr-badge cr-badge-online">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
${s.online_count}
|
||||
</span>
|
||||
<span class="cr-badge cr-badge-msgs">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
${s.message_count}
|
||||
</span>
|
||||
<span class="cr-badge cr-badge-dur">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
${dur}
|
||||
</span>
|
||||
</div>
|
||||
<div class="cr-live-actions">
|
||||
<button class="btn-cr-end" onclick="adminEndSession(${s.id})">Завершить</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
async function adminEndSession(id) {
|
||||
if (!await LS.confirm(`Завершить урок #${id}? Все участники будут отключены.`, { title: 'Завершить урок', confirmText: 'Завершить' })) return;
|
||||
try {
|
||||
await LS.api(`/api/classroom/${id}`, { method: 'DELETE' });
|
||||
LS.toast('Урок завершён', 'success', 2500);
|
||||
loadCrActiveSessions();
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
function crHistDebounce() {
|
||||
clearTimeout(_crHistDebTimer);
|
||||
_crHistDebTimer = setTimeout(() => { _crHistPage = 1; loadCrHistory(); }, 350);
|
||||
}
|
||||
|
||||
async function loadCrHistory(page) {
|
||||
if (page) _crHistPage = page;
|
||||
_crHistSearch = (document.getElementById('cr-hist-q')?.value || '').trim();
|
||||
const el = document.getElementById('cr-hist-list');
|
||||
el.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const params = new URLSearchParams({ page: _crHistPage, limit: 20 });
|
||||
if (_crHistSearch) params.set('search', _crHistSearch);
|
||||
const { sessions, total, pages } = await LS.api('/api/classroom/admin/sessions?' + params);
|
||||
_crHistTotal = total; _crHistPages = pages;
|
||||
document.getElementById('cr-hist-count').textContent = `${total} уроков`;
|
||||
if (!sessions.length) {
|
||||
el.innerHTML = '<div class="empty">Нет завершённых уроков</div>';
|
||||
renderCrPagination();
|
||||
return;
|
||||
}
|
||||
el.innerHTML = sessions.map(s => {
|
||||
const title = s.title || `Урок #${s.id}`;
|
||||
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
|
||||
const dur = fmtDuration(s.ended_at ? Math.round((new Date(s.ended_at)-new Date(s.created_at))/1000) : null);
|
||||
return `<div>
|
||||
<div class="cr-hist-row${_crOpenDetailId===s.id?' open':''}" onclick="toggleCrDetail(${s.id},this)">
|
||||
<div class="cr-hist-icon">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:18px;height:18px;color:var(--violet)"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<div class="cr-hist-main">
|
||||
<div class="cr-hist-title">${esc(title)}</div>
|
||||
<div class="cr-hist-meta">${esc(s.teacher_name)} · ${cls} · ${fmtDate(s.ended_at || s.created_at)}</div>
|
||||
</div>
|
||||
<div class="cr-hist-chips">
|
||||
<span class="cr-badge cr-badge-online">${s.participant_count} уч.</span>
|
||||
<span class="cr-badge cr-badge-msgs">${s.message_count} сообщ.</span>
|
||||
<span class="cr-badge cr-badge-dur">${dur}</span>
|
||||
</div>
|
||||
<svg class="cr-hist-chevron ic" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<div class="cr-detail-drawer${_crOpenDetailId===s.id?' open':''}" id="cr-detail-${s.id}">
|
||||
<div class="cr-detail-inner" id="cr-detail-inner-${s.id}">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (_crOpenDetailId) {
|
||||
const dr = document.getElementById(`cr-detail-${_crOpenDetailId}`);
|
||||
if (dr) loadCrSessionDetail(_crOpenDetailId);
|
||||
}
|
||||
renderCrPagination();
|
||||
} catch(e) {
|
||||
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderCrPagination() {
|
||||
const el = document.getElementById('cr-hist-pagination');
|
||||
if (_crHistPages <= 1) { el.innerHTML = ''; return; }
|
||||
const p = _crHistPage, total = _crHistPages;
|
||||
let html = '<div class="cr-pagination">';
|
||||
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p-1})" ${p<=1?'disabled':''}>
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</button>`;
|
||||
const range = [];
|
||||
for (let i=1;i<=total;i++) {
|
||||
if (i===1||i===total||Math.abs(i-p)<=1) range.push(i);
|
||||
else if (range[range.length-1]!=='…') range.push('…');
|
||||
}
|
||||
range.forEach(r => {
|
||||
if (r==='…') html += `<span class="cr-page-info">…</span>`;
|
||||
else html += `<button class="cr-page-btn${r===p?' active':''}" onclick="loadCrHistory(${r})">${r}</button>`;
|
||||
});
|
||||
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p+1})" ${p>=total?'disabled':''}>
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button></div>`;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
async function toggleCrDetail(id, rowEl) {
|
||||
const wasOpen = _crOpenDetailId === id;
|
||||
// close all
|
||||
document.querySelectorAll('.cr-hist-row.open').forEach(r => r.classList.remove('open'));
|
||||
document.querySelectorAll('.cr-detail-drawer.open').forEach(d => { d.classList.remove('open'); d.style.maxHeight=''; });
|
||||
_crOpenDetailId = null;
|
||||
if (wasOpen) return;
|
||||
// open this one
|
||||
rowEl.classList.add('open');
|
||||
const dr = document.getElementById(`cr-detail-${id}`);
|
||||
if (dr) { dr.classList.add('open'); }
|
||||
_crOpenDetailId = id;
|
||||
await loadCrSessionDetail(id);
|
||||
}
|
||||
|
||||
async function loadCrSessionDetail(id) {
|
||||
const inner = document.getElementById(`cr-detail-inner-${id}`);
|
||||
if (!inner) return;
|
||||
inner.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const { session, stats, attendance, pages } = await LS.api(`/api/classroom/${id}/summary`);
|
||||
const dur = fmtDuration(stats.duration_sec);
|
||||
inner.innerHTML = `
|
||||
<div class="cr-detail-grid">
|
||||
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.participant_count}</div><div class="cr-detail-label">Участников</div></div>
|
||||
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.message_count}</div><div class="cr-detail-label">Сообщений</div></div>
|
||||
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.page_count}</div><div class="cr-detail-label">Страниц</div></div>
|
||||
<div class="cr-detail-stat"><div class="cr-detail-val" style="font-size:1rem">${dur}</div><div class="cr-detail-label">Длительность</div></div>
|
||||
</div>
|
||||
${attendance.length ? `
|
||||
<div class="section-title" style="font-size:0.72rem;margin-bottom:8px">Посещаемость</div>
|
||||
<div class="cr-attend-list">
|
||||
${attendance.map(a => `
|
||||
<div class="cr-attend-row">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px;flex-shrink:0;color:var(--violet)"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
<span class="cr-attend-name">${esc(a.user_name)}</span>
|
||||
<span class="cr-attend-time">${a.joined_at ? new Date(a.joined_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '—'}</span>
|
||||
<span class="cr-attend-dur">${a.duration_sec ? fmtDuration(a.duration_sec) : (a.left_at ? '—' : '<span style="color:var(--green)">онлайн</span>')}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
${pages.length > 1 ? `
|
||||
<div class="section-title" style="font-size:0.72rem;margin:16px 0 8px">Страницы доски</div>
|
||||
<div class="cr-pages-list">
|
||||
${pages.map(p => `
|
||||
<div class="cr-page-chip">
|
||||
<span class="cr-page-num">Стр. ${p.page_num}</span>
|
||||
<span class="cr-page-cnt">${p.stroke_count} штр.</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="cr-detail-actions">
|
||||
<button class="btn-cr-export" onclick="adminExportChat(${id})">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Экспорт чата
|
||||
</button>
|
||||
<button class="btn-cr-del" onclick="adminDeleteSession(${id})">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
|
||||
Удалить запись
|
||||
</button>
|
||||
</div>`;
|
||||
} catch(e) {
|
||||
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function adminExportChat(id) {
|
||||
window.open(`/api/classroom/${id}/chat/export`, '_blank');
|
||||
}
|
||||
|
||||
async function adminDeleteSession(id) {
|
||||
if (!await LS.confirm('Удалить всю запись об этом уроке? Данные нельзя восстановить.', { title: 'Удалить урок', confirmText: 'Удалить', dangerous: true })) return;
|
||||
try {
|
||||
await LS.api(`/api/classroom/${id}/history`, { method: 'DELETE' });
|
||||
LS.toast('Урок удалён', 'success', 2500);
|
||||
_crOpenDetailId = null;
|
||||
loadCrHistory();
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ─── wire tab loading ─── */
|
||||
const _origSwitchTab = window.switchTab;
|
||||
window.switchTab = function(btn) {
|
||||
@@ -4823,6 +5248,7 @@
|
||||
else if (tab === 'audit') loadAuditLog();
|
||||
else if (tab === 'errors') loadErrorLog();
|
||||
else if (tab === 'health') loadHealth();
|
||||
else if (tab === 'classroom') { loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); }
|
||||
};
|
||||
|
||||
/* ─── init ─── */
|
||||
|
||||
@@ -262,6 +262,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link nav-active"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -367,6 +367,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -277,6 +277,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -567,6 +567,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
+3655
-209
File diff suppressed because it is too large
Load Diff
@@ -432,6 +432,7 @@ function renderGrid() {
|
||||
<p>Откройте виды в каталоге, чтобы они появились здесь</p>
|
||||
<a href="/red-book.html" class="btn-rb-outline"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Исследовать виды</a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -415,6 +415,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -1193,6 +1193,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/ls.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<style>
|
||||
.sb-content { background: #f4f5f8; min-height: 100vh; }
|
||||
.fc-wrap { max-width: 1100px; margin: 0 auto; padding: 28px 28px 80px; }
|
||||
@@ -395,6 +396,8 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/search.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
/* ── auth ── */
|
||||
@@ -664,13 +667,28 @@ async function startStudyForDeck(deckId) {
|
||||
bindSwipe();
|
||||
}
|
||||
|
||||
const _FC_DELIMS = [
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
];
|
||||
function mathHtmlFC(text) {
|
||||
if (!text) return '';
|
||||
const tmp = document.createElement('span');
|
||||
tmp.textContent = text;
|
||||
if (window.renderMathInElement) {
|
||||
try { renderMathInElement(tmp, { delimiters: _FC_DELIMS, throwOnError: false }); } catch {}
|
||||
}
|
||||
return tmp.innerHTML;
|
||||
}
|
||||
|
||||
function showStudyCard() {
|
||||
const card = _studyCards[_studyIdx];
|
||||
if (!card) { finishStudy(); return; }
|
||||
const el = document.getElementById('study-card');
|
||||
el.className = 'study-card-inner';
|
||||
document.getElementById('study-front-text').textContent = card.front;
|
||||
document.getElementById('study-back-text').textContent = card.back;
|
||||
document.getElementById('study-front-text').innerHTML = mathHtmlFC(card.front);
|
||||
document.getElementById('study-back-text').innerHTML = mathHtmlFC(card.back);
|
||||
_studyFlipped = false;
|
||||
document.getElementById('study-btns').classList.remove('visible');
|
||||
document.getElementById('study-flip-hint').style.display = 'block';
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Гостевой просмотр — LearnSpace</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" crossorigin="anonymous" />
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0b0920;
|
||||
--bg2: #12093a;
|
||||
--violet: #9B5DE5;
|
||||
--cyan: #06D6E0;
|
||||
--text: #e8e0f7;
|
||||
--muted: rgba(232,224,247,0.45);
|
||||
--border: rgba(155,93,229,0.2);
|
||||
}
|
||||
|
||||
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: 'Manrope', sans-serif; overflow: hidden; }
|
||||
|
||||
/* ── Name entry screen ── */
|
||||
#guest-entry {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 30%, rgba(155,93,229,0.12) 0%, transparent 70%), var(--bg);
|
||||
}
|
||||
.ge-box {
|
||||
width: 100%; max-width: 400px; padding: 40px 36px 36px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 32px 80px rgba(0,0,0,0.55);
|
||||
margin: 16px;
|
||||
}
|
||||
.ge-logo {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-family: 'Unbounded', sans-serif; font-size: 0.9rem; font-weight: 800;
|
||||
color: var(--text); margin-bottom: 28px;
|
||||
}
|
||||
.ge-logo svg { width: 28px; height: 28px; }
|
||||
.ge-title { font-family: 'Unbounded', sans-serif; font-size: 1.05rem; font-weight: 800; margin-bottom: 6px; }
|
||||
.ge-sub { font-size: 0.78rem; color: var(--muted); margin-bottom: 26px; line-height: 1.6; }
|
||||
.ge-lesson-name {
|
||||
font-size: 0.82rem; font-weight: 700; color: var(--violet);
|
||||
margin-bottom: 22px; padding: 8px 12px;
|
||||
background: rgba(155,93,229,0.08); border-radius: 8px;
|
||||
border: 1px solid rgba(155,93,229,0.18);
|
||||
display: none;
|
||||
}
|
||||
.ge-label { font-size: 0.72rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; }
|
||||
.ge-input {
|
||||
width: 100%; padding: 12px 14px;
|
||||
background: rgba(255,255,255,0.05); border: 1.5px solid rgba(255,255,255,0.12);
|
||||
border-radius: 12px; color: var(--text); font-family: 'Manrope', sans-serif;
|
||||
font-size: 0.9rem; outline: none; transition: border-color 0.15s;
|
||||
}
|
||||
.ge-input:focus { border-color: var(--violet); }
|
||||
.ge-input::placeholder { color: rgba(255,255,255,0.22); }
|
||||
.ge-btn {
|
||||
width: 100%; margin-top: 18px; padding: 13px;
|
||||
background: linear-gradient(135deg, var(--violet), #5e2fb5);
|
||||
border: none; border-radius: 12px; color: #fff;
|
||||
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
|
||||
cursor: pointer; transition: opacity 0.15s, transform 0.12s;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
}
|
||||
.ge-btn:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
.ge-btn:disabled { opacity: 0.4; cursor: default; transform: none; }
|
||||
.ge-disclaimer {
|
||||
margin-top: 16px; font-size: 0.68rem; color: rgba(255,255,255,0.2);
|
||||
text-align: center; line-height: 1.6;
|
||||
}
|
||||
.ge-error { margin-top: 12px; font-size: 0.75rem; color: #FF6B6B; text-align: center; display: none; }
|
||||
|
||||
/* ── Board layout ── */
|
||||
#guest-board { display: none; flex-direction: column; height: 100vh; }
|
||||
|
||||
/* Header */
|
||||
.gb-header {
|
||||
height: 46px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background: rgba(10,7,30,0.92); border-bottom: 1px solid rgba(255,255,255,0.07);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 10;
|
||||
}
|
||||
.gb-header-left { display: flex; align-items: center; gap: 10px; }
|
||||
.gb-header-right { display: flex; align-items: center; gap: 10px; }
|
||||
.gb-logo { font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800; color: var(--violet); }
|
||||
.gb-title { font-size: 0.78rem; font-weight: 700; color: var(--text); max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.gb-sep { width: 1px; height: 18px; background: rgba(255,255,255,0.1); }
|
||||
.gb-badge {
|
||||
display: flex; align-items: center; gap: 5px; padding: 3px 9px;
|
||||
background: rgba(155,93,229,0.1); border: 1px solid rgba(155,93,229,0.22);
|
||||
border-radius: 99px; font-size: 0.63rem; font-weight: 700; color: var(--violet);
|
||||
}
|
||||
.gb-badge-dot { width: 5px; height: 5px; border-radius: 50%; background: #06D6A0; animation: pulse-dot 1.5s ease infinite; }
|
||||
@keyframes pulse-dot { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(.7)} }
|
||||
|
||||
/* Page nav */
|
||||
.gb-page-nav { display: flex; align-items: center; gap: 8px; }
|
||||
.gb-page-btn {
|
||||
width: 28px; height: 28px; border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 7px; background: rgba(255,255,255,0.04);
|
||||
color: var(--muted); cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.gb-page-btn:hover:not(:disabled) { border-color: rgba(155,93,229,0.4); color: var(--text); background: rgba(155,93,229,0.08); }
|
||||
.gb-page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.gb-page-label { font-size: 0.72rem; font-weight: 700; color: var(--muted); white-space: nowrap; }
|
||||
|
||||
/* Canvas area */
|
||||
.gb-canvas-wrap {
|
||||
flex: 1; position: relative; overflow: hidden;
|
||||
background: #2d5a2d; /* chalkboard green — same as classroom */
|
||||
}
|
||||
#guest-canvas { display: block; width: 100%; height: 100%; }
|
||||
|
||||
/* Ended overlay */
|
||||
#gb-ended {
|
||||
display: none; position: fixed; inset: 0; z-index: 200;
|
||||
background: rgba(11,9,32,0.92); backdrop-filter: blur(8px);
|
||||
flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 14px; text-align: center;
|
||||
}
|
||||
#gb-ended svg { width: 48px; height: 48px; color: var(--violet); }
|
||||
#gb-ended h2 { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; }
|
||||
#gb-ended p { font-size: 0.8rem; color: var(--muted); }
|
||||
|
||||
/* Status toast */
|
||||
#gb-toast {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||
padding: 8px 18px; border-radius: 99px;
|
||||
background: rgba(20,15,50,0.95); border: 1px solid rgba(155,93,229,0.3);
|
||||
font-size: 0.76rem; font-weight: 600; color: var(--text);
|
||||
pointer-events: none; opacity: 0; transition: opacity 0.25s;
|
||||
white-space: nowrap; z-index: 500;
|
||||
}
|
||||
#gb-toast.show { opacity: 1; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.gb-logo { display: none; }
|
||||
.gb-title { max-width: 140px; font-size: 0.72rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Name entry screen ─────────────────────────────── -->
|
||||
<div id="guest-entry">
|
||||
<div class="ge-box">
|
||||
<div class="ge-logo">
|
||||
<svg viewBox="0 0 32 32" fill="none"><rect width="32" height="32" rx="8" fill="#9B5DE5"/><path d="M8 22V12l8-4 8 4v10" stroke="#fff" stroke-width="2" stroke-linecap="round"/><path d="M13 22v-5h6v5" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
LearnSpace
|
||||
</div>
|
||||
<div class="ge-title">Гостевой просмотр</div>
|
||||
<div class="ge-sub">Вы смотрите доску в режиме чтения. Рисовать нельзя.</div>
|
||||
<div class="ge-lesson-name" id="ge-lesson-name"></div>
|
||||
<div class="ge-label">Ваше имя</div>
|
||||
<input class="ge-input" id="ge-name-input" type="text" placeholder="Введите ваше имя…" maxlength="40"
|
||||
onkeydown="if(event.key==='Enter') guestJoin()">
|
||||
<button class="ge-btn" id="ge-join-btn" onclick="guestJoin()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||
Войти как гость
|
||||
</button>
|
||||
<div class="ge-error" id="ge-error"></div>
|
||||
<div class="ge-disclaimer">Ваше имя будет видно учителю в списке участников</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Board ─────────────────────────────────────────── -->
|
||||
<div id="guest-board">
|
||||
<header class="gb-header">
|
||||
<div class="gb-header-left">
|
||||
<span class="gb-logo">LearnSpace</span>
|
||||
<div class="gb-sep"></div>
|
||||
<span class="gb-title" id="gb-title">Онлайн-урок</span>
|
||||
<div class="gb-badge">
|
||||
<span class="gb-badge-dot"></span>
|
||||
Гостевой просмотр
|
||||
</div>
|
||||
</div>
|
||||
<div class="gb-header-right">
|
||||
<div class="gb-page-nav">
|
||||
<button class="gb-page-btn" id="gb-prev" onclick="gbPrevPage()" disabled title="Предыдущая страница">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
<span class="gb-page-label" id="gb-page-label">1 / 1</span>
|
||||
<button class="gb-page-btn" id="gb-next" onclick="gbNextPage()" disabled title="Следующая страница">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="gb-canvas-wrap">
|
||||
<canvas id="guest-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Lesson ended overlay ─── -->
|
||||
<div id="gb-ended">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>
|
||||
<h2>Урок завершён</h2>
|
||||
<p>Учитель завершил урок. Спасибо за участие!</p>
|
||||
</div>
|
||||
|
||||
<div id="gb-toast"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js" crossorigin="anonymous"></script>
|
||||
<script src="/js/whiteboard.js"></script>
|
||||
<script>
|
||||
const _token = new URLSearchParams(location.search).get('token');
|
||||
let _guestId = null;
|
||||
let _sessionId = null;
|
||||
let _wb = null;
|
||||
let _curPage = 1;
|
||||
let _totalPages = 1;
|
||||
let _es = null;
|
||||
let _wbMaxSeq = 0;
|
||||
let _pollTimer = null;
|
||||
|
||||
/* ── toast ── */
|
||||
let _toastTimer;
|
||||
function showToast(msg, dur = 2800) {
|
||||
const el = document.getElementById('gb-toast');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
clearTimeout(_toastTimer);
|
||||
_toastTimer = setTimeout(() => el.classList.remove('show'), dur);
|
||||
}
|
||||
|
||||
/* ── pre-load session info ── */
|
||||
async function init() {
|
||||
if (!_token) { showError('Неверная ссылка'); return; }
|
||||
try {
|
||||
const r = await fetch(`/api/classroom/guest/${_token}`);
|
||||
if (!r.ok) { showError('Ссылка недействительна или урок уже завершён'); return; }
|
||||
const info = await r.json();
|
||||
if (info.status !== 'active') {
|
||||
showError('Урок ещё не начался. Попробуйте позже или обратитесь к учителю.');
|
||||
return;
|
||||
}
|
||||
const nameEl = document.getElementById('ge-lesson-name');
|
||||
nameEl.textContent = info.title || 'Онлайн-урок';
|
||||
nameEl.style.display = 'block';
|
||||
_totalPages = info.page_count || 1;
|
||||
_curPage = info.current_page || 1;
|
||||
} catch {
|
||||
showError('Не удалось подключиться к серверу');
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('ge-error');
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
/* ── join ── */
|
||||
async function guestJoin() {
|
||||
const nameInput = document.getElementById('ge-name-input');
|
||||
const btn = document.getElementById('ge-join-btn');
|
||||
const name = nameInput.value.trim() || 'Гость';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/classroom/guest/${_token}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
showError(err.error || 'Не удалось войти');
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const data = await r.json();
|
||||
_guestId = data.guestId;
|
||||
_sessionId = data.sessionId;
|
||||
_totalPages = data.page_count || 1;
|
||||
_curPage = data.current_page || 1;
|
||||
|
||||
document.getElementById('gb-title').textContent = data.title || 'Онлайн-урок';
|
||||
startBoard();
|
||||
} catch {
|
||||
showError('Ошибка соединения');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── board startup ── */
|
||||
function startBoard() {
|
||||
document.getElementById('guest-entry').style.display = 'none';
|
||||
const boardEl = document.getElementById('guest-board');
|
||||
boardEl.style.display = 'flex';
|
||||
|
||||
const canvas = document.getElementById('guest-canvas');
|
||||
_wb = new Whiteboard(canvas, { readOnly: true, bg: 'chalk' });
|
||||
_wb.fit();
|
||||
window.addEventListener('resize', () => _wb.fit());
|
||||
|
||||
loadPage(_curPage);
|
||||
connectSSE();
|
||||
|
||||
// Send goodbye on tab close
|
||||
window.addEventListener('pagehide', leaveGuest);
|
||||
}
|
||||
|
||||
/* ── load strokes for a page ── */
|
||||
async function loadPage(pageNum) {
|
||||
_curPage = pageNum;
|
||||
updatePageNav();
|
||||
_wbMaxSeq = 0;
|
||||
_wb.clearPage();
|
||||
try {
|
||||
const r = await fetch(`/api/classroom/guest/${_token}/strokes?page_num=${pageNum}`);
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
_wbMaxSeq = data.seq || 0;
|
||||
_wb.loadStrokes(data.strokes || []);
|
||||
if (data.template) _wb.setTemplate(data.template);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function updatePageNav() {
|
||||
document.getElementById('gb-page-label').textContent = `${_curPage} / ${_totalPages}`;
|
||||
document.getElementById('gb-prev').disabled = _curPage <= 1;
|
||||
document.getElementById('gb-next').disabled = _curPage >= _totalPages;
|
||||
}
|
||||
|
||||
function gbPrevPage() { if (_curPage > 1) loadPage(_curPage - 1); }
|
||||
function gbNextPage() { if (_curPage < _totalPages) loadPage(_curPage + 1); }
|
||||
|
||||
/* ── SSE ── */
|
||||
function connectSSE() {
|
||||
const url = `/api/classroom/guest/${_token}/stream?guestId=${encodeURIComponent(_guestId || '')}`;
|
||||
_es = new EventSource(url);
|
||||
_es.onmessage = (e) => {
|
||||
try { handleEvent(JSON.parse(e.data)); } catch {}
|
||||
};
|
||||
_es.onerror = () => {
|
||||
// Auto-reconnects — just show brief toast
|
||||
setTimeout(() => { if (_es.readyState === EventSource.CONNECTING) showToast('Переподключение…'); }, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function handleEvent(data) {
|
||||
if (!data.type) return;
|
||||
switch (data.type) {
|
||||
|
||||
case 'classroom_strokes':
|
||||
if (data.pageNum == _curPage) {
|
||||
_wbMaxSeq = Math.max(_wbMaxSeq, ...(data.strokes || []).map(s => s.seq || 0));
|
||||
_wb.addStrokes(data.strokes || []);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'classroom_stroke_preview':
|
||||
if (data.pageNum == _curPage) {
|
||||
if (data.cancel) _wb.removeLiveStroke(data.liveId);
|
||||
else _wb.setLiveStroke(data.liveId, data.tool, data.data, data.userName, '#06D6E0');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'classroom_stroke_deleted':
|
||||
_wb.removeStroke(data.strokeId);
|
||||
break;
|
||||
|
||||
case 'classroom_stroke_updated':
|
||||
if (data.pageNum == _curPage)
|
||||
_wb.updateStroke(data.strokeId, data.data);
|
||||
break;
|
||||
|
||||
case 'classroom_page_added':
|
||||
_totalPages++;
|
||||
updatePageNav();
|
||||
break;
|
||||
|
||||
case 'classroom_page_changed':
|
||||
// Follow teacher
|
||||
if (data.pageNum !== _curPage) loadPage(data.pageNum);
|
||||
break;
|
||||
|
||||
case 'classroom_template_changed':
|
||||
if (data.pageNum == _curPage) _wb.setTemplate(data.template);
|
||||
break;
|
||||
|
||||
case 'classroom_page_cleared':
|
||||
if (data.pageNum == _curPage) { _wbMaxSeq = 0; _wb.clearPage(); }
|
||||
break;
|
||||
|
||||
case 'classroom_page_renamed':
|
||||
// Nothing visible to guest
|
||||
break;
|
||||
|
||||
case 'classroom_page_duplicated':
|
||||
_totalPages++;
|
||||
updatePageNav();
|
||||
break;
|
||||
|
||||
case 'classroom_page_deleted':
|
||||
_totalPages = Math.max(1, _totalPages - 1);
|
||||
if (_curPage > _totalPages) loadPage(_totalPages);
|
||||
else updatePageNav();
|
||||
break;
|
||||
|
||||
case 'classroom_ended':
|
||||
showEnded();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function showEnded() {
|
||||
if (_es) { _es.close(); _es = null; }
|
||||
document.getElementById('gb-ended').style.display = 'flex';
|
||||
}
|
||||
|
||||
function leaveGuest() {
|
||||
if (!_guestId) return;
|
||||
navigator.sendBeacon(`/api/classroom/guest/${_token}/leave`,
|
||||
new Blob([JSON.stringify({ guestId: _guestId })], { type: 'application/json' }));
|
||||
}
|
||||
|
||||
/* ── boot ── */
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -285,6 +285,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -20,12 +20,14 @@
|
||||
* rtc.destroy();
|
||||
*/
|
||||
class ClassroomRTC {
|
||||
constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive }) {
|
||||
constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive, onMicLevel, vadThreshold }) {
|
||||
this._sid = sessionId;
|
||||
this._uid = userId;
|
||||
this._onSignal = onSignal; // fn(targetUid, payload)
|
||||
this._onScreen = onScreenStream; // fn(stream | null)
|
||||
this._onMicActive = onMicActive; // fn(uid, bool) — optional
|
||||
this._onMicLevel = onMicLevel; // fn(uid, level 0-100) — optional, fires every tick
|
||||
this._vadThreshold = vadThreshold ?? 12;
|
||||
|
||||
this._peers = new Map(); // uid → PeerState
|
||||
this._localStream = null; // mic audio
|
||||
@@ -62,15 +64,17 @@ class ClassroomRTC {
|
||||
src.connect(analyser);
|
||||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||
let speaking = false;
|
||||
const THRESHOLD = 12;
|
||||
const THRESHOLD = this._vadThreshold;
|
||||
const timer = setInterval(() => {
|
||||
analyser.getByteFrequencyData(buf);
|
||||
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
|
||||
const level = Math.min(100, Math.round((avg / 64) * 100));
|
||||
const now = avg > THRESHOLD;
|
||||
if (now !== speaking) {
|
||||
speaking = now;
|
||||
try { this._onMicActive(uid, speaking); } catch {}
|
||||
try { if (this._onMicActive) this._onMicActive(uid, speaking); } catch {}
|
||||
}
|
||||
try { if (this._onMicLevel) this._onMicLevel(uid, level); } catch {}
|
||||
}, 120);
|
||||
this._vadTimers.set(uid, { ctx, timer });
|
||||
} catch {}
|
||||
@@ -307,10 +311,11 @@ class ClassroomRTC {
|
||||
|
||||
/* ── Screen sharing (teacher) ───────────────────────���─────────────────── */
|
||||
|
||||
async startScreenShare() {
|
||||
async startScreenShare(constraints = {}) {
|
||||
try {
|
||||
this._screenStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { cursor: 'always' }, audio: false,
|
||||
video: { cursor: 'always', ...constraints.video },
|
||||
audio: constraints.audio ?? false,
|
||||
});
|
||||
} catch { return null; }
|
||||
|
||||
@@ -340,6 +345,23 @@ class ClassroomRTC {
|
||||
}
|
||||
}
|
||||
|
||||
/** Take an already-acquired MediaStream (e.g. from a pre-picker) and share it. */
|
||||
async useExistingScreenStream(stream) {
|
||||
if (this._screenStream) {
|
||||
this._screenStream.getTracks().forEach(t => t.stop());
|
||||
this._screenStream = null;
|
||||
}
|
||||
this._screenStream = stream;
|
||||
const vt = this._screenStream.getVideoTracks()[0];
|
||||
if (!vt) return stream;
|
||||
for (const [, peer] of this._peers) {
|
||||
if (!peer.screenSender) {
|
||||
peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
|
||||
}
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
isSharing() { return !!this._screenStream; }
|
||||
|
||||
/* ── Cleanup ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -792,7 +792,7 @@ class AngryBirdsSim {
|
||||
|
||||
/* Planet + g — second line, readable */
|
||||
ctx.font = '13px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.72)';
|
||||
ctx.fillText(`${pl.label} g = ${pl.g} м/с²`, 14, 50);
|
||||
ctx.fillText(`${pl.label.replace(/<svg[\s\S]*?<\/svg>/g, '').trim()} g = ${pl.g} м/с²`, 14, 50);
|
||||
|
||||
/* Wind reminder */
|
||||
if (lvl?.wind) {
|
||||
|
||||
@@ -50,6 +50,7 @@ class BohrAtomSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() { return { level: this.level }; }
|
||||
setParams({ level } = {}) {
|
||||
if (level !== undefined) {
|
||||
const n = Math.max(1, Math.min(6, Math.round(+level)));
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
/* Strip SVG markup for canvas fillText — replaces icon SVGs with Unicode */
|
||||
function _csClean(s) {
|
||||
if (!s || !s.includes('<svg')) return s;
|
||||
return s.replace(/<svg[\s\S]*?<\/svg>/g, m => {
|
||||
if (m.includes('x1="5" y1="12" x2="19"')) return '\u2192'; // → right arrow
|
||||
if (m.includes('x1="12" y1="5" x2="12" y2="19"')) return '\u2193'; // ↓ down (precip)
|
||||
if (m.includes('x1="12" y1="19" x2="12" y2="5"')) return '\u2191'; // ↑ up (gas)
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
ChemSandboxSim v2 — «Химическая песочница»
|
||||
• Колба Эрленмейера с реалистичным стеклом
|
||||
@@ -1089,7 +1101,7 @@ class ChemSandboxSim {
|
||||
// ── Молекулярное уравнение ──
|
||||
ctx.font = 'bold 17px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = rx.fx.none ? 'rgba(255,100,100,0.75)' : 'rgba(255,255,255,0.95)';
|
||||
ctx.fillText(rx.eq, W / 2, y);
|
||||
ctx.fillText(_csClean(rx.eq), W / 2, y);
|
||||
y += 22;
|
||||
|
||||
// ── Тип реакции + пояснение ──
|
||||
@@ -1109,7 +1121,7 @@ class ChemSandboxSim {
|
||||
if (rx.why) {
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||||
ctx.fillText(rx.why, W / 2, y);
|
||||
ctx.fillText(_csClean(rx.why), W / 2, y);
|
||||
y += 17;
|
||||
}
|
||||
|
||||
@@ -1117,7 +1129,7 @@ class ChemSandboxSim {
|
||||
if (rx.ionFull) {
|
||||
ctx.font = '13px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = 'rgba(155,200,255,0.60)';
|
||||
ctx.fillText('Полн.: ' + rx.ionFull, W / 2, y);
|
||||
ctx.fillText('Полн.: ' + _csClean(rx.ionFull), W / 2, y);
|
||||
y += 16;
|
||||
}
|
||||
|
||||
@@ -1125,7 +1137,7 @@ class ChemSandboxSim {
|
||||
if (rx.ionNet) {
|
||||
ctx.font = 'bold 13px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = 'rgba(123,245,164,0.75)';
|
||||
ctx.fillText('Сокр.: ' + rx.ionNet, W / 2, y);
|
||||
ctx.fillText('Сокр.: ' + _csClean(rx.ionNet), W / 2, y);
|
||||
y += 16;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ class CollisionSim {
|
||||
this.c.height = r.height || 420;
|
||||
}
|
||||
|
||||
getParams() { return { m1: this.m1, m2: this.m2, v1: this.v1, v2: this.v2, angle: this.angle, e: this.e }; }
|
||||
setParams(p) {
|
||||
if (p.m1 !== undefined) this.m1 = +p.m1;
|
||||
if (p.m2 !== undefined) this.m2 = +p.m2;
|
||||
|
||||
@@ -86,6 +86,7 @@ class ElectrolysisSim {
|
||||
this._initIons();
|
||||
}
|
||||
|
||||
getParams() { return { voltage: this.voltage, electrolyte: this.electrolyte }; }
|
||||
setParams({ voltage, electrolyte } = {}) {
|
||||
if (voltage !== undefined) this.voltage = Math.max(1, Math.min(12, +voltage));
|
||||
if (electrolyte !== undefined) {
|
||||
|
||||
@@ -48,6 +48,7 @@ class EquilibriumSim {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
getParams() { return { T: this.T, nA: this.nA, nB: this.nB, Ea_f: this.Ea_f, Ea_r: this.Ea_r }; }
|
||||
setParams({ T, nA, nB, Ea_f, Ea_r } = {}) {
|
||||
let needReset = false;
|
||||
if (T !== undefined) this.T = Math.max(200, Math.min(500, +T));
|
||||
|
||||
@@ -1787,7 +1787,7 @@ class ForceSandboxSim {
|
||||
|
||||
// Угловая скорость ω — фиолетовая метка справа от тела
|
||||
if (hasOmg) {
|
||||
const sym = b.omega > 0 ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.49-4.12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.12"/></svg>';
|
||||
const sym = b.omega > 0 ? '\u21BB' : '\u21BA';
|
||||
const labX = b.x + halfW + 7;
|
||||
const labY = b.type === 'box' ? b.y - b.h * 0.12 : b.y - b.r * 0.15;
|
||||
ctx.save();
|
||||
@@ -1852,7 +1852,7 @@ class ForceSandboxSim {
|
||||
ctx.fillStyle = '#EF476F';
|
||||
ctx.fillText(`p=${(body.mass * spd / S).toFixed(1)} кг·м/с`, cx, cy + r + 12);
|
||||
if (Math.abs(body.omega) > 0.05) {
|
||||
const sym = body.omega > 0 ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.49-4.12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.12"/></svg>';
|
||||
const sym = body.omega > 0 ? '\u21BB' : '\u21BA';
|
||||
ctx.fillStyle = '#9B5DE5';
|
||||
ctx.fillText(`${sym} ω=${Math.abs(body.omega).toFixed(2)} рад/с`, cx, cy + r + 22);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ class GraphTransformSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() { return { a: this.a, k: this.k, b: this.b, c: this.c }; }
|
||||
setParams({ a, k, b, c } = {}) {
|
||||
if (a !== undefined) this.a = +a;
|
||||
if (k !== undefined) this.k = +k;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,7 @@ class IsoprocessSim {
|
||||
|
||||
setProcess(p) { this.process = p; this.draw(); this._emit(); }
|
||||
setGamma(g) { this.gamma = +g; this.draw(); this._emit(); }
|
||||
getParams() { return { P1: this.P1, V1: this.V1, process: this.process }; }
|
||||
setParams({ P1, V1 } = {}) {
|
||||
if (P1 !== undefined) this.P1 = Math.max(0.4, Math.min(8, +P1));
|
||||
if (V1 !== undefined) this.V1 = Math.max(2, Math.min(28, +V1));
|
||||
|
||||
@@ -84,6 +84,7 @@ class MirrorSim {
|
||||
this.draw(); this._emit();
|
||||
}
|
||||
|
||||
getParams() { return { f: this.f, d: this.d, h: this.h }; }
|
||||
setParams({ f, d, h } = {}) {
|
||||
if (f !== undefined) this.f = Math.max(30, Math.min(300, +f));
|
||||
if (d !== undefined) this.d = Math.max(30, Math.min(490, +d));
|
||||
|
||||
@@ -34,6 +34,7 @@ class NormalDistSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() { return { mu: this.mu, sigma: this.sigma, shade: this.shade, zLow: this.zLow, zHigh: this.zHigh }; }
|
||||
setParams({ mu, sigma, shade, zLow, zHigh } = {}) {
|
||||
if (mu !== undefined) this.mu = +mu;
|
||||
if (sigma !== undefined) this.sigma = Math.max(0.1, +sigma);
|
||||
|
||||
@@ -51,6 +51,9 @@ class PendulumSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() {
|
||||
return { L: this.L, g: this.g, theta: +(this.theta * 180 / Math.PI).toFixed(3), damping: this.damping };
|
||||
}
|
||||
setParams({ L, g, theta, damping } = {}) {
|
||||
if (L !== undefined) this.L = +L;
|
||||
if (g !== undefined) this.g = +g;
|
||||
|
||||
@@ -60,6 +60,7 @@ class ProbabilitySim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() { return { mode: this.mode, trials: this.trials, speed: this.speed }; }
|
||||
setParams({ mode, trials, speed } = {}) {
|
||||
if (mode !== undefined) this.mode = mode;
|
||||
if (trials !== undefined) this.trials = Math.max(1, +trials);
|
||||
|
||||
@@ -88,6 +88,11 @@ class ProjectileSim {
|
||||
this._cw = w; this._ch = h;
|
||||
}
|
||||
|
||||
getParams() {
|
||||
return { v0: this.v0, angle: this.angle, h0: this.h0, g: this.g,
|
||||
drag: this.drag, Cd: this.Cd, mass: this.mass, wind: this.wind,
|
||||
bounce: this.bounce, restitution: this.restitution };
|
||||
}
|
||||
setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution } = {}) {
|
||||
if (v0 !== undefined) this.v0 = +v0;
|
||||
if (angle !== undefined) this.angle = +angle;
|
||||
|
||||
@@ -43,6 +43,7 @@ class QuadraticSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() { return { a: this.a, b: this.b, c: this.c }; }
|
||||
setParams({ a, b, c } = {}) {
|
||||
if (a !== undefined) this.a = +a;
|
||||
if (b !== undefined) this.b = +b;
|
||||
|
||||
@@ -42,6 +42,7 @@ class RefractionSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() { return { n1: this.n1, n2: this.n2, angle: this.angle, dispersion: this.dispersion }; }
|
||||
setParams({ n1, n2, angle, dispersion } = {}) {
|
||||
if (n1 !== undefined) this.n1 = Math.max(1.0, Math.min(3.0, +n1));
|
||||
if (n2 !== undefined) this.n2 = Math.max(1.0, Math.min(3.0, +n2));
|
||||
|
||||
@@ -39,6 +39,7 @@ class ThinLensSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() { return { f: this.f, d: this.d, h: this.h }; }
|
||||
setParams({ f, d, h } = {}) {
|
||||
if (f !== undefined) this.f = Math.max(-200, Math.min(200, +f));
|
||||
if (d !== undefined) this.d = Math.max(30, Math.min(400, +d));
|
||||
|
||||
@@ -67,6 +67,7 @@ class TitrationSim {
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────── */
|
||||
|
||||
getParams() { return { acidConc: this.acidConc, baseConc: this.baseConc, acidVol: this.acidVol, indicator: this.indicator, acidType: this.acidType }; }
|
||||
setParams({ acidConc, baseConc, acidVol, indicator, acidType } = {}) {
|
||||
if (acidConc !== undefined) this.acidConc = Math.max(0.05, Math.min(1.0, +acidConc));
|
||||
if (baseConc !== undefined) this.baseConc = Math.max(0.05, Math.min(1.0, +baseConc));
|
||||
|
||||
@@ -792,7 +792,7 @@ class TriangleSim {
|
||||
|
||||
// Formula
|
||||
this._drawFormulaBox(ctx, this.W, this.H,
|
||||
`${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² − 2·${adjSide1Name}·${adjSide2Name}·cos${angName} <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ${c2.toFixed(2)} = ${check.toFixed(2)}`,
|
||||
`${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² \u2212 2\u00B7${adjSide1Name}\u00B7${adjSide2Name}\u00B7cos${angName} \u2192 ${c2.toFixed(2)} = ${check.toFixed(2)}`,
|
||||
'#fbbf24');
|
||||
|
||||
ctx.restore();
|
||||
@@ -845,7 +845,7 @@ class TriangleSim {
|
||||
const diff = Math.abs(hypArea - (leg1Area + leg2Area));
|
||||
const statusCol = isRight ? '#22d55e' : '#f59e0b';
|
||||
const statusText = isRight
|
||||
? `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${leg1Name}² + ${leg2Name}² = ${hypName}² (${(leg1Area + leg2Area).toFixed(2)} = ${hypArea.toFixed(2)})`
|
||||
? `\u2713 ${leg1Name}\u00B2 + ${leg2Name}\u00B2 = ${hypName}\u00B2 (${(leg1Area + leg2Area).toFixed(2)} = ${hypArea.toFixed(2)})`
|
||||
: `${leg1Name}² + ${leg2Name}² = ${(leg1Area + leg2Area).toFixed(2)} ≠ ${hypName}² = ${hypArea.toFixed(2)} (Δ = ${diff.toFixed(2)})`;
|
||||
|
||||
this._drawFormulaBox(ctx, this.W, this.H, statusText, statusCol);
|
||||
|
||||
@@ -59,6 +59,10 @@ class WavesSim {
|
||||
this._emit();
|
||||
}
|
||||
|
||||
getParams() {
|
||||
return { A1: this._A1, f1: this._f1, phi1: this._phi1, A2: this._A2, f2: this._f2, phi2: this._phi2,
|
||||
n: this._n, speed: this._speed, mode: this._mode };
|
||||
}
|
||||
setParams({ A1, f1, phi1, A2, f2, phi2, n, speed } = {}) {
|
||||
if (A1 !== undefined) this._A1 = Math.max(5, Math.min(90, +A1));
|
||||
if (f1 !== undefined) this._f1 = Math.max(0.3, Math.min(4, +f1));
|
||||
|
||||
+1523
-192
File diff suppressed because it is too large
Load Diff
@@ -380,6 +380,7 @@
|
||||
<span class="sb-link active"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></span>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
+366
-6
@@ -136,11 +136,12 @@
|
||||
|
||||
.sim-zoom-btns { display: flex; gap: 4px; }
|
||||
.zoom-btn {
|
||||
width: 32px; height: 32px; border-radius: 10px;
|
||||
min-width: 32px; width: auto; height: 32px; border-radius: 10px;
|
||||
border: 1.5px solid var(--border-h);
|
||||
background: transparent; color: var(--text-2);
|
||||
cursor: pointer; font-size: 1.1rem; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; font-size: .8rem; font-weight: 700;
|
||||
padding: 0 9px; white-space: nowrap;
|
||||
display: flex; align-items: center; justify-content: center; gap: 4px;
|
||||
transition: all .15s;
|
||||
}
|
||||
.zoom-btn:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.07); }
|
||||
@@ -668,6 +669,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
@@ -933,6 +935,54 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- hydrostatics controls -->
|
||||
<div id="ctrl-hydro" class="sim-zoom-btns" style="display:none">
|
||||
<select id="hydro-mode-sel" onchange="hydroMode(this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 8px;font-size:.72rem;cursor:pointer">
|
||||
<option value="pressure">Давление P=ρgh</option>
|
||||
<option value="surface">Пов. натяжение</option>
|
||||
<option value="communicating">Сообщ. сосуды</option>
|
||||
<option value="archimedes">Архимед</option>
|
||||
</select>
|
||||
<select id="hydro-liq-sel" onchange="hydroSim&&hydroSim.setLiquid(this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 8px;font-size:.72rem;cursor:pointer">
|
||||
<option value="water">Вода</option>
|
||||
<option value="saltwater">Солёная вода</option>
|
||||
<option value="oil">Масло</option>
|
||||
<option value="alcohol">Спирт</option>
|
||||
<option value="glycerin">Глицерин</option>
|
||||
<option value="mercury">Ртуть</option>
|
||||
</select>
|
||||
<div id="hydro-arch-ctrl" style="display:none;gap:4px;align-items:center">
|
||||
<select id="hydro-mat-sel" onchange="hydroSim&&hydroSim.setMaterial(this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 8px;font-size:.72rem;cursor:pointer">
|
||||
<option value="styrofoam">Пенопласт</option>
|
||||
<option value="cork">Пробка</option>
|
||||
<option value="wood">Дерево</option>
|
||||
<option value="ice">Лёд</option>
|
||||
<option value="plastic">Пластик</option>
|
||||
<option value="glass">Стекло</option>
|
||||
<option value="aluminum">Алюминий</option>
|
||||
<option value="iron">Железо</option>
|
||||
<option value="gold">Золото</option>
|
||||
</select>
|
||||
<button class="zoom-btn" onclick="hydroSim&&hydroSim.addBody()" title="Добавить тело">+ Тело</button>
|
||||
<button class="zoom-btn" onclick="hydroSim&&hydroSim.clearBodies()" title="Очистить">Очистить</button>
|
||||
</div>
|
||||
<div id="hydro-comm-ctrl" style="display:none;gap:4px;align-items:center">
|
||||
<label style="font-size:.72rem;color:rgba(255,255,255,.5)">Сосудов:</label>
|
||||
<select onchange="hydroSim&&hydroSim.setNumVessels(+this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 6px;font-size:.72rem;cursor:pointer">
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
<button class="zoom-btn" id="hydro-valve-btn" onclick="hydroToggleValve()" title="Кран">Кран: откр.</button>
|
||||
</div>
|
||||
<div id="hydro-surf-ctrl" style="display:none;gap:4px;align-items:center">
|
||||
<label style="font-size:.72rem;color:rgba(255,255,255,.5);white-space:nowrap">θ:</label>
|
||||
<input type="range" min="0" max="160" value="20" step="5" style="width:72px;accent-color:#9B5DE5" oninput="hydroSim&&hydroSim.setContactAngle(+this.value);document.getElementById('hydro-theta-val').textContent=this.value+'\u00B0';document.getElementById('hydro-theta-lbl').textContent=this.value+'\u00B0';document.querySelector('#hydro-panel-theta input[type=range]').value=this.value">
|
||||
<span id="hydro-theta-val" style="font-size:.72rem;color:#9B5DE5;min-width:28px;white-space:nowrap">20°</span>
|
||||
<button class="zoom-btn" id="hydro-surf-toggle" onclick="hydroToggleSurface()" title="Переключить: капилляры / капля" style="white-space:nowrap">Капилляры</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- theory toggle (all sims) -->
|
||||
<button class="zoom-btn" id="theory-toggle" onclick="toggleTheory()" title="Теория и формулы" style="margin-left:auto">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>
|
||||
@@ -3537,6 +3587,97 @@
|
||||
<div class="pstat"><div class="pstat-label">Диагональ</div><div class="pstat-val" id="stbar-d">—</div></div>
|
||||
</div>
|
||||
|
||||
<!-- ── HYDROSTATICS sim body ── -->
|
||||
<div id="sim-hydro" class="sim-proj-wrap" style="display:none">
|
||||
<div class="sim-body-wrap">
|
||||
|
||||
<!-- left panel -->
|
||||
<div class="proj-panel" style="width:230px;gap:0;overflow-y:auto">
|
||||
|
||||
<div class="gp-section-title" style="margin-bottom:8px">Параметры</div>
|
||||
|
||||
<!-- liquid -->
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">Жидкость</div>
|
||||
<select onchange="hydroSim&&hydroSim.setLiquid(this.value);document.getElementById('hydro-liq-sel').value=this.value" style="width:100%;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:5px 8px;font-size:.78rem">
|
||||
<option value="water">Вода (1000 кг/м³)</option>
|
||||
<option value="saltwater">Солёная вода (1030)</option>
|
||||
<option value="oil">Масло (900)</option>
|
||||
<option value="alcohol">Спирт (790)</option>
|
||||
<option value="glycerin">Глицерин (1260)</option>
|
||||
<option value="mercury">Ртуть (13600)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- material (Archimedes only) -->
|
||||
<div id="hydro-panel-mat" style="margin-bottom:10px;display:none">
|
||||
<div style="font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">Материал тела</div>
|
||||
<select onchange="hydroSim&&hydroSim.setMaterial(this.value)" style="width:100%;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:5px 8px;font-size:.78rem">
|
||||
<option value="styrofoam">Пенопласт (30 кг/м³)</option>
|
||||
<option value="cork">Пробка (120)</option>
|
||||
<option value="wood">Дерево (500)</option>
|
||||
<option value="ice">Лёд (900)</option>
|
||||
<option value="plastic">Пластик (1100)</option>
|
||||
<option value="glass">Стекло (2500)</option>
|
||||
<option value="aluminum">Алюминий (2700)</option>
|
||||
<option value="iron">Железо (7800)</option>
|
||||
<option value="gold">Золото (19300)</option>
|
||||
</select>
|
||||
<div style="display:flex;gap:5px;margin-top:6px">
|
||||
<button class="gp-btn" onclick="hydroSim&&hydroSim.addBody()" style="flex:1">+ Тело</button>
|
||||
<button class="gp-btn" onclick="hydroSim&&hydroSim.clearBodies()" style="flex:1">Очистить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- contact angle (surface tension) -->
|
||||
<div id="hydro-panel-theta" style="margin-bottom:10px;display:none">
|
||||
<div style="display:flex;justify-content:space-between;font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">
|
||||
<span>Краевой угол θ</span>
|
||||
<span id="hydro-theta-lbl" style="color:#9B5DE5">20°</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="160" value="20" step="5" style="width:100%;accent-color:#9B5DE5" oninput="hydroSim&&hydroSim.setContactAngle(+this.value);document.getElementById('hydro-theta-lbl').textContent=this.value+'\u00B0';document.getElementById('hydro-theta-val').textContent=this.value+'\u00B0';document.querySelector('#hydro-surf-ctrl input[type=range]').value=this.value">
|
||||
<div style="display:flex;justify-content:space-between;font-size:.65rem;color:rgba(255,255,255,.25);margin-top:2px">
|
||||
<span>Смачивание</span><span>Несмачивание</span>
|
||||
</div>
|
||||
<div style="margin-top:6px">
|
||||
<button class="gp-btn" id="hydro-surf-toggle-panel" onclick="hydroToggleSurface()" style="width:100%">Капилляры</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- communicating vessels -->
|
||||
<div id="hydro-panel-comm" style="margin-bottom:10px;display:none">
|
||||
<div style="font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">Сосудов</div>
|
||||
<div style="display:flex;gap:5px">
|
||||
<button class="gp-btn hydro-nv active" onclick="hydroSetVessels(2,this)" style="flex:1">2</button>
|
||||
<button class="gp-btn hydro-nv" onclick="hydroSetVessels(3,this)" style="flex:1">3</button>
|
||||
<button class="gp-btn hydro-nv" onclick="hydroSetVessels(4,this)" style="flex:1">4</button>
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<button class="gp-btn" id="hydro-valve-panel-btn" onclick="hydroToggleValve()" style="width:100%;color:#06D6A0;border-color:rgba(6,214,160,.3)">Кран: открыт</button>
|
||||
</div>
|
||||
<div style="margin-top:6px;display:flex;gap:5px">
|
||||
<button class="gp-btn" onclick="hydroSim&&hydroSim.addLiquid(0)" style="flex:1">+ Жидкость</button>
|
||||
<button class="gp-btn" onclick="hydroSim&&hydroSim.removeLiquid()" style="flex:1">- Жидкость</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- formula display -->
|
||||
<div class="gp-section-title" style="margin-top:4px;margin-bottom:6px">Формулы</div>
|
||||
<div id="hydro-formulas" style="font-size:.72rem;font-family:'JetBrains Mono',monospace;color:rgba(255,255,255,.6);line-height:1.7;background:rgba(255,255,255,.03);border-radius:8px;padding:8px 10px;min-height:80px"></div>
|
||||
|
||||
<!-- result badge -->
|
||||
<div id="hydro-result" style="margin-top:8px;font-size:.82rem;font-weight:700;text-align:center;padding:8px;border-radius:8px;display:none"></div>
|
||||
|
||||
</div><!-- /.proj-panel -->
|
||||
|
||||
<!-- canvas area -->
|
||||
<div style="flex:1;min-width:0;position:relative">
|
||||
<canvas id="hydro-canvas" style="width:100%;height:100%;display:block"></canvas>
|
||||
</div>
|
||||
|
||||
</div><!-- /.sim-body-wrap -->
|
||||
</div><!-- /#sim-hydro -->
|
||||
|
||||
<!-- ── Theory panel (overlay right) ── -->
|
||||
<div class="theory-panel" id="theory-panel">
|
||||
<div class="theory-panel-inner" id="theory-content"></div>
|
||||
@@ -4152,6 +4293,10 @@
|
||||
title: 'Закон Кулона',
|
||||
desc: 'Силовые линии и эквипотенциальные поверхности для системы точечных зарядов.',
|
||||
preview: P_FIELD },
|
||||
{ id: 'hydrostatics', cat: 'phys',
|
||||
title: 'Гидростатика',
|
||||
desc: 'Давление жидкости P=ρgh, закон Архимеда, сообщающиеся сосуды, поверхностное натяжение и капиллярность.',
|
||||
preview: P_SANDBOX },
|
||||
{ id: 'dynamics', cat: 'phys',
|
||||
title: 'Динамика',
|
||||
desc: 'Законы Ньютона, песочница сил, наклонная плоскость — всё в одном интерактивном модуле.',
|
||||
@@ -4269,11 +4414,11 @@
|
||||
'sim-quadratic','sim-normaldist','sim-graphtransform',
|
||||
'sim-pendulum','sim-equilibrium','sim-thinlens','sim-titration',
|
||||
'sim-refraction','sim-mirrors','sim-isoprocess','sim-probability','sim-bohratom','sim-electrolysis',
|
||||
'sim-waves'];
|
||||
'sim-waves','sim-hydro'];
|
||||
const ALL_CTRL_BARS = ['ctrl-graph','ctrl-proj','ctrl-coll','ctrl-tri','ctrl-trigcircle','ctrl-mag',
|
||||
'ctrl-molphys',
|
||||
'ctrl-coulomb','ctrl-circuit','ctrl-chemistry','ctrl-dynamics','ctrl-chemsandbox',
|
||||
'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves'];
|
||||
'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves','ctrl-hydro'];
|
||||
|
||||
/* ── sim routing ── */
|
||||
|
||||
@@ -4324,6 +4469,8 @@
|
||||
if (id === 'bohratom') _openBohrAtom();
|
||||
if (id === 'electrolysis') _openElectrolysis();
|
||||
if (id === 'waves') _openWaves();
|
||||
if (id === 'hydrostatics') _openHydro();
|
||||
if (id.startsWith('hydrostatics:')) _openHydro(id.split(':')[1]);
|
||||
}
|
||||
|
||||
function _simShow(elId) {
|
||||
@@ -4423,6 +4570,21 @@
|
||||
_simShow('sim-graph');
|
||||
_simShow('ctrl-graph');
|
||||
|
||||
_registerSimState('graph',
|
||||
() => ({
|
||||
fns: [0,1,2].map(i => ({ expr: document.getElementById(`fn${i}`)?.value || '', color: FN_COLORS[i] }))
|
||||
}),
|
||||
(st) => {
|
||||
if (!Array.isArray(st.fns)) return;
|
||||
st.fns.forEach((fn, i) => {
|
||||
const el = document.getElementById(`fn${i}`);
|
||||
if (el) { el.value = fn.expr; }
|
||||
if (gSim) gSim.setFn(i, fn.expr, FN_COLORS[i]);
|
||||
});
|
||||
}
|
||||
);
|
||||
if (_embedMode) _startStateEmit('graph');
|
||||
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!gSim) {
|
||||
gSim = new GraphSim(document.getElementById('graph-canvas'));
|
||||
@@ -4446,6 +4608,8 @@
|
||||
document.getElementById('sim-topbar-title').textContent = 'Бросок тела';
|
||||
_simShow('sim-proj');
|
||||
_simShow('ctrl-proj');
|
||||
_registerSimState('projectile', () => pSim?.getParams(), st => pSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('projectile');
|
||||
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!pSim) {
|
||||
@@ -4629,6 +4793,8 @@
|
||||
document.getElementById('sim-topbar-title').textContent = 'Столкновение шаров';
|
||||
_simShow('sim-coll');
|
||||
_simShow('ctrl-coll');
|
||||
_registerSimState('collision', () => cSim?.getParams(), st => cSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('collision');
|
||||
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!cSim) {
|
||||
@@ -6535,6 +6701,8 @@
|
||||
function _openQuadratic() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Корни квадратного уравнения';
|
||||
_simShow('sim-quadratic');
|
||||
_registerSimState('quadratic', () => quadSim?.getParams(), st => quadSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('quadratic');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!quadSim) {
|
||||
quadSim = new QuadraticSim(document.getElementById('quadratic-canvas'));
|
||||
@@ -6573,6 +6741,8 @@
|
||||
function _openNormalDist() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Нормальное распределение';
|
||||
_simShow('sim-normaldist');
|
||||
_registerSimState('normaldist', () => ndSim?.getParams(), st => ndSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('normaldist');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!ndSim) {
|
||||
ndSim = new NormalDistSim(document.getElementById('normaldist-canvas'));
|
||||
@@ -6617,6 +6787,8 @@
|
||||
function _openGraphTransform() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Трансформации графиков';
|
||||
_simShow('sim-graphtransform');
|
||||
_registerSimState('graphtransform', () => gtSim?.getParams(), st => gtSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('graphtransform');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!gtSim) {
|
||||
gtSim = new GraphTransformSim(document.getElementById('graphtransform-canvas'));
|
||||
@@ -6663,6 +6835,8 @@
|
||||
function _openPendulum() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Маятник';
|
||||
_simShow('sim-pendulum');
|
||||
_registerSimState('pendulum', () => pendSim?.getParams(), st => pendSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('pendulum');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!pendSim) {
|
||||
pendSim = new PendulumSim(document.getElementById('pendulum-canvas'));
|
||||
@@ -6705,6 +6879,8 @@
|
||||
function _openEquilibrium() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Химическое равновесие';
|
||||
_simShow('sim-equilibrium');
|
||||
_registerSimState('equilibrium', () => eqSim?.getParams(), st => eqSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('equilibrium');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!eqSim) {
|
||||
eqSim = new EquilibriumSim(document.getElementById('equilibrium-canvas'));
|
||||
@@ -6746,6 +6922,8 @@
|
||||
function _openThinLens() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Тонкая линза';
|
||||
_simShow('sim-thinlens');
|
||||
_registerSimState('thinlens', () => lensSim?.getParams(), st => lensSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('thinlens');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!lensSim) {
|
||||
lensSim = new ThinLensSim(document.getElementById('thinlens-canvas'));
|
||||
@@ -6787,6 +6965,8 @@
|
||||
function _openMirror() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Зеркала';
|
||||
_simShow('sim-mirrors');
|
||||
_registerSimState('mirrors', () => mirrorSim?.getParams(), st => mirrorSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('mirrors');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!mirrorSim) {
|
||||
mirrorSim = new MirrorSim(document.getElementById('mirror-canvas'));
|
||||
@@ -6877,6 +7057,9 @@
|
||||
function _openIsoprocess() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Изопроцессы';
|
||||
_simShow('sim-isoprocess');
|
||||
_registerSimState('isoprocess', () => isoSim?.getParams(),
|
||||
st => { if (isoSim) { isoSim.setParams(st); if (st.process) isoSim.setProcess(st.process); } });
|
||||
if (_embedMode) _startStateEmit('isoprocess');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!isoSim) {
|
||||
isoSim = new IsoprocessSim(document.getElementById('isoprocess-canvas'));
|
||||
@@ -6941,6 +7124,8 @@
|
||||
function _openTitration() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'pH и кривая титрования';
|
||||
_simShow('sim-titration');
|
||||
_registerSimState('titration', () => titrSim?.getParams(), st => titrSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('titration');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!titrSim) {
|
||||
titrSim = new TitrationSim(document.getElementById('titration-canvas'));
|
||||
@@ -6989,6 +7174,8 @@
|
||||
function _openRefraction() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Преломление света';
|
||||
_simShow('sim-refraction');
|
||||
_registerSimState('refraction', () => refrSim?.getParams(), st => refrSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('refraction');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!refrSim) {
|
||||
refrSim = new RefractionSim(document.getElementById('refraction-canvas'));
|
||||
@@ -7028,6 +7215,8 @@
|
||||
function _openProbability() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Теория вероятностей';
|
||||
_simShow('sim-probability');
|
||||
_registerSimState('probability', () => probSim?.getParams(), st => probSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('probability');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!probSim) {
|
||||
probSim = new ProbabilitySim(document.getElementById('probability-canvas'));
|
||||
@@ -7066,6 +7255,8 @@
|
||||
function _openBohrAtom() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Атом Бора';
|
||||
_simShow('sim-bohratom');
|
||||
_registerSimState('bohratom', () => bohrSim?.getParams(), st => bohrSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('bohratom');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!bohrSim) {
|
||||
bohrSim = new BohrAtomSim(document.getElementById('bohratom-canvas'));
|
||||
@@ -7102,6 +7293,8 @@
|
||||
function _openElectrolysis() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Электролиз';
|
||||
_simShow('sim-electrolysis');
|
||||
_registerSimState('electrolysis', () => elecSim?.getParams(), st => elecSim?.setParams(st));
|
||||
if (_embedMode) _startStateEmit('electrolysis');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!elecSim) {
|
||||
elecSim = new ElectrolysisSim(document.getElementById('electrolysis-canvas'));
|
||||
@@ -7141,6 +7334,9 @@
|
||||
document.getElementById('sim-topbar-title').textContent = 'Волны и звук';
|
||||
document.getElementById('ctrl-waves').style.display = '';
|
||||
_simShow('sim-waves');
|
||||
_registerSimState('waves', () => wavesSim?.getParams(),
|
||||
st => { if (wavesSim) { if (st.mode) wavesSim.setMode(st.mode); wavesSim.setParams(st); } });
|
||||
if (_embedMode) _startStateEmit('waves');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!wavesSim) {
|
||||
wavesSim = new WavesSim(document.getElementById('waves-canvas'));
|
||||
@@ -7887,6 +8083,123 @@
|
||||
},
|
||||
};
|
||||
|
||||
/* ══════════════════════════════════════════════
|
||||
HYDROSTATICS
|
||||
══════════════════════════════════════════════ */
|
||||
let hydroSim = null;
|
||||
let _hydroValveOpen = true;
|
||||
|
||||
function _openHydro(preset) {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Гидростатика';
|
||||
_simShow('sim-hydro');
|
||||
document.getElementById('ctrl-hydro').style.display = '';
|
||||
_registerSimState('hydrostatics',
|
||||
() => ({ mode: hydroSim?.mode, liq: hydroSim?.liquidKey }),
|
||||
st => { if (st?.mode && hydroSim) hydroMode(st.mode); });
|
||||
if (_embedMode) _startStateEmit('hydrostatics');
|
||||
window.addEventListener('load', () => {}, { once: true });
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
const canvas = document.getElementById('hydro-canvas');
|
||||
const mode = preset || 'pressure';
|
||||
if (!hydroSim) {
|
||||
hydroSim = new HydroSim(canvas, mode);
|
||||
hydroSim.onUpdate = _hydroUpdateUI;
|
||||
} else {
|
||||
hydroSim.fit();
|
||||
hydroSim.play();
|
||||
}
|
||||
hydroMode(mode);
|
||||
}));
|
||||
}
|
||||
|
||||
function hydroMode(mode) {
|
||||
if (!hydroSim) return;
|
||||
hydroSim.setMode(mode);
|
||||
const sel = document.getElementById('hydro-mode-sel');
|
||||
if (sel) sel.value = mode;
|
||||
// show/hide sub-controls
|
||||
['arch','comm','surf','mat'].forEach(k => {
|
||||
const el = document.getElementById('hydro-panel-' + k);
|
||||
const el2 = document.getElementById('hydro-' + k + '-ctrl');
|
||||
if (el) el.style.display = 'none';
|
||||
if (el2) el2.style.display = 'none';
|
||||
});
|
||||
if (mode === 'archimedes') {
|
||||
const a = document.getElementById('hydro-panel-mat');
|
||||
const b = document.getElementById('hydro-arch-ctrl');
|
||||
if (a) a.style.display = '';
|
||||
if (b) b.style.display = 'flex';
|
||||
}
|
||||
if (mode === 'surface') {
|
||||
const a = document.getElementById('hydro-panel-theta');
|
||||
const b = document.getElementById('hydro-surf-ctrl');
|
||||
if (a) a.style.display = '';
|
||||
if (b) b.style.display = 'flex';
|
||||
}
|
||||
if (mode === 'communicating') {
|
||||
const a = document.getElementById('hydro-panel-comm');
|
||||
const b = document.getElementById('hydro-comm-ctrl');
|
||||
if (a) a.style.display = '';
|
||||
if (b) b.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function hydroToggleSurface() {
|
||||
if (!hydroSim) return;
|
||||
const next = hydroSim._stMode === 'capillary' ? 'drop' : 'capillary';
|
||||
hydroSim._stMode = next;
|
||||
const label = next === 'capillary' ? '\u041A\u0430\u043F\u0438\u043B\u043B\u044F\u0440\u044B' : '\u041A\u0430\u043F\u043B\u044F';
|
||||
['hydro-surf-toggle','hydro-surf-toggle-panel'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = label;
|
||||
});
|
||||
}
|
||||
|
||||
function hydroToggleValve() {
|
||||
if (!hydroSim) return;
|
||||
_hydroValveOpen = !_hydroValveOpen;
|
||||
hydroSim.setValve(_hydroValveOpen);
|
||||
const label = _hydroValveOpen ? 'Кран: открыт' : 'Кран: закрыт';
|
||||
const color = _hydroValveOpen ? '#06D6A0' : '#F15BB5';
|
||||
['hydro-valve-btn','hydro-valve-panel-btn'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.textContent = label; el.style.color = color; el.style.borderColor = _hydroValveOpen ? 'rgba(6,214,160,.3)' : 'rgba(241,91,181,.3)'; }
|
||||
});
|
||||
}
|
||||
|
||||
function hydroSetVessels(n, btn) {
|
||||
if (hydroSim) hydroSim.setNumVessels(n);
|
||||
document.querySelectorAll('.hydro-nv').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
}
|
||||
|
||||
function _hydroUpdateUI(info) {
|
||||
if (!info) return;
|
||||
const el = document.getElementById('hydro-formulas');
|
||||
if (!el) return;
|
||||
const lines = [];
|
||||
if (info.formula) lines.push(`<span style="color:#FFD166">${info.formula}</span>`);
|
||||
if (info.liqName) lines.push(`Жидкость: ${info.liqName}${info.rho ? ' (ρ=' + info.rho + ')' : ''}`);
|
||||
if (info.matName) lines.push(`Материал: ${info.matName}`);
|
||||
if (info.FA) lines.push(`<span style="color:#06D6E0">F_A = ${info.FA} Н</span>`);
|
||||
if (info.mg) lines.push(`<span style="color:#F15BB5">mg = ${info.mg} Н</span>`);
|
||||
if (info.sigma) lines.push(`σ = ${info.sigma} Н/м, θ = ${info.theta}°`);
|
||||
if (info.h && !info.FA) lines.push(`h_подъём = ${info.h} мм`);
|
||||
el.innerHTML = lines.join('<br>');
|
||||
// result badge
|
||||
const rb = document.getElementById('hydro-result');
|
||||
if (rb && info.state) {
|
||||
const colors = { 'ВСПЛЫВАЕТ': '#06D6A0', 'ТОНЕТ': '#F15BB5', 'ВЗВЕШЕНО': '#FFD166' };
|
||||
rb.style.display = '';
|
||||
rb.style.color = colors[info.state] || '#fff';
|
||||
rb.style.background = (colors[info.state] || '#9B5DE5') + '18';
|
||||
rb.style.border = '1px solid ' + (colors[info.state] || '#9B5DE5') + '44';
|
||||
rb.textContent = info.state;
|
||||
} else if (rb) {
|
||||
rb.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
let _theoryOpen = false;
|
||||
function toggleTheory() {
|
||||
_theoryOpen = !_theoryOpen;
|
||||
@@ -7923,6 +8236,51 @@
|
||||
const _embedMode = _qp.get('embed') === '1';
|
||||
const _autoSim = _qp.get('sim');
|
||||
|
||||
/* ── Sim state relay (embed mode only) ──────────────────────────────── */
|
||||
// Map simId → { getState, applyState } registered by openSim handlers
|
||||
const _simStateRegistry = {};
|
||||
|
||||
function _registerSimState(simId, getState, applyState) {
|
||||
_simStateRegistry[simId] = { getState, applyState };
|
||||
}
|
||||
|
||||
let _lastEmittedState = null;
|
||||
let _stateEmitInterval = null;
|
||||
|
||||
function _startStateEmit(simId) {
|
||||
if (_stateEmitInterval) clearInterval(_stateEmitInterval);
|
||||
_lastEmittedState = null;
|
||||
_stateEmitInterval = setInterval(() => {
|
||||
const reg = _simStateRegistry[simId];
|
||||
if (!reg) return;
|
||||
try {
|
||||
const state = reg.getState();
|
||||
const json = JSON.stringify(state);
|
||||
if (json === _lastEmittedState) return;
|
||||
_lastEmittedState = json;
|
||||
window.parent.postMessage({ type: 'sim_state', simId, state }, '*');
|
||||
} catch {}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function _stopStateEmit() {
|
||||
if (_stateEmitInterval) { clearInterval(_stateEmitInterval); _stateEmitInterval = null; }
|
||||
_lastEmittedState = null;
|
||||
}
|
||||
|
||||
// Receive apply_sim_state from parent (students)
|
||||
window.addEventListener('message', e => {
|
||||
if (!_embedMode) return;
|
||||
const d = e.data;
|
||||
if (!d || d.type !== 'apply_sim_state') return;
|
||||
const reg = _simStateRegistry[_autoSim];
|
||||
if (!reg) return;
|
||||
try {
|
||||
reg.applyState(d.state);
|
||||
_lastEmittedState = JSON.stringify(d.state); // suppress echo
|
||||
} catch {}
|
||||
});
|
||||
|
||||
if (_embedMode) {
|
||||
document.querySelector('.sidebar').style.display = 'none';
|
||||
document.querySelector('.sb-content').style.marginLeft = '0';
|
||||
@@ -7932,7 +8290,8 @@
|
||||
if (_autoSim) {
|
||||
document.getElementById('lab-sim').classList.add('open');
|
||||
document.querySelector('.sim-topbar').style.display = 'none';
|
||||
openSim(_autoSim);
|
||||
// defer until all external scripts are loaded
|
||||
window.addEventListener('load', () => openSim(_autoSim));
|
||||
}
|
||||
} else {
|
||||
/* init — fetch sim settings + permissions in parallel, then render */
|
||||
@@ -8006,5 +8365,6 @@
|
||||
<script src="/js/labs/probability.js"></script>
|
||||
<script src="/js/labs/bohratom.js"></script>
|
||||
<script src="/js/labs/electrolysis.js"></script>
|
||||
<script src="/js/labs/hydrostatics.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -787,6 +787,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
+178
-47
@@ -8,6 +8,7 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/ls.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<style>
|
||||
.sb-content { background: #f4f5f8; overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
@@ -135,10 +136,52 @@
|
||||
|
||||
/* question pick cards */
|
||||
.lq-q-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 24px; }
|
||||
/* question filters */
|
||||
.lq-filter-row { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||
.lq-filter-select {
|
||||
flex: 1; padding: 8px 10px;
|
||||
border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 500;
|
||||
color: #0F172A; background: #fff; outline: none; cursor: pointer;
|
||||
transition: border-color 0.15s; appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238898AA' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 8px center; padding-right: 24px;
|
||||
}
|
||||
.lq-filter-select:focus { border-color: var(--violet); }
|
||||
.lq-q-count { font-size: 0.7rem; color: #8898AA; font-weight: 600; white-space: nowrap; margin-bottom: 12px; }
|
||||
|
||||
/* load more */
|
||||
.btn-load-more {
|
||||
width: 100%; padding: 10px; border: 1.5px dashed rgba(155,93,229,0.3);
|
||||
border-radius: 12px; background: transparent; margin-bottom: 24px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
|
||||
color: var(--violet); cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.btn-load-more:hover { background: rgba(155,93,229,0.05); border-color: var(--violet); }
|
||||
.btn-load-more:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* result stat cards */
|
||||
.lq-result-stats { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||
.lq-result-stat { flex: 1; padding: 10px 12px; border-radius: 12px; background: rgba(15,23,42,0.04); text-align: center; }
|
||||
.lq-result-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 900; color: #0F172A; }
|
||||
.lq-result-stat-lbl { font-size: 0.64rem; color: #8898AA; margin-top: 2px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.lq-result-stat.rs-correct { background: rgba(6,214,160,0.08); }
|
||||
.lq-result-stat.rs-correct .lq-result-stat-val { color: #059652; }
|
||||
.lq-result-stat.rs-wrong { background: rgba(239,71,111,0.07); }
|
||||
.lq-result-stat.rs-wrong .lq-result-stat-val { color: #EF476F; }
|
||||
|
||||
/* explanation box */
|
||||
.lq-explanation {
|
||||
margin-top: 14px; padding: 13px 15px;
|
||||
background: rgba(6,214,224,0.06); border: 1.5px solid rgba(6,214,224,0.2);
|
||||
border-radius: 12px; font-size: 0.82rem; color: #0F172A; line-height: 1.6;
|
||||
}
|
||||
.lq-explanation-label { font-size: 0.64rem; font-weight: 700; color: #0891B2; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
|
||||
|
||||
.lq-q-card {
|
||||
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
|
||||
border-radius: 14px; padding: 14px 16px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
box-shadow: 0 1px 4px rgba(15,23,42,0.04); transition: all 0.15s;
|
||||
}
|
||||
.lq-q-card:hover { border-color: rgba(155,93,229,0.25); }
|
||||
@@ -146,9 +189,10 @@
|
||||
.lq-q-body { flex: 1; min-width: 0; }
|
||||
.lq-q-text {
|
||||
font-size: 0.84rem; font-weight: 600; color: #0F172A;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden; line-height: 1.5; min-height: 1.5em;
|
||||
}
|
||||
.lq-q-meta { font-size: 0.7rem; color: #8898AA; margin-top: 3px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.lq-q-meta { font-size: 0.7rem; color: #8898AA; margin-top: 5px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.btn-launch {
|
||||
padding: 7px 16px; border: none; border-radius: 999px;
|
||||
background: var(--grad-1); color: #fff;
|
||||
@@ -228,7 +272,7 @@
|
||||
}
|
||||
.lq-results-title {
|
||||
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
|
||||
color: #0F172A; margin-bottom: 14px; display: flex; align-items: center; gap: 8px;
|
||||
color: #0F172A; margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.result-bars { display: flex; flex-direction: column; gap: 10px; }
|
||||
.result-bar-row { display: flex; align-items: center; gap: 10px; }
|
||||
@@ -313,6 +357,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
@@ -421,12 +466,27 @@
|
||||
<i data-lucide="search" class="lq-search-icon"></i>
|
||||
<input class="lq-search" id="q-search" type="text" placeholder="Поиск вопросов…" />
|
||||
</div>
|
||||
<div class="lq-filter-row">
|
||||
<select class="lq-filter-select" id="topic-filter" onchange="onTopicFilter()">
|
||||
<option value="">Все темы</option>
|
||||
</select>
|
||||
<select class="lq-filter-select" id="diff-filter" onchange="onDiffFilter()" style="max-width:130px">
|
||||
<option value="">Любой уровень</option>
|
||||
<option value="1">Лёгкий</option>
|
||||
<option value="2">Средний</option>
|
||||
<option value="3">Сложный</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lq-q-count" id="q-count" style="display:none"></div>
|
||||
<div class="lq-q-list" id="q-list">
|
||||
<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">
|
||||
<div class="spinner" style="margin:0 auto 10px"></div>
|
||||
Загрузка вопросов…
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-load-more" id="btn-load-more" style="display:none" onclick="loadMoreQuestions()">
|
||||
Загрузить ещё
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -435,6 +495,8 @@
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||
<script>
|
||||
if (!LS.requireAuth()) throw new Error();
|
||||
|
||||
@@ -461,6 +523,22 @@
|
||||
'fill-blank': 'Заполни пробел', ordering: 'Порядок',
|
||||
};
|
||||
|
||||
/* ── math rendering ── */
|
||||
const MATH_DELIMS = [
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
];
|
||||
function mathHtml(text) {
|
||||
if (!text) return '';
|
||||
const tmp = document.createElement('span');
|
||||
tmp.textContent = text;
|
||||
if (window.renderMathInElement) {
|
||||
try { renderMathInElement(tmp, { delimiters: MATH_DELIMS, throwOnError: false }); } catch {}
|
||||
}
|
||||
return tmp.innerHTML;
|
||||
}
|
||||
|
||||
/* ── sidebar ── */
|
||||
function toggleSidebar() {
|
||||
const layout = document.querySelector('.app-layout');
|
||||
@@ -513,8 +591,12 @@
|
||||
let answerCount = 0;
|
||||
let sseSource = null;
|
||||
let allQuestions = [];
|
||||
let filteredQuestions = [];
|
||||
let searchTimeout = null;
|
||||
let _topicFilter = '';
|
||||
let _diffFilter = '';
|
||||
let _qPage = 0;
|
||||
let _totalQ = 0;
|
||||
const Q_LIMIT = 30;
|
||||
|
||||
/* ── load classes ── */
|
||||
async function loadClasses() {
|
||||
@@ -593,9 +675,24 @@
|
||||
document.getElementById('session-header-label').innerHTML =
|
||||
'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor" stroke="none"/></svg> Активна · ' + selectedClass.name;
|
||||
updateStudentCounter(0);
|
||||
loadTopics();
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
try {
|
||||
const topics = await LS.api('/api/topics');
|
||||
const sel = document.getElementById('topic-filter');
|
||||
sel.innerHTML = '<option value="">Все темы</option>';
|
||||
(topics || []).forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.id;
|
||||
opt.textContent = t.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function updateStudentCounter(count) {
|
||||
answerCount = count;
|
||||
document.getElementById('as-students-text').textContent =
|
||||
@@ -658,34 +755,56 @@
|
||||
}
|
||||
|
||||
/* ── load questions ── */
|
||||
async function loadQuestions() {
|
||||
document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA"><div class="spinner" style="margin:0 auto 10px"></div> Загрузка…</div>';
|
||||
async function loadQuestions(reset = true) {
|
||||
if (reset) { _qPage = 0; allQuestions = []; }
|
||||
if (reset) document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA"><div class="spinner" style="margin:0 auto 10px"></div> Загрузка…</div>';
|
||||
const params = new URLSearchParams({ limit: Q_LIMIT, offset: _qPage * Q_LIMIT });
|
||||
if (_topicFilter) params.set('topic_id', _topicFilter);
|
||||
if (_diffFilter) params.set('difficulty', _diffFilter);
|
||||
const sq = document.getElementById('q-search')?.value.trim();
|
||||
if (sq) params.set('search', sq);
|
||||
try {
|
||||
const data = await LS.api('/api/questions?limit=50');
|
||||
allQuestions = data.rows || [];
|
||||
filteredQuestions = allQuestions;
|
||||
const data = await LS.api('/api/questions?' + params.toString());
|
||||
const rows = data.rows || [];
|
||||
_totalQ = data.total ?? (reset ? rows.length : allQuestions.length + rows.length);
|
||||
allQuestions = reset ? rows : [...allQuestions, ...rows];
|
||||
renderQuestionList();
|
||||
const btnMore = document.getElementById('btn-load-more');
|
||||
const countEl = document.getElementById('q-count');
|
||||
if (btnMore) btnMore.style.display = allQuestions.length < _totalQ ? '' : 'none';
|
||||
if (countEl) { countEl.textContent = `Показано ${allQuestions.length} из ${_totalQ}`; countEl.style.display = ''; }
|
||||
} catch {
|
||||
document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.82rem">Ошибка загрузки вопросов</div>';
|
||||
if (reset) document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.82rem">Ошибка загрузки вопросов</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreQuestions() {
|
||||
const btn = document.getElementById('btn-load-more');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Загрузка…'; }
|
||||
_qPage++;
|
||||
await loadQuestions(false);
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Загрузить ещё'; }
|
||||
}
|
||||
|
||||
function onTopicFilter() { _topicFilter = document.getElementById('topic-filter').value; loadQuestions(true); }
|
||||
function onDiffFilter() { _diffFilter = document.getElementById('diff-filter').value; loadQuestions(true); }
|
||||
|
||||
function renderQuestionList() {
|
||||
const list = document.getElementById('q-list');
|
||||
if (!filteredQuestions.length) {
|
||||
if (!allQuestions.length) {
|
||||
list.innerHTML = '<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">Вопросов не найдено</div>';
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
filteredQuestions.forEach(q => {
|
||||
allQuestions.forEach(q => {
|
||||
const diffCls = 'badge-diff-' + (q.difficulty || 1);
|
||||
const diffLabel = q.difficulty === 1 ? 'Лёгкий' : q.difficulty === 2 ? 'Средний' : 'Сложный';
|
||||
const typeLabel = TYPE_LABELS[q.type] || q.type || '';
|
||||
const isLaunched = currentQuestion && currentQuestion.id === q.id;
|
||||
html += `<div class="lq-q-card${isLaunched ? ' launched' : ''}">
|
||||
<div class="lq-q-body">
|
||||
<div class="lq-q-text">${esc(q.text)}</div>
|
||||
<div class="lq-q-text" data-text="${esc(q.text)}"></div>
|
||||
<div class="lq-q-meta">
|
||||
<span class="badge ${diffCls}">${diffLabel}</span>
|
||||
${typeLabel ? `<span class="badge badge-type">${esc(typeLabel)}</span>` : ''}
|
||||
@@ -698,35 +817,28 @@
|
||||
</div>`;
|
||||
});
|
||||
list.innerHTML = html;
|
||||
list.querySelectorAll('.lq-q-text[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/* search questions */
|
||||
/* search questions — server-side */
|
||||
document.getElementById('q-search').addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const q = document.getElementById('q-search').value.trim().toLowerCase();
|
||||
filteredQuestions = q
|
||||
? allQuestions.filter(item => item.text.toLowerCase().includes(q) || (item.topic || '').toLowerCase().includes(q))
|
||||
: allQuestions;
|
||||
renderQuestionList();
|
||||
}, 280);
|
||||
searchTimeout = setTimeout(() => loadQuestions(true), 350);
|
||||
});
|
||||
|
||||
/* ── launch question ── */
|
||||
async function launchQuestion(questionId) {
|
||||
if (!activeSession) return;
|
||||
const q = allQuestions.find(x => x.id === questionId);
|
||||
if (!q) return;
|
||||
try {
|
||||
await LS.api('/api/live/' + activeSession.id + '/question', {
|
||||
const resp = await LS.api('/api/live/' + activeSession.id + '/question', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ question_id: questionId }),
|
||||
});
|
||||
currentQuestion = q;
|
||||
currentQuestion = resp.question || allQuestions.find(x => x.id === questionId) || { id: questionId };
|
||||
answerCount = 0;
|
||||
updateStudentCounter(0);
|
||||
renderActiveQuestion(q);
|
||||
renderActiveQuestion(currentQuestion);
|
||||
renderQuestionList();
|
||||
} catch (e) {
|
||||
LS.toast(e.message || 'Ошибка запуска вопроса', 'error');
|
||||
@@ -750,7 +862,7 @@
|
||||
const letter = String.fromCharCode(65 + idx);
|
||||
optionsHtml += `<div class="lq-active-opt${opt.is_correct ? ' correct' : ''}">
|
||||
<div class="lq-opt-letter">${opt.is_correct ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : letter}</div>
|
||||
<span>${esc(opt.text)}</span>
|
||||
<span data-text="${esc(opt.text)}"></span>
|
||||
</div>`;
|
||||
});
|
||||
optionsHtml += '</div>';
|
||||
@@ -762,7 +874,7 @@
|
||||
В эфире · <span class="badge ${diffCls}">${diffLabel}</span>
|
||||
${typeLabel ? `<span class="badge badge-type">${esc(typeLabel)}</span>` : ''}
|
||||
</div>
|
||||
<div class="lq-active-text">${esc(q.text)}</div>
|
||||
<div class="lq-active-text" data-text="${esc(q.text)}"></div>
|
||||
${optionsHtml}
|
||||
<div class="lq-counter">
|
||||
<div>
|
||||
@@ -779,6 +891,7 @@
|
||||
</button>
|
||||
<div id="results-area"></div>
|
||||
`;
|
||||
card.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
|
||||
lucide.createIcons();
|
||||
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
@@ -798,38 +911,48 @@
|
||||
}
|
||||
|
||||
function renderResults(data, container) {
|
||||
const { answers = [], options = [] } = data;
|
||||
// count answers per option
|
||||
const countMap = {};
|
||||
answers.forEach(a => {
|
||||
const key = a.option_id ?? a.answer ?? 'other';
|
||||
countMap[key] = (countMap[key] || 0) + 1;
|
||||
});
|
||||
const totalAnswers = answers.length;
|
||||
const maxCount = Math.max(...Object.values(countMap), 1);
|
||||
|
||||
// use options from current question if not in results
|
||||
const opts = options.length ? options : (currentQuestion?.options || []);
|
||||
const opts = data.options || [];
|
||||
const q = data.question || {};
|
||||
const stats = data.stats || {};
|
||||
const total = stats.total || 0;
|
||||
const correct= stats.correct|| 0;
|
||||
const maxCount = Math.max(...opts.map(o => o.chosen_count || 0), 1);
|
||||
|
||||
if (!opts.length) {
|
||||
container.innerHTML = '<div style="padding:16px;text-align:center;color:#8898AA;font-size:0.84rem">Нет данных о вариантах ответа</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const pctCorrect = total > 0 ? Math.round(correct / total * 100) : 0;
|
||||
const pctWrong = total > 0 ? 100 - pctCorrect : 0;
|
||||
|
||||
let html = `<div class="lq-results-wrap">
|
||||
<div class="lq-results-title"><i data-lucide="bar-chart-horizontal" style="width:14px;height:14px;opacity:0.5"></i> Результаты · ${totalAnswers} ответов</div>
|
||||
<div class="lq-results-title"><i data-lucide="bar-chart-horizontal" style="width:14px;height:14px;opacity:0.5"></i> Результаты</div>
|
||||
<div class="lq-result-stats">
|
||||
<div class="lq-result-stat">
|
||||
<div class="lq-result-stat-val">${total}</div>
|
||||
<div class="lq-result-stat-lbl">Ответов</div>
|
||||
</div>
|
||||
<div class="lq-result-stat rs-correct">
|
||||
<div class="lq-result-stat-val">${pctCorrect}%</div>
|
||||
<div class="lq-result-stat-lbl">Верно</div>
|
||||
</div>
|
||||
<div class="lq-result-stat rs-wrong">
|
||||
<div class="lq-result-stat-val">${pctWrong}%</div>
|
||||
<div class="lq-result-stat-lbl">Неверно</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-bars">`;
|
||||
|
||||
opts.forEach((opt, idx) => {
|
||||
const letter = String.fromCharCode(65 + idx);
|
||||
const key = opt.id ?? idx;
|
||||
const count = countMap[key] || 0;
|
||||
const pct = Math.round((count / Math.max(maxCount, 1)) * 100);
|
||||
const count = opt.chosen_count || 0;
|
||||
const pct = Math.round(count / maxCount * 100);
|
||||
const isCorrect = opt.is_correct;
|
||||
html += `<div class="result-bar-row">
|
||||
<div class="result-bar-label${isCorrect ? ' correct-lbl' : ''}">
|
||||
${isCorrect ? '<span class="rb-correct-marker"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>' : letter + '.'}
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100px">${esc(opt.text)}</span>
|
||||
<span class="rb-opt-text" data-text="${esc(opt.text)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100px"></span>
|
||||
</div>
|
||||
<div class="result-bar-track">
|
||||
<div class="result-bar-fill${isCorrect ? ' correct-fill' : ''}" style="width:${pct}%"></div>
|
||||
@@ -838,8 +961,16 @@
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += '</div></div>';
|
||||
html += '</div>';
|
||||
if (q.explanation) {
|
||||
html += `<div class="lq-explanation">
|
||||
<div class="lq-explanation-label">Объяснение</div>
|
||||
<div class="lq-exp-text" data-text="${esc(q.explanation)}"></div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
container.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -351,6 +351,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -550,6 +550,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/ls.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<style>
|
||||
.sb-content { background: #f4f5f8; overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
@@ -243,6 +244,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link nav-active"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
@@ -382,6 +384,8 @@
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||
<script>
|
||||
if (!LS.requireAuth()) throw new Error();
|
||||
|
||||
@@ -409,6 +413,22 @@
|
||||
};
|
||||
const SUBJECT_NAMES = { bio: 'Биология', chem: 'Химия', math: 'Математика', phys: 'Физика', other: 'Другое' };
|
||||
|
||||
/* ── math rendering ── */
|
||||
const _MATH_DELIMS = [
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
];
|
||||
function mathHtml(text) {
|
||||
if (!text) return '';
|
||||
const tmp = document.createElement('span');
|
||||
tmp.textContent = text;
|
||||
if (window.renderMathInElement) {
|
||||
try { renderMathInElement(tmp, { delimiters: _MATH_DELIMS, throwOnError: false }); } catch {}
|
||||
}
|
||||
return tmp.innerHTML;
|
||||
}
|
||||
|
||||
/* ── sidebar ── */
|
||||
function toggleSidebar() {
|
||||
const layout = document.querySelector('.app-layout');
|
||||
@@ -570,7 +590,7 @@
|
||||
<span class="badge ${diffCls}">${diffLabel}</span>
|
||||
<span class="badge badge-type">${esc(typeLabel)}</span>
|
||||
</div>
|
||||
<div class="qc-text">${esc(q.text)}</div>
|
||||
<div class="qc-text">${mathHtml(q.text)}</div>
|
||||
<div class="qc-footer">
|
||||
<span class="qc-topic"><i data-lucide="tag" style="width:11px;height:11px"></i> ${esc(q.topic || SUBJECT_NAMES[q.subject_slug] || '—')}</span>
|
||||
${optsCount ? `<span class="qc-opts-count"><i data-lucide="list" style="width:11px;height:11px"></i> ${optsCount} вар.</span>` : ''}
|
||||
@@ -578,20 +598,20 @@
|
||||
|
||||
if (isExpanded) {
|
||||
html += `<div class="qc-preview">
|
||||
<div class="qc-preview-text">${esc(q.text)}</div>`;
|
||||
<div class="qc-preview-text">${mathHtml(q.text)}</div>`;
|
||||
if ((q.options || []).length) {
|
||||
html += '<div class="qc-options">';
|
||||
q.options.forEach((opt, idx) => {
|
||||
const letter = String.fromCharCode(65 + idx);
|
||||
html += `<div class="qc-option${opt.is_correct ? ' correct' : ''}">
|
||||
<div class="qc-option-marker">${opt.is_correct ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : letter}</div>
|
||||
<span>${esc(opt.text)}</span>
|
||||
<span>${mathHtml(opt.text)}</span>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
if (q.explanation) {
|
||||
html += `<div class="qc-explanation"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>`;
|
||||
html += `<div class="qc-explanation"><strong>Пояснение:</strong> ${mathHtml(q.explanation)}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
<div class="bio-topbar">
|
||||
<a href="/red-book.html" style="color:var(--rb-muted);text-decoration:none;font-size:12px;border:1px solid var(--rb-border);padding:6px 12px;border-radius:8px;"><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Красная книга</a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<h1><svg class="ic" viewBox="0 0 24 24"><path d="M17 14 12 3 7 14"/><path d="M4 20 8 11h8l4 9"/><line x1="12" y1="20" x2="12" y2="22"/></svg> Биомы Беларуси</h1>
|
||||
<div class="biome-tabs" id="biome-tabs"></div>
|
||||
<button id="sound-btn" onclick="toggleSound()" title="Звуки биома" style="display:inline-flex;align-items:center;gap:6px;background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;cursor:pointer;flex-shrink:0;transition:all .2s;"><svg class="ic" viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg> Звук</button>
|
||||
|
||||
@@ -170,6 +170,7 @@
|
||||
<div class="eco-topbar">
|
||||
<a href="/red-book.html" class="eco-back"><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Красная книга</a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<h1><svg class="ic" viewBox="0 0 24 24"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg> Пищевые сети · Симулятор экосистем</h1>
|
||||
<span style="margin-left:auto;font-size:12px;color:var(--rb-muted)" id="node-count"></span>
|
||||
<button onclick="toggleEnergyFlow()" id="btn-energy" title="Анимация потока энергии" style="background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;padding:6px 12px;border-radius:8px;cursor:pointer;transition:all .2s"><svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> Поток</button>
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
<p class="rb-sb-section">РАЗДЕЛЫ</p>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Каталог видов</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<a href="/collection-rb.html" class="sb-link"><i data-lucide="star" class="sb-icon"></i><span class="sb-lbl">Моя коллекция</span></a>
|
||||
<a href="/red-book-ecosystem.html" class="sb-link"><i data-lucide="git-fork" class="sb-icon"></i><span class="sb-lbl">Пищевые сети</span></a>
|
||||
<a href="/red-book-biomes.html" class="sb-link"><i data-lucide="trees" class="sb-icon"></i><span class="sb-lbl">Биомы</span></a>
|
||||
|
||||
@@ -517,6 +517,7 @@
|
||||
<p class="rb-sb-section">РАЗДЕЛЫ</p>
|
||||
<a href="/red-book.html" class="sb-link active"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Каталог видов</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<a href="/collection-rb.html" class="sb-link"><i data-lucide="star" class="sb-icon"></i><span class="sb-lbl">Моя коллекция</span></a>
|
||||
<a href="/red-book-ecosystem.html" class="sb-link"><i data-lucide="git-fork" class="sb-icon"></i><span class="sb-lbl">Пищевые сети</span></a>
|
||||
<a href="/red-book-biomes.html" class="sb-link"><i data-lucide="trees" class="sb-icon"></i><span class="sb-lbl">Биомы</span></a>
|
||||
|
||||
@@ -305,6 +305,7 @@
|
||||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||||
<div class="sb-divider"></div>
|
||||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
|
||||
@@ -672,6 +672,8 @@ window.LS = {
|
||||
parentGetLinks, parentCreateLink, parentUpdateLink, parentDeleteLink,
|
||||
crCreateSession, crGetSession, crEndSession, crGetActiveByClass, crGetMyActive,
|
||||
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
|
||||
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
|
||||
crAdminGetAllHistory, crAdminGetTeachersList,
|
||||
escapeHtml, esc,
|
||||
parseDate, fmtRelTime, safeHref,
|
||||
initPage,
|
||||
@@ -985,6 +987,28 @@ async function crGetChat(id) { return req('GET', `/classroom/
|
||||
async function crGetAttendance(id) { return req('GET', `/classroom/${id}/attendance`); }
|
||||
async function crSignal(id, targetUserId, payload) { return req('POST', `/classroom/${id}/signal`, { target_user_id: targetUserId, payload }); }
|
||||
async function crGetOnlineStudents() { return req('GET', '/classroom/online-students'); }
|
||||
async function crGetMyHistory(page = 1) { return req('GET', `/classroom/my/history?page=${page}`); }
|
||||
async function crGetClassHistory(classId, page = 1, search = '') {
|
||||
const q = search ? `&search=${encodeURIComponent(search)}` : '';
|
||||
return req('GET', `/classroom/class/${classId}/history?page=${page}${q}`);
|
||||
}
|
||||
async function crGetSessionSummary(id) { return req('GET', `/classroom/${id}/summary`); }
|
||||
async function crExportChatUrl(id) { return `/api/classroom/${id}/chat/export`; }
|
||||
async function crGetAllNotes(id) { return req('GET', `/classroom/${id}/notes/all`); }
|
||||
async function crDeleteHistory(id) { return req('DELETE', `/classroom/${id}/history`); }
|
||||
async function crAdminGetAllHistory(p = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (p.page) q.set('page', p.page);
|
||||
if (p.limit) q.set('limit', p.limit);
|
||||
if (p.search) q.set('search', p.search);
|
||||
if (p.teacher) q.set('teacher', p.teacher);
|
||||
if (p.class_id) q.set('class_id', p.class_id);
|
||||
if (p.date_from) q.set('date_from', p.date_from);
|
||||
if (p.date_to) q.set('date_to', p.date_to);
|
||||
if (p.sort) q.set('sort', p.sort);
|
||||
return req('GET', `/classroom/admin/sessions?${q}`);
|
||||
}
|
||||
async function crAdminGetTeachersList() { return req('GET', '/classroom/admin/teachers-list'); }
|
||||
|
||||
/* ── gamification admin ────────────────────────────────────────────────── */
|
||||
async function adminGamAward(data) { return req('POST', '/gamification/admin/award', data); }
|
||||
@@ -1046,6 +1070,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
styleEl.textContent = STYLE;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window._lsLiveOverriddenByClassroom) return;
|
||||
document.head.appendChild(styleEl);
|
||||
document.body.appendChild(el);
|
||||
});
|
||||
@@ -1053,15 +1078,38 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
let currentLiveId = null;
|
||||
let answered = false;
|
||||
|
||||
/* render text that may contain \(...\) or \[...\] LaTeX using window.katex */
|
||||
function _mathHtml(text) {
|
||||
if (!text) return '';
|
||||
const kat = window.katex;
|
||||
if (!kat) { const d = document.createElement('span'); d.textContent = text; return d.innerHTML; }
|
||||
let out = '', i = 0;
|
||||
while (i < text.length) {
|
||||
const ii = text.indexOf('\\(', i), bi = text.indexOf('\\[', i);
|
||||
let next = -1, close = '', disp = false;
|
||||
if (ii >= 0 && (bi < 0 || ii <= bi)) { next = ii; close = '\\)'; disp = false; }
|
||||
else if (bi >= 0) { next = bi; close = '\\]'; disp = true; }
|
||||
const plain = document.createElement('span');
|
||||
if (next < 0) { plain.textContent = text.slice(i); out += plain.innerHTML; break; }
|
||||
plain.textContent = text.slice(i, next); out += plain.innerHTML;
|
||||
const ci = text.indexOf(close, next + 2);
|
||||
if (ci < 0) { const p2 = document.createElement('span'); p2.textContent = text.slice(next); out += p2.innerHTML; break; }
|
||||
try { out += kat.renderToString(text.slice(next + 2, ci), { displayMode: disp, throwOnError: false }); }
|
||||
catch { const p2 = document.createElement('span'); p2.textContent = text.slice(next, ci + close.length); out += p2.innerHTML; }
|
||||
i = ci + close.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function openOverlay(liveId, question, options) {
|
||||
currentLiveId = liveId;
|
||||
answered = false;
|
||||
document.getElementById('lslq-text').textContent = question.text;
|
||||
document.getElementById('lslq-text').innerHTML = _mathHtml(question.text);
|
||||
const keys = 'АБВГДЕ';
|
||||
document.getElementById('lslq-opts').innerHTML = (options || []).map((o, i) => `
|
||||
<div class="ls-live-opt" data-id="${o.id}" onclick="window._lsLiveAnswer(${liveId},${o.id},this)">
|
||||
<span class="ls-live-opt-key">${keys[i] || i+1}</span>
|
||||
<span>${esc(o.text)}</span>
|
||||
<span>${_mathHtml(o.text)}</span>
|
||||
</div>`).join('');
|
||||
document.getElementById('lslq-status').textContent = 'Выберите ответ';
|
||||
document.getElementById('ls-live-overlay').classList.add('open');
|
||||
@@ -1079,7 +1127,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
return `<div class="ls-live-opt ${cls}" style="flex-direction:column;align-items:flex-start;gap:4px">
|
||||
<div style="display:flex;align-items:center;gap:10px;width:100%">
|
||||
<span class="ls-live-opt-key">${keys[i]||i+1}</span>
|
||||
<span style="flex:1">${esc(o.text)}</span>
|
||||
<span style="flex:1">${_mathHtml(o.text)}</span>
|
||||
<span class="ls-live-result-pct">${pct}%</span>
|
||||
</div>
|
||||
<div class="ls-live-result-bar" style="width:100%">
|
||||
@@ -1103,6 +1151,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window._lsLiveOverriddenByClassroom) return;
|
||||
connectSSE(d => {
|
||||
if (d.type === 'live_question') {
|
||||
openOverlay(d.liveId, d.question, d.question?.options);
|
||||
|
||||
Reference in New Issue
Block a user