feat(classroom): открытие любого учебника в онлайн-уроке
Учитель может выбрать любой активный учебник из каталога /api/textbooks и открыть его в общем iframe для всех участников. По аналогии с симуляциями: - Backend: контроллер classroom/textbook.js + 4 роута (POST/DELETE /:id/textbook, /:id/textbook/nav, /:id/textbook/mode) с SSE-событиями classroom_textbook_open|close|nav|mode - Embed-режим /textbook/:slug?embed=1: сервер injectит CSS+JS-bridge перед </head>, скрывая хедер/сайдбар и пересылая клики/скролл наверх через postMessage (без правки 40+ HTML-учебников) - Frontend (classroom.html): кнопка «Учебник» в header, пикер с фильтрами по предмету, iframe-панель с режимами демо/свободно, relay nav-событий учителя → всем студентам в demo-режиме
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
const db = require('../../db/db');
|
||||
const { emitToSession } = require('./_shared');
|
||||
|
||||
const _stmtTextbookBySlug = db.prepare(
|
||||
'SELECT slug, title, subject, grade FROM textbooks WHERE slug=? AND is_active=1'
|
||||
);
|
||||
|
||||
function _requireTeacher(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) { res.status(404).json({ error: 'Сессия не активна' }); return null; }
|
||||
if (session.teacher_id !== req.user.id && req.user.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Нет доступа' }); return null;
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function textbookOpen(req, res) {
|
||||
const sessionId = _requireTeacher(req, res);
|
||||
if (!sessionId) return;
|
||||
|
||||
const { slug, hash } = req.body || {};
|
||||
if (!slug || typeof slug !== 'string' || !/^[a-z0-9_-]{1,80}$/i.test(slug))
|
||||
return res.status(400).json({ error: 'Неверный slug' });
|
||||
|
||||
const tb = _stmtTextbookBySlug.get(slug);
|
||||
if (!tb) return res.status(404).json({ error: 'Учебник не найден' });
|
||||
|
||||
const safeHash = (typeof hash === 'string' && /^[a-z0-9_-]{1,40}$/i.test(hash)) ? hash : null;
|
||||
|
||||
emitToSession(sessionId, {
|
||||
type: 'classroom_textbook_open',
|
||||
sessionId,
|
||||
slug: tb.slug,
|
||||
title: tb.title,
|
||||
subject: tb.subject,
|
||||
grade: tb.grade,
|
||||
hash: safeHash,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
function textbookNav(req, res) {
|
||||
const sessionId = _requireTeacher(req, res);
|
||||
if (!sessionId) return;
|
||||
|
||||
const { slug, hash, scrollY } = req.body || {};
|
||||
if (!slug || typeof slug !== 'string' || !/^[a-z0-9_-]{1,80}$/i.test(slug))
|
||||
return res.status(400).json({ error: 'Неверный slug' });
|
||||
if (!_stmtTextbookBySlug.get(slug))
|
||||
return res.status(404).json({ error: 'Учебник не найден' });
|
||||
|
||||
const safeHash = (typeof hash === 'string' && /^[a-z0-9_-]{1,40}$/i.test(hash)) ? hash : null;
|
||||
const sy = (typeof scrollY === 'number' && isFinite(scrollY)) ? Math.max(0, Math.min(scrollY, 1e6)) : null;
|
||||
|
||||
emitToSession(sessionId, {
|
||||
type: 'classroom_textbook_nav',
|
||||
sessionId, slug, hash: safeHash, scrollY: sy,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
function textbookMode(req, res) {
|
||||
const sessionId = _requireTeacher(req, res);
|
||||
if (!sessionId) return;
|
||||
const { mode } = req.body || {};
|
||||
if (mode !== 'demo' && mode !== 'free') return res.status(400).json({ error: 'mode must be demo|free' });
|
||||
emitToSession(sessionId, { type: 'classroom_textbook_mode', sessionId, mode });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
function textbookClose(req, res) {
|
||||
const sessionId = _requireTeacher(req, res);
|
||||
if (!sessionId) return;
|
||||
emitToSession(sessionId, { type: 'classroom_textbook_close', sessionId });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
module.exports = { textbookOpen, textbookNav, textbookMode, textbookClose };
|
||||
@@ -8,5 +8,6 @@ module.exports = {
|
||||
...require('./classroom/chat'),
|
||||
...require('./classroom/permissions'),
|
||||
...require('./classroom/sim'),
|
||||
...require('./classroom/textbook'),
|
||||
...require('./classroom/admin'),
|
||||
};
|
||||
|
||||
@@ -108,6 +108,12 @@ router.post('/:id/sim/state', ...teacher, c.simState);
|
||||
router.post('/:id/sim/mode', ...teacher, c.simMode);
|
||||
router.post('/:id/sim/annotate', ...teacher, c.simAnnotate);
|
||||
|
||||
// Textbook: open/close/navigate for all participants
|
||||
router.post('/:id/textbook', ...teacher, c.textbookOpen);
|
||||
router.delete('/:id/textbook', ...teacher, c.textbookClose);
|
||||
router.post('/:id/textbook/nav', ...teacher, c.textbookNav);
|
||||
router.post('/:id/textbook/mode', ...teacher, c.textbookMode);
|
||||
|
||||
// Cursor broadcast (all participants)
|
||||
router.post('/:id/cursor', ...auth, cursorLimiter, c.broadcastCursor);
|
||||
|
||||
|
||||
@@ -326,13 +326,106 @@ app.use((req, res, next) => {
|
||||
});
|
||||
|
||||
// Clean URL for textbooks: /textbook/<slug> → frontend/textbooks/<html_path>
|
||||
// With ?embed=1 — inject CSS that hides headers/sidebars + JS-bridge for classroom sync.
|
||||
const fs = require('fs');
|
||||
const _textbookDb = require('./db/db');
|
||||
const _stmtTextbookPath = _textbookDb.prepare('SELECT html_path FROM textbooks WHERE slug=? AND is_active=1');
|
||||
const _embedCache = new Map(); // html_path → {mtime, html}
|
||||
|
||||
const EMBED_INJECT = `
|
||||
<style id="__ls_embed_style__">
|
||||
/* Скрываем хедеры/боковые навигации в embed-режиме classroom */
|
||||
.hdr, .hdr-back, .hdr-side, .app-layout > .sidebar, .sidebar, .mob-bar,
|
||||
.topbar, .topbar-nav, .tb-back, .ls-topbar, .ls-sidebar, .sb-content > .sidebar,
|
||||
header.hdr, nav.sidebar { display: none !important; }
|
||||
.app-layout { display: block !important; }
|
||||
.sb-content { margin-left: 0 !important; width: 100% !important; }
|
||||
body { padding-top: 0 !important; margin-top: 0 !important; }
|
||||
main { padding-top: 16px !important; }
|
||||
html, body { background: #fff; }
|
||||
</style>
|
||||
<script id="__ls_embed_bridge__">
|
||||
(function(){
|
||||
try {
|
||||
if (window.parent === window) return;
|
||||
var SLUG = __LS_SLUG__;
|
||||
// Перехват внутренних ссылок → передача наверх (учитель транслирует всем)
|
||||
document.addEventListener('click', function(e){
|
||||
var a = e.target.closest && e.target.closest('a[href]');
|
||||
if (!a) return;
|
||||
var href = a.getAttribute('href') || '';
|
||||
if (!href || href.startsWith('javascript:')) return;
|
||||
if (href.startsWith('#')) {
|
||||
// hash-навигация внутри страницы
|
||||
window.parent.postMessage({ type:'ls_tb_nav', slug:SLUG, hash:href.slice(1) }, '*');
|
||||
return;
|
||||
}
|
||||
// ссылка на другой учебник (/textbook/<slug>)
|
||||
var m = href.match(/^\\/textbook\\/([a-z0-9_-]+)/i);
|
||||
if (m) {
|
||||
e.preventDefault();
|
||||
window.parent.postMessage({ type:'ls_tb_nav', slug:m[1], hash:null }, '*');
|
||||
}
|
||||
}, true);
|
||||
// Скролл (throttled) — учитель транслирует
|
||||
var st;
|
||||
window.addEventListener('scroll', function(){
|
||||
clearTimeout(st);
|
||||
st = setTimeout(function(){
|
||||
window.parent.postMessage({ type:'ls_tb_scroll', slug:SLUG, scrollY:window.scrollY }, '*');
|
||||
}, 250);
|
||||
}, { passive:true });
|
||||
// Принимаем команды от parent (для студентов в demo-режиме)
|
||||
window.addEventListener('message', function(e){
|
||||
var d = e.data; if (!d || typeof d !== 'object') return;
|
||||
if (d.type === 'ls_tb_apply') {
|
||||
if (d.hash != null) { try { location.hash = '#' + d.hash; } catch(_){} }
|
||||
if (typeof d.scrollY === 'number') { try { window.scrollTo(0, d.scrollY); } catch(_){} }
|
||||
}
|
||||
if (d.type === 'ls_tb_lock') {
|
||||
// блокируем взаимодействие у студентов в demo
|
||||
var el = document.getElementById('__ls_embed_lock__');
|
||||
if (d.locked && !el) {
|
||||
el = document.createElement('div');
|
||||
el.id = '__ls_embed_lock__';
|
||||
el.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:transparent;cursor:not-allowed';
|
||||
document.body.appendChild(el);
|
||||
} else if (!d.locked && el) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
// Сообщаем parent что embed готов
|
||||
window.parent.postMessage({ type:'ls_tb_ready', slug:SLUG }, '*');
|
||||
} catch(_){}
|
||||
})();
|
||||
</script>
|
||||
`;
|
||||
|
||||
function _renderEmbed(filePath, slug) {
|
||||
let stat; try { stat = fs.statSync(filePath); } catch { return null; }
|
||||
const cached = _embedCache.get(filePath);
|
||||
if (cached && cached.mtime === stat.mtimeMs && cached.slug === slug) return cached.html;
|
||||
let html;
|
||||
try { html = fs.readFileSync(filePath, 'utf8'); } catch { return null; }
|
||||
const inject = EMBED_INJECT.replace('__LS_SLUG__', JSON.stringify(slug));
|
||||
if (html.includes('</head>')) html = html.replace('</head>', inject + '</head>');
|
||||
else html = inject + html;
|
||||
_embedCache.set(filePath, { mtime: stat.mtimeMs, slug, html });
|
||||
return html;
|
||||
}
|
||||
|
||||
app.get('/textbook/:slug', (req, res, next) => {
|
||||
const row = _stmtTextbookPath.get(req.params.slug);
|
||||
if (!row) return next();
|
||||
const filePath = path.join(frontendDir, 'textbooks', row.html_path);
|
||||
if (!isProd) res.setHeader('Cache-Control', 'no-store');
|
||||
if (req.query.embed === '1') {
|
||||
const html = _renderEmbed(filePath, req.params.slug);
|
||||
if (html == null) return next();
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
return res.send(html);
|
||||
}
|
||||
res.sendFile(filePath, err => { if (err) next(); });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user