62 Commits

Author SHA1 Message Date
Maxim Dolgolyov ce4f1dcec1 fix(qbank): ручная транзакция вместо db.transaction (node:sqlite не имеет её)
DatabaseSync (node:sqlite) не имеет .transaction() как better-sqlite3 —
в контроллерах она добавлена обёрткой в db/db.js, а скрипт открывает БД
напрямую. Заменил на runTx() с ручным BEGIN/COMMIT/ROLLBACK. Применение
предложений (--apply) теперь работает; перегенерация JSON не требуется.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:21:43 +03:00
Maxim Dolgolyov 9858108556 feat(qbank): гард публикации теста + ИИ-инструмент ремонта банка вопросов
P0 целостность банка. Аудит показал: «180 битых» — ложная тревога (177 это
fill-blank с ответом в correct_text). Реально битых MCQ — 3 (single без верного
варианта), без темы — 1020 (все по математике).

- testController.update: нельзя опубликовать тест ученикам, если в нём есть
  вопрос без правильного ответа (нет верного варианта И нет correct_text);
  возвращает список brokenQuestions. unanswerableInTest() — переиспользуемо.
- scripts/fix-question-bank.js: ИИ-ремонт через шлюз Kilo. --broken (выбрать
  верный вариант среди существующих), --topics (привязать матем-вопросы к
  существующим темам, батчами). DRY-RUN по умолчанию → предложения в JSON на
  вычитку → --apply применяет. --limit N для теста. Проверено: broken 3/3
  (graph-вопросы корректно → ручная проверка), topics 12/12 в адекватные темы.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:53:15 +03:00
Maxim Dolgolyov e53c107d83 feat(materials): теги и фильтр по тегам в «Мои материалы»
- теги показываются чипами на карточках (клик по тегу — фильтр)
- панель тегов над сеткой: «Все теги» + все теги пользователя, активный подсвечен
- теги редактируются в модалках «Изменить» и «Новая заметка» (через запятую,
  normTags: тримминг, дедуп без учёта регистра, лимит 12)
- фильтр по тегу + существующий текстовый поиск (поиск уже включал tags в haystack)

Только фронт: колонка tags и приём в create/update/list уже были на бэкенде.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:35:11 +03:00
Maxim Dolgolyov c49077abbc feat(assistant): живость питомца — лицо реагирует на диалог (фича 6/6)
Лицо Квантика в шапке чата (PetSprite) меняет настроение по состоянию:
- думает (нейтральное + лёгкая анимация-покачивание asstThink) пока ждём/стримим
- радуется (happy) на готовый ответ; грустит (sad) на ошибку/лимит/«не нашёл»
- ликует (ecstatic) на сгенерированный тест и нарисованную картинку
Вплетено в send/sendNonStream/makeQuiz/drawInChat через setNameFace().
Анимация уважает prefers-reduced-motion. Только frontend.

Серия из 6 фич доработки Квантика завершена (стриминг, контекст урока,
сократический режим, авто-здоровье провайдеров, генерация тестов, живость).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:12:49 +03:00
Maxim Dolgolyov 78aea47619 feat(assistant): генерация тестов в банк вопросов (фича 5/6)
Учитель: режим «Тест в банк» в Квантике — тема/текст превращается ИИ в вопросы
с выбором ответа, ревью в чате (варианты, верный подсвечен, пояснение),
кнопка «Сохранить в банк» (выбор предмета + тема) создаёт их через POST /questions.

Бэкенд: questionsFromText (по образцу flashcardsFromText, надёжный парс JSON
с починкой обрезанного) + роут POST /assistant/questions (requireRole
teacher/admin, fcLimiter). Клиент: LS.assistantQuestions. Виджет: режим quiz
только для учителя + makeQuiz (рендер и сохранение через createQuestion/getSubjects).

Проверено на живом шлюзе: 5 валидных вопросов, верный индекс в диапазоне.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:09:02 +03:00
Maxim Dolgolyov bc0ed1892f feat(assistant): авто-здоровье провайдеров + ручная проверка (фича 4/6)
Новый модуль assistant-health.js (по образцу classroom-cleanup): каждые 15 мин
пингует каждого провайдера (pingLLM) → app_settings.assistant_health
{ id:{ok,at,error,ms,fails} }. Авто-понижение: если активный провайдер
не отвечает 2+ раза подряд, а есть здоровый рабочий запасной — автоматически
переключает assistant_active и пишет assistant_failover (баннер «health»).
schedule() из server.js (unref).

Админка: тумблер «Авто-проверка провайдеров», кнопка «Проверить сейчас»
(POST /admin/assistant/health → runHealth), цветной индикатор здоровья на
каждой карточке провайдера (зелёный/красный + время/ошибка в title).
keyless-шлюзы и провайдеры без ключа учтены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:02:37 +03:00
Maxim Dolgolyov 40c3152fe8 feat(assistant): сократический / анти-чит режим (фича 3/6)
- тумблер учителя «Сократический режим» (/admin#assistant): для УЧЕНИКОВ
  Квантик объясняет теорию полно, но конкретные задачи не решает «под ключ» —
  даёт метод, первый шаг и наводящий вопрос (assistant_socratic в app_settings)
- авто-анти-чит: явная просьба «сделай за меня / реши моё дз / do my homework»
  включает сократический режим даже без тумблера (_CHEAT_RE)
- учителей/админов и режимы hint/check не ограничивает; работает и в /ask, и в стриме

_socraticFor(role,mode,q) + проброс socratic в buildAskMessages. Бэкенд+админ-UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:57:24 +03:00
Maxim Dolgolyov 2506a72806 feat(assistant): контекст урока/страницы для Квантика (фича 2/6)
Квантик теперь знает, где находится ученик:
- pageHint() — лёгкий ситуативный контекст («ученик на странице учебник:
  «Физика 7, §12»») подмешивается к ЛЮБОМУ свободному вопросу автоматически
- getPageContext() расширен с учебника на уроки (theory/course/lesson):
  «Объяснить этот урок / Конспект урока / Флешкарты из урока»
- метки чипов адаптируются (параграф/урок)

Бэкенд уже принимал context (pageCtx) — правок не потребовалось.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:52:42 +03:00
Maxim Dolgolyov 089f93b8ee feat(assistant): стриминг ответов Квантика (фича 1/6)
Ответ модели «печатается» вживую через SSE поверх POST (fetch-stream,
не EventSource). Бэкенд: callLLMStream (stream:true, парсинг SSE upstream) +
callLLMStreamFailover (failover только до первого куска) + endpoint
POST /assistant/ask/stream (события meta|delta|done; быстрые пути FAQ/кэш/мета
отдаются одним done). buildAskMessages выделен из askModel (DRY).
Клиент: LS.assistantAskStream (fetch-stream + парсер SSE). Виджет: send()
стримит дельты как plain-текст с CSS-кареткой, на done — KaTeX-рендер,
источники, ссылки, оценка. Фоллбэк на sendNonStream (старый путь) если
стриминг недоступен/упал до первого куска. Cache-Control: no-transform
отключает буферизацию compression.

Проверено против живого шлюза: 24 дельты, первый текст ~1.3с, 100% русский.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:50:11 +03:00
Maxim Dolgolyov 5b4d9324a4 feat(assistant): поддержка keyless-шлюзов + пресет Pollinations
Pollinations (text.pollinations.ai/openai, модель openai) даёт бесплатный
инференс БЕЗ ключа — проверено: 98% чистый русский. Чтобы такой провайдер
считался рабочим (раньше ключ требовался всем, кроме localhost):
- _noKeyNeeded/_aNoKey: localhost ИЛИ pollinations.ai → ключ не обязателен
  (используется в providersOrdered, pingLLM, active-check, testAssistant)
- пресет «Pollinations (без ключа)» в ASSISTANT_PRESETS
- бейдж провайдера: «без ключа» (зелёный) вместо «нет ключа» для keyless

Кейд-провайдеры (Kilo/Gemini/HF/…) по-прежнему требуют ключ — затронуты
только URL с pollinations.ai (спуф в пути отвергается).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:35:56 +03:00
Maxim Dolgolyov 4d2d02f080 feat(assistant): пресет провайдера HuggingFace Router
Добавлен OpenAI-совместимый шлюз HuggingFace Router
(router.huggingface.co/v1/chat/completions, дефолт Qwen/Qwen2.5-72B-Instruct)
в ASSISTANT_PRESETS — выбирается из выпадашки при добавлении провайдера.
Эндпоинт /models публичный (121 модель), «Загрузить модели» работает;
нужен HF access-token с правом inference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:23:55 +03:00
Maxim Dolgolyov d15c15ef2a feat(assistant): сканер бесплатных моделей Kilo в админке
Кнопка «Сканировать модели» в /admin#assistant: тянет live-список со шлюза
провайдера, отбирает бесплатные чат-модели (музыка/картинки/модерация
отсекаются), прогоняет каждую тест-запросом на русском и показывает отчёт
(новые / исчезнувшие / % кириллицы / скорость). «Применить выбранные»
сохраняет список в app_settings (assistant_kilo_models); хардкод KILO_MODELS
остаётся сидом, есть «Вернуть встроенный список».

Backend: scanModels/probeModel/applyModels (admin-only роуты), _kiloModels()
делает список динамическим. Переиспользует _fetchModels. Клиент: adminAssistantScan/Probe/ApplyModels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:18:04 +03:00
Maxim Dolgolyov dc5501d723 fix(assistant): актуализирован список бесплатных моделей Kilo
Сверено с live-списком шлюза kilocode.ai + протестировано на русский (2026-06-24):
- удалён исчезнувший со шлюза nex-agi/nex-n2-pro:free
- исправлены устаревшие лимиты: nemotron-super ctx 1M->262K, owl-alpha опечатка 1048756->1048576
- добавлен openrouter/free (авто-роутер, 100% русский, устойчив к пропаже отдельных моделей)
- дефолтный пресет Kilo: ultra-550b (таймаутит) -> super-120b (быстрый, чистый русский)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:04:13 +03:00
Maxim Dolgolyov 4c1ce8394c feat(trigcircle): шкала значений по оси Y на графике (координатная плоскость)
- y-ось графика теперь подписана значениями (KaTeX):
  sin/cos — 1, ½, 0, −½, −1; tg/ctg — 3, 2, 1, 0, −1, −2, −3
- пунктирные линии уровней + подписи слева от панели
- подписи прячутся при смене функции (лишние уровни tg/ctg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:44:29 +03:00
Maxim Dolgolyov 0640efc82c feat(trigcircle): развёртка угла на графике + KaTeX-подписи граф-вида
- развёртка: участок кривой [0, α] выделяется ярче (с свечением) —
  видно, как угол на окружности «разворачивается» в график
- подпись текущего угла (π/3 и т.п.) на вертикальном маркере, KaTeX
- подписи делений оси X (π/2, π, 3π/2, 2π) — теперь KaTeX-оверлеем
- название функции (y = sin x / cos x / tg x / ctg x) — KaTeX-оверлеем
- _ovLabel: любая LaTeX-команда (\pi, \sin…) теперь рендерится через KaTeX

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:35:28 +03:00
Maxim Dolgolyov 7562d1a77b fix(trigcircle): координатная подпись больше не перекрывает угол
Координатный тултип (cos; sin) выносится радиально НАРУЖУ за точку (вдоль луча от центра),
а не просто со смещением — так KaTeX-плашка значений всегда дальше от центральной дуги угла
и её подписи (π/3 и т.п.), наложения нет.

Verified: node --check; смоук — coord-подпись дальше от центра, чем сама точка.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:08:09 +03:00
Maxim Dolgolyov 1707a510a9 feat(trigcircle): KaTeX-оверлей для подписей на canvas (координаты, значения, угол)
На <canvas> KaTeX не рисуется (fillText), поэтому подписи, которые были юникод-текстом
(√2/2, координаты точки, π/4, значение на графике), переведены на HTML-оверлей #trig-overlay
поверх холста с KaTeX-рендером и точным позиционированием (transform по CSS-px = координаты
canvas). Переведены: координатная подсказка (cos; sin), бейджи значений sin/cos, метка угла
у дуги, бейдж значения на графике. Подписи-слова sin/cos/tg/ctg и мелкие точки табличных
углов остаются на canvas (не математика / 16 мелких меток).

Механика: _ov/_ovLabel/_ovClearUnused — кэш по ключу (ре-рендер только при смене LaTeX),
KaTeX лишь для дробей/корней, простые числа — текстом (быстро при перетаскивании), неис-
пользованные за кадр подписи прячутся. Старые canvas-методы _badge/_tooltip больше не зовутся.

Verified: node --check; headless-смоук оверлея 12/12 (coord/vsin/vcos/angle/gval создаются,
KaTeX-LaTeX для √2/2 и π/4, позиционирование/плашка, десятичные как текст, скрытие при
выкл. слоя/графика). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:03:39 +03:00
Maxim Dolgolyov e70bf819ce docs(trigcircle): статус — Ф1–Ф6 + KaTeX-везде готовы (Ф3 уже был)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:52:44 +03:00
Maxim Dolgolyov 48158ea88d feat(trigcircle): Фаза 5 — чётность/нечётность (−α) + периоды
Тумблер «Чётность (−α)»: на окружности рисуется зеркальная точка −α (отражение через
ось Ox, пунктир P↔−α) — наглядно нечётность sin и чётность cos. Блок-справка на KaTeX
(строится один раз): sin(−α)=−sin α, cos(−α)=cos α, tg(−α)=−tg α, периоды
T_sin=T_cos=2π, T_tg=T_ctg=π. (Формулы приведения для текущего угла — уже Фаза 2.)

Аддитивно: this.showParity + _drawParity + хук в draw(); glue trigToggleParity;
тумблер + #trig-parity в панели.

Verified: node --check; headless-смоук 9/9 (_drawParity без throw для 30/150/210/300;
toggle строит блок один раз с верными тождествами+периодами, показ/скрытие). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:51:55 +03:00
Maxim Dolgolyov fe6df8fb98 feat(trigcircle): Фаза 4 — таблица значений (особые углы, KaTeX)
Тумблер «Таблица значений» → компактная таблица первой четверти (0/30/45/60/90°)
с точными sin/cos/tg/ctg на KaTeX (строится один раз). Строка опорного острого угла
текущего положения подсвечивается (150° → подсвечена строка 30°). По симметрии/приведению
(Фаза 2) это покрывает все 16 углов.

Glue: _trigBuildValueTable (один раз) + trigToggleTable; подсветка строки в _trigUpdateUI
по refDeg. Панель: тумблер + #trig-table.

Verified: node --check; headless-смоук 10/10 (5 строк, заголовки, значения 30/45/90,
tg «не опр.», toggle показ/скрытие). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:49:33 +03:00
Maxim Dolgolyov 244df71aec feat(trigcircle): вся математика панели на KaTeX (значения + угол)
Значения sin/cos/tg/ctg в панели и стат-баре теперь рендерятся KaTeX для дробей/корней
(\tfrac{1}{2}, \tfrac{\sqrt{3}}{2}, …), а простые числа (0, ±1, десятичные) — текстом
(быстро при перетаскивании, без лишних KaTeX-вызовов). Бейдж угла — KaTeX π-доли по
таблице 16 углов (150° = 5π/6, 210° = 7π/6, …) + радианы + котерминальные.

Хелперы: _tex (общий рендер с фолбэком), _angleLatex/_piLabelToLatex (рад → LaTeX π-доли),
setMathVal (KaTeX только для нетривиальных форм). Формулы значений/приведения и уравнений
уже были на KaTeX.

Verified: node --check; headless-смоук 9/10 (10-я — артефакт стаба: в реальном DOM
textContent= очищает прежний innerHTML; логика LaTeX верна). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:47:30 +03:00
Maxim Dolgolyov 5bb0aeb940 docs(trigcircle): отметка статуса фаз (Ф1/Ф2/KaTeX/Ф6 готовы)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:41:37 +03:00
Maxim Dolgolyov dfa0535b63 feat(trigcircle): Фаза 6 — простейшие тригонометрические уравнения
Режим уравнения fn(x)=a (sin/cos/tg): окружность подсвечивает ВСЕ решения на [0,2π)
(точки + направляющая линия значения), а панель показывает общую формулу через KaTeX:
  sin x=a → x=(-1)ⁿ·arcsin a + πn;  cos x=a → x=±arccos a + 2πn;  tg x=a → x=arctg a + πn.
Для табличных значений главное значение подставляется точно (arcsin½=π/6 и т.п.), для
нетабличных — символьно (\arcsin a). |a|>1 для sin/cos → «нет решений». Список решений
в градусах. setEquation встаёт на первое решение; clearEquation выходит из режима.

Аддитивно: новое поле this.eq + методы setEquation/clearEquation/_drawEquation + хук в draw();
glue trigSetEqFn/trigSolve/trigClearEq/trigEqKey; секция «Уравнение» в панели labs-bodies.

Verified: node --check; headless-смоук 13/13 (решения sin/cos/tg/один/нет; формулы
(-1)ⁿ/±/+πn/none/нетабличное→arcsin) + изолированная отрисовка _drawEquation без throw.
Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:41:07 +03:00
Maxim Dolgolyov cefb5e0836 feat(trigcircle): рендер формул через KaTeX
Блок «Точные значения · приведение» теперь рендерится KaTeX (katex.renderToString,
как в graph.js/_sim_engine), с фолбэком на сырой LaTeX если katex ещё не загрузился.
Добавлен _latexVal (точное значение → LaTeX: \tfrac{1}{2}, \tfrac{\sqrt{3}}{2}, …),
функции \sin/\cos/\operatorname{tg}/\operatorname{ctg}, цвет наследуется от CSS-цвета
контейнера (без \textcolor). Формула приведения и значения — те же (проверено).

Verified: node --check; headless-смоук LaTeX-вывода (дамп + проверки) — 150°=180°−30°
sin=½ cos=−√3/2 tg=−√3/3; 45° без приведения √2/2; 90° tg «не опр.»; 210°=180°+30°;
300°=360°−60°; 137° нетабличный. Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:35:25 +03:00
Maxim Dolgolyov 5eed248702 feat(trigcircle): Фаза 2 — точные значения + формулы приведения
При исследовании выяснилось: Пифагор (sin²+cos²=1, _pythBar) и знаки по четвертям
(_quadSigns) уже рисуются на canvas. Поэтому Фаза 2 даёт главное недостающее по программе —
блок «Точные значения · приведение»: для текущего угла показывает sin/cos/tg/ctg точными
значениями (½, √2/2, √3/2, √3/3, √3) и для нетривиальных четвертей — формулу приведения
к острому углу (напр. 150° = 180°−30°, cos 150° = −cos 30° = −√3/2). Нетабличный угол →
сообщение. Без KaTeX (чистый HTML + готовый форматтер _f), без новых зависимостей.

Verified: node --check; headless-смоук рендера 11/11 (150° приведение+знаки, 45° QI без
головы, 210° QIII tg+, 137° нетабличный). Эмодзи нет.

sec/csc (5-я/6-я функции) — вторичны для школьной программы, отложены (предложу опционально).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:30:02 +03:00
Maxim Dolgolyov d395e1083b feat(trigcircle): Фаза 1 — работа с углами + обзор (тренажёр тригонометрии)
План тренажёра в plans/trig-circle/PLAN.md (всё по теме на окружности, кроме графиков функций).
Фаза 1 (аддитивно к рабочему режиму):
- Ввод угла в градусах (поле + Enter/кнопка) → goToAngle (нормализует, показывает
  котерминальность). Подсказка «+360°·k» в бейдже угла.
- Тумблер «График/функции» — скрыть график (тема «функции») → круг на всю ширину
  (переиспользует существующий слой graph + _layout).
- Полная сетка табличных углов (16: 0…330°) вместо 8.
- Опорный (острый) угол к оси Ox в выводе (основа формул приведения) + знаки sin/cos/tg
  по текущей четверти. stats() расширен полями refAngle/refDeg.

Verified: node --check; headless-смоук (vm + canvas-Proxy) 9/9 — опорный угол 30/150/210→30°,
300→60°, 90→90°, 0→0; знаки по четвертям (II: sin+ cos− tg−; IV: sin− cos+); новые
глобальные glue-функции определены. Эмодзи нет (стрелка — inline SVG .ic, tg-неопр. — em-dash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:23:31 +03:00
Maxim Dolgolyov 40df8893cc fix(lab): значок «связанной симуляции» на карточках учебников не скрывался при выключенной лаборатории
В каталоге учебников (textbooks.html) у карточек есть кнопка .tb-lab-btn «открыть
связанную симуляцию» (openLabSim → /lab?sim=…). Это <button onclick>, а не <a href="/lab">,
поэтому kill-switch `[href="/lab"]` её не ловил, и значок-колба оставался при отключённой
«Лаборатории».

Фикс: добавил `.tb-lab-btn` в FEATURE_WIDGETS.lab → api.js скрывает её через инъекцию
при lab=false (работает и без ls.css). Плюс страховка в openLabSim: при lab=false не
открываем (тост «Лаборатория отключена»); админ — всегда (admin-override).

Verified vm-смоук на реальном api.js 4/4 (lab off → .tb-lab-btn скрыта; lab on → нет;
admin → ничего). node --check api.js + инлайн textbooks.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:36:11 +03:00
Maxim Dolgolyov 8027d9fda0 fix(gamification): kill-switch не доходил до учебников (нет ls.css)
Учебники (frontend/textbooks/*.html, 112 шт.) грузят api.js, но НЕ ls.css. api.js ставил
класс .no-gamification на <html>, но сами правила kill-switch (`[data-gamified]`, попап XP)
живут в ls.css → до учебников не доходили, и встроенная XP-механика (бейдж/карточка XP,
ачивки, level-up попап #ach-popup) продолжала отображаться при выключенной геймификации.

Фикс — централизованно в _applyFeatureCss: при gamification=false дублируем правила
`.no-gamification [data-gamified],#ach-popup{display:none}` в инъектируемый <style>, поэтому
kill-switch работает на ЛЮБОЙ странице с api.js, без ls.css. Плюс на страницах без сайдбара
(учебники/embed) теперь авторитетно дёргаем loadFeatures() (только для залогиненных,
in-memory-дедуп) — кэш фич там не обновлялся. Админ по-прежнему видит всё (admin-override).

Verified vm-смоук на реальном api.js 7/7 (student+off → правило инъектится; student+on → нет;
admin → ничего).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:25:45 +03:00
Maxim Dolgolyov 43df41287f feat(errors): сбор клиентских (браузерных) ошибок в админ-вкладку «Ошибки»
Глобальный репортер в api.js (грузится на всех страницах) ловит необработанные JS-ошибки
(window 'error') и rejected-промисы ('unhandledrejection') в браузере пользователя и шлёт
в POST /api/client-errors. Дедуп по сигнатуре + лимит 15/страницу, только для залогиненных,
keepalive, не флудит и сам не падает.

Бэкенд: routes/clientErrors (auth + rate-limit 20/мин на юзера) → clientErrorController
пишет в общий error_log с level='client' (message/stack/route=url/method=kind/user_id),
поля обрезаются. Появляются в существующей админ-вкладке «Ошибки» с бейджем «БРАУЗЕР»
(фиолетовый акцент vs розовый у серверных). Тест client-errors.test.js 5/5.

lint:routes 0; node --check всех файлов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:17:04 +03:00
Maxim Dolgolyov db1db68488 fix(wishes): TypeError в toggleForm — lucide заменял <i> на <svg>
Кнопка «Поделиться идеей» падала: btn.querySelector('i') возвращал null, т.к. lucide.createIcons
при первом рендере заменяет <i data-lucide> на <svg>. Обернул иконку в стабильный
контейнер #wq-new-ic и пере-вставляю свежий <i> в его innerHTML перед icons() (с guard).

Headless-смоук toggleForm 5/5 (open/close, смена иконки chevron-up/plus, без throw).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:02:54 +03:00
Maxim Dolgolyov 3c45c606bf feat(admin/tests): пикер вопросов — серверный поиск по всему банку + «Показать ещё» + фильтры
Раньше «Добавить вопросы» в конструкторе тестов грузил лишь первые 100 вопросов предмета
(дефолтный лимит API), а поиск фильтровал клиентски только эти 100 — для математики
(1753 вопроса) 1653 были недоступны и не находились.

Теперь пикер ходит на сервер (бэкенд уже умеет q/difficulty/type/page): поле поиска
(debounce 300мс) ищет по ВСЕМУ банку предмета; кнопка «Показать ещё» подгружает
страницами по 100 с индикатором «Показано N из total»; добавлены фильтры по сложности
и типу. Поиск/фильтры сохраняются между перерисовками (после добавления вопроса).

Чистый фронтенд (tests.js + CSS в admin.html); бэкенд не тронут. Verified:
backend list q/difficulty/type/paging 8/8; headless-смоук пикера 12/12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:17:17 +03:00
Maxim Dolgolyov 1649d6c2ec fix(admin): список сессий показывает и незавершённые (зависшие), +фильтр ?status
Корень бага «зависший тест есть в алерте, но нет в списке»: getAllSessions жёстко
фильтровал WHERE status='completed' → in_progress (зависшие) сессии в списке /admin#sessions
не появлялись. Теперь по умолчанию показываются все статусы (UI sessions.js уже null-safe:
percent/score/duration рисуются как «—»), плюс опциональный ?status=completed|in_progress|abandoned
для сужения. Зависшая сессия теперь находится и в списке, и открывается по deep-link из алерта.

admin-sessions.test.js 4/4; node --check OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:14:56 +03:00
Maxim Dolgolyov 4b5be8442b fix(admin): «Открыть» зависшей сессии ведёт на её детали, а не в пустой список
Алерт «Зависла» (in_progress >1ч) вёл на /admin#sessions, но список сессий показывает
ТОЛЬКО completed (getAllSessions: WHERE status='completed') — поэтому зависшей сессии там
не было (симптом: «показывает зависший тест, но в списке его нет»). Теперь «Открыть»
делает deep-link на детали конкретной сессии /admin#sessions/<id> — страница деталей
открывает сессию при любом статусе (getSessionDetail без фильтра по статусу) и позволяет
её посмотреть и удалить.

node --check OK; id присутствует в payload overview (stuckSessions → ts.id).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:02:53 +03:00
Maxim Dolgolyov 3898080f04 fix(features): админ открывает отключённые модули — пейдж-гейты уважают admin-override
Причина бага «из админа конструктор симуляций редиректит на дашборд»: у sim-builder.html
свой пейдж-гейт, который при feature_sim_builder=false уводил на /dashboard НЕЗАВИСИМО от
роли (мой прошлый admin-override был только в hideDisabledFeatures, а этот гейт его не знал).

Тот же недочёт нашёлся ещё у 3 страниц с собственным фича-редиректом (на /403):
collection.html, knowledge-map.html, red-book.html. Во все 4 добавил обход для админа
(админ управляет модулями → видит и открывает всё, даже отключённое) — согласно правилу
admin-override. Поведение для ученика/учителя не изменилось.

node --check инлайна всех 4 страниц — OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:59:51 +03:00
Maxim Dolgolyov efba722977 feat(wishes): редизайн страницы — удобнее и красивее
Полный фронт-редизайн /wishes (бэкенд не тронут):
- Hero с градиентной иконкой; «Поделиться идеей» — сворачиваемая форма (по умолчанию
  свёрнута, если пожелания уже есть; список сразу виден).
- Визуальный выбор категории чипами с иконками/цветом вместо select; счётчик символов.
- Статус-пилюли вверху с counts — кликабельный фильтр (для всех ролей, не только админ).
- Подбар: фильтр по категориям + живой поиск (по заголовку/тексту/автору); адаптивно
  скрывается, когда мало данных.
- Карточки: цветная иконка категории, статус-бейдж с иконкой, ответ админа в выделенном
  блоке, анимация появления, hover. Дружелюбные empty-состояния (нет идей / ничего не найдено)
  и скелетоны при загрузке.
- Клиентская фильтрация (один fetch, мгновенно) + точечные обновления списка без перезагрузки
  после создания/сохранения/удаления.

Verified: рендер-смоук 13/13 (карточки, иконки категорий, статусы, ответ, фильтры
status/cat/поиск с тогглом, empty); node --check инлайна; эмодзи нет (иконки — lucide).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:26:32 +03:00
Maxim Dolgolyov be9fdfa703 feat(wishes): трекер пожеланий по улучшению системы
Любой авторизованный пользователь подаёт пожелание (заголовок, категория, описание);
видит только свои. Админ видит все, фильтрует по статусу, ведёт по статусам
(новое → запланировано → в работе → готово / отклонено) и пишет ответ автору. Автор
получает уведомление при смене статуса (pushNotif).

Бэкенд: миграция 080 (таблица wishes), wishController (list/create/update/remove с
валидацией и whitelist категорий/статусов), routes/wishes (PATCH — только админ, DELETE —
автор«новое»/админ, проверка в хендлере), смонтировано в server.js. Тесты 15/15.

Фронт: страница /wishes (форма + список со статус-бейджами; у админа — фильтры,
смена статуса, ответ, удаление), пункт «Пожелания» в сайдбаре (все роли), фиче-флаг
feature_wishes_enabled (тумблер в админ-модулях + whitelist + FEATURE_HREFS; админ
видит всегда). Клиентские врапперы LS.wish*.

⚠️ Живой БД нужен npm run migrate (080). lint:routes 0; node --check всех файлов + инлайна.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:12:10 +03:00
Maxim Dolgolyov 758e1bf6cb feat(dashboard): статус «идёт онлайн-урок» с присоединением
На дашборде ученика/учителя — баннер активной classroom-сессии: заголовок урока,
для учителя «N онлайн», для ученика «Присоединиться/Вернуться», ссылка на /classroom
(там сессия подхватывается автоматически). Данные — LS.crGetMySession (учитель → своя
сессия, ученик → сессия его класса/приглашения). Нет активной сессии → баннер скрыт.

Доска работает по WebSocket, дашборд — по SSE, поэтому добавлен отдельный SSE-сигнал
classroom_live (state started/ended) ученикам класса/приглашённым/учителю в createSession
и endSession (аддитивно, в try/catch — не ломает создание/завершение сессии). Баннер
живо появляется/исчезает по этому событию + обновляется при возврате на вкладку.

Verified: рендер баннера 10/10 (ученик/учитель/нет сессии, online-счёт без вышедших,
пустой title→«Онлайн-урок»); node --check sessions.js + инлайна dashboard; sse-путь резолвится.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:24:14 +03:00
Maxim Dolgolyov 0d4c658d93 refactor(assignments): единый модуль assignment-utils.js (тип/«сдано»/срочность)
Логика классификации типа задания и статуса «сдано» дублировалась в трёх местах
(dashboard.html, homework.html, assignmentController.js) и начала расходиться. Вынес в
один UMD-модуль frontend/js/assignment-utils.js (грузится и в браузере, и в Node через
require — как svg-sanitize.js): type(a), isDone(a, sub, opts), urgencyScore(a).

Нюанс «сдано» для upload/file параметризован: вид ученика (acceptedOnly) — закрыто только
при принятой сдаче; учитель/обзор долгов — любая сдача не на доработке. Поведение всех трёх
поверхностей сохранено 1:1.

- homework.html: asgnType/asgnDone/urgencyScore → тонкие делегаты в AssignmentUtils.
- dashboard.html: urgencyScore делегирует; classify и upload-ветка buildAssignCard через
  AssignmentUtils.type/isDone (заодно корректнее: учебник-ДЗ больше не путается с upload).
- assignmentController: classOutstanding/_assignTypeOf → AssignmentUtils.

Verified: AU-контракт 25/25 (типы, isDone teacher vs acceptedOnly, порядок urgency);
интеграция 8/8 (classOutstanding те же 14 уч./42 просрочено; homework делегирует). node --check
всех файлов + инлайна обоих HTML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:09:34 +03:00
Maxim Dolgolyov 5a4bc48027 feat(classes): вкладка «Долги» — что висит у учеников + удаление ДЗ класса/ученика
Новый read-only эндпоинт GET /api/classes/:id/outstanding (teacher/admin, ownership):
по каждому ученику класса — его незакрытые задания (классовые + личные от этого учителя)
со статусом не начато / в процессе / на доработке / просрочено и дедлайном. Логика «сдано»
совпадает с /homework (тест — завершён/исчерпаны попытки; учебник — всё прочитано;
загрузка/файл — есть сдача не на доработке). Общий запрос вынесен в assignmentRowsForUser(uid)
— им же теперь питается /assignments/my (поведение не изменилось, +поле created_by).

Фронт (classes.html): вкладка «Долги» в карточке класса — сводка «должников X из Y,
просрочено Z», по каждому должнику карточка со статус-чипами и списком зависших заданий;
бейдж с числом просрочек на вкладке. Удаление прямо из списка: личное → «у ученика»,
классовое → «у всего класса» (через DELETE /assignments/:id, ownership на бэке).

Verified: classOutstanding смоук на живой БД (14 учеников/58 позиций/42 просрочено,
ownership 403/404/admin); node --check; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:46:45 +03:00
Maxim Dolgolyov 73ba5a3530 fix(homework): блок ДЗ — только задания с флагом is_homework; ясная подпись типа
(1) На /homework блок «Актуальные задания» и выпадашка загрузки теперь показывают
только задания с флагом ДЗ (is_homework). Обычные тесты/экзамены и не-ДЗ файлы сюда
не попадают. Выпадашка привязки — только типы upload/file (куда ученик реально сдаёт
файл), без тест-ДЗ и учебников.

(2) В конструкторе задания (classes.html) вкладка типа «Сдать работу» → «Загрузка
работы»: остальные вкладки — существительные-типы контента (Файл, Готовый тест), а
«Сдать работу» читалась как действие ученика. Это тип upload — ДЗ, куда ученик грузит
файл (saveAssignment: is_homework=1, count=1); сам тип нужен, поправлена только подпись.

Headless-смоук фильтра ДЗ 6/6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:08:57 +03:00
Maxim Dolgolyov a7f2ae9937 fix(features): админ видит и открывает все модули, даже отключённые
Скрытие фич в сайдбаре (и kill-switch геймификации) применялось независимо от роли —
у админа тоже пропадали пункты отключённых модулей. Админ управляет модулями, поэтому
должен видеть и открывать всё.

Добавлен admin-override (_isAdminUser, читает getUser() синхронно из localStorage —
работает и на ранней FOUC-инъекции): для админа _applyFeatureCss чистит <style>-скрытие
и снимает .no-gamification; hideDisabledFeatures выходит до любых скрытий/редиректов;
showBoardIfAllowed показывает доску админу всегда. Поведение для student/teacher не
изменилось. Verified vm-смоук на реальном api.js 10/10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:03:09 +03:00
Maxim Dolgolyov 748b0aaab1 feat(homework): блок «Актуальные задания» на странице /homework
Раньше страница «Домашние задания» показывала только историю сдач, а сам список
актуальных ДЗ (что нужно сделать, с дедлайнами) жил лишь на дашборде. Теперь у ученика
сверху секция «Актуальные задания» на тех же данных (LS.myAssignments) — карточки с
дедлайном/просрочкой/срочностью, действие по типу задания: тест → Начать/Продолжить
(LS.startAssignment), учебник → Открыть §, файл → Скачать, ДЗ-загрузка → Сдать
(прокрутка к области загрузки + преднабор задания). Закрытые задания (пройденный тест,
прочитанный учебник, принятая работа) скрыты; пустая секция не показывается.

Заодно убрано ограничение «только первый класс» в выпадашке загрузки: задания берутся
по всем классам, а класс для отправки выводится из выбранного задания (data-class) —
чинит сдачу для учеников в нескольких классах.

Чисто фронтенд, аддитивно. Headless-смоук рендера 19/19.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:57:50 +03:00
Maxim Dolgolyov 22c7b38e9a feat(admin): сброс системы «чистый запуск» в веб-панели
Добавлено такое же действие, как [Z] в control-panel: POST /api/admin/reset-system
(+ /reset-system/plan для предпросмотра), только admin. Общая логика вынесена в
src/services/systemReset.js (classify/pickKeptAdmin/runReset) — реюзится CLI и эндпоинтом.

Веб-эндпоинт безопаснее CLI: сохраняет ТЕКУЩЕГО админа (оператор остаётся залогинен),
делает бэкап БД ДО сброса (wal_checkpoint + копия в data/backups/), требует body.confirm='СБРОС'.
UI — «Опасная зона» в overview-секции: предпросмотр плана + ввод «СБРОС» + результат с именем бэкапа.

db.js: добавлен db._path (нужен бэкапу при сбросе). Логика проверена смоуком на копии живой БД
(16 юзеров удалено, контент сохранён, REASSIGN на админа, гейм-счётчики обнулены, 0 висячих FK).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:45:13 +03:00
Maxim Dolgolyov 205290139d feat(control-panel): сброс системы «чистый запуск» (с бэкапом и подтверждением)
Пункт [Z] в control-panel.ps1: предпросмотр → бэкап БД → подтверждение вводом
«СБРОС» → очистка. Скрипт backend/scripts/reset-system.js (dry-run по умолчанию,
выполнение только с --apply --confirm=RESET):
• сохраняет одного админа (min id), переназначает ему авторский контент
  (courses/tests/flashcard_decks/custom_sims/шаблоны/библиотека/lab-ссылки/board);
• стирает всех остальных пользователей + классы/задания/сессии/геймификацию/
  уведомления/прогресс/историю классрума/доступы/логи;
• сохраняет контент: учебники, вопросы, темы, уроки, exam-prep, симуляции,
  биохимия, красная книга, магазин/достижения-определения, роли/права, app_settings;
• обнуляет игровые счётчики админа; классифицирует ВСЕ 119 таблиц, неизвестные не трогает;
• FK off + транзакция + foreign_key_check + VACUUM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:32:44 +03:00
Maxim Dolgolyov c6d323ec6d feat(tests): витрина доступных тестов ученику + флаг «доступен ученикам»
Раньше ученик видел лишь 1 тест на предмет (дефолтный). Теперь учитель/админ
может пометить любой свой тест доступным, и он появляется в каталоге на дашборде.

- Миграция 079: tests.available_to_students (default 0).
- testController: list для ученика отдаёт тесты с available_to_students=1 и вопросами;
  create/update принимают флаг; update сделан частичным (не затирает поля при toggle).
- admin «Тесты»: бейдж «Доступен ученикам» + быстрый тумблер «Ученикам/Скрыть»
  (toggleTstAvail; конструктор доступен и учителям — видят свои тесты).
- Дашборд: виджет «Тесты» → секция «Доступные тесты» (loadAvailableTests), клик
  запускает фикс-тест. Прячется, если доступных нет.

⚠️ Живой БД нужен npm run migrate (колонка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:42 +03:00
Maxim Dolgolyov c5d440a7a9 fix(tests): режимы доступных тестов только exam/practice + скрытие пустых предметов
Рассогласование: админ-настройка допускала режимы topic/random, но POST /api/sessions
принимает только exam/practice → клик по такому предмету падал с 400. Убрал topic/random
из валидатора subjects.js и из админ-дропдауна (SC_MODES). Дашборд: старые значения
topic/random коэрсятся в practice; предметы без вопросов в банке И без фикс-теста больше
не показываются (раньше давали 404 «No questions found» при запуске).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:53:43 +03:00
Maxim Dolgolyov 1aa95a6776 fix(dashboard): hero «Лаборатория дня» виден при выключенной лабе
Hero-карточка #hc-lab имела href="/lab", но loadLabOfDay меняет его на
/lab?sim=<id> → CSS [href="/lab"] больше не матчит, карточка оставалась видной.
Прячем по стабильному id: #hc-lab/#hc-pet/#hc-read добавлены в FEATURE_WIDGETS
(lab/pet/textbooks). .hero-row переведён на grid auto-fit (minmax 240) — сетка сама
подстраивается под видимые карточки без дыры; syncHeroRow прячет весь ряд, если
карточек не осталось (мобайл-медиазапрос не трогаем — без инлайн-колонок).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:37:41 +03:00
Maxim Dolgolyov 399a222b65 fix(dashboard): пустой бокс колонки прогресса, когда флешкарты отключены
#w-flashcard прятался, но он — секция внутри #w-progress-col (один .widget-бокс с
рамкой/паддингом: карточка + прогресс по предметам + результаты). Если все секции
скрыты (флешкарты выкл и нет данных), оставался пустой бокс. Добавлена
syncProgressCol(): прячет #w-progress-col, если ни одна секция не видна (computed-
display, учитывает и инъект-CSS флешкарт). Зовётся в конце loadFlashcardWidget /
loadLastResultsWidget / loadSubjProgressWidget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:26:33 +03:00
Maxim Dolgolyov 796a2416cb chore(admin): секция «Игры» → «Модули» (там уже не только игры)
Вкладка/заголовок админ-панели переименованы: nav «Игры»→«Модули» (иконка
gamepad-2→layout-grid), section-title «Управление играми»→«Управление модулями»,
описание про «отключённые игры»→«отключённые модули». В секции теперь лаба,
теория, путеводитель, доска, классрум и т.д. — не только игры. Free-student
подсекция уже звалась «Модули…» — консистентно.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:11:09 +03:00
Maxim Dolgolyov 604fa7ac0b fix(sidebar): убрать мигание ссылок «Подготовка к экзамену» при отключении
Ссылки exam-prep (/exam-prep/*) скрывались отдельным async-механизмом (/api/exam-prep/
tracks), не входящим в синхронный кэш-CSS → мелькали на долю секунды после обновления.
Теперь hideDisabledFeatures кэширует точные хрефы скрытых треков в localStorage
(ls_examhide), а _applyFeatureCss добавляет их в инъект-CSS синхронно из кэша на ранней
загрузке (до сборки сайдбара). При включении трека он убирается из кэша → снова виден
(re-apply _applyFeatureCss после свежего fetch). hideEmptySidebarGroups перенесён в конец
hideDisabledFeatures (учитывает скрытие exam-prep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:56:12 +03:00
Maxim Dolgolyov 38f8be9389 feat(features): тумблер «Путеводитель» (/sitemap)
Пункт «Путеводитель» теперь отключается как остальные модули: ключ sitemap в
вайтлист updateFeatures (backend) + тумблер в admin → фичи (GAME_FEATURES) +
/sitemap,/sitemap.html в FEATURE_HREFS (скрытие из сайдбара + редирект при выкл).
По умолчанию ВКЛ (opt-in disable). Пустая группа схлопнётся авто (hideEmptySidebarGroups).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:51:36 +03:00
Maxim Dolgolyov c04a8c2178 fix(sidebar): прятать пустые группы (заголовок без видимых пунктов)
Когда все пункты группы сайдбара скрыты (фичи отключены / teacher-only у ученика),
оставался висеть пустой заголовок-аккордеон (напр. «Практика и игры»). Добавлена
hideEmptySidebarGroups(): по .sb-group проверяет computed-display пунктов .sb-link
в теле и прячет группу, если ни одного видимого. Зовётся синхронно из sidebar.js
после сборки (по кэш-CSS — без мигания) и в конце hideDisabledFeatures (по свежим
данным; re-show при включении).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:49:51 +03:00
Maxim Dolgolyov 83f0ba9c04 fix(features): пустой блок флешкарт, лаба в сайдбаре, мигание (FOUC)
Три проблемы UX отключения модулей:
1) Пустой блок флешкарт на дашборде: виджет #w-flashcard — <div>, а скрытие шло
   только по [href]. Добавлена карта FEATURE_WIDGETS (флешкарты→#w-flashcard) —
   контейнер прячется целиком.
2) Лаборатория не уходила из сайдбара: не было ГЛОБАЛЬНОГО тумблера lab (только
   free-student). Добавлен в whitelist updateFeatures + GAME_FEATURES; map уже знал
   lab→/lab. Теперь выключение скрывает пункт и редиректит со страницы.
3) Мигание выключенных модулей (FOUC): hideDisabledFeatures асинхронный. Теперь
   loadFeatures кэширует /api/features в localStorage, а _applyFeatureCss инъектит
   <style id=ls-feat-hide> синхронно из кэша на ранней загрузке (api.js идёт до
   sidebar.js) — сайдбар/виджеты строятся уже скрытыми. Геймификация: класс
   no-gamification ставится на <html> (раньше body), ls.css правило body.no-gamification
   → .no-gamification (работает и для html, и для body).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:41:11 +03:00
Maxim Dolgolyov d5fbd0168e feat(permissions): +10 прав ролей с энфорсом (Доступ · роли)
Реестр (registry.js) пополнен правами, которыми раньше нельзя было управлять:
• Учитель: classroom.host (онлайн-уроки), livequiz.host (живые викторины),
  simbuilder.use (конструктор симуляций), flashcards.manage (общие колоды).
• Ученик: homework.submit (сдача ДЗ), materials.save («Мои материалы»),
  assistant.use (ИИ-ассистент), games.play (учебные игры),
  flashcards.access / exam.access (доступ к разделам).
Все default=1 → текущее поведение сохранено; админ может выключить по роли/классу/юзеру.

Энфорс на роутах: учительские — requirePermission (роуты уже teacher-only);
ученические на ОБЩИХ роутах (assistant/materials/games/flashcards/exam-prep) —
новый requirePermissionForStudents(key) (учитель/админ проходят всегда, проверка
только ученику — иначе isEnabled=false сломал бы учителя). PERM_DEFAULTS строится
из реестра → фолбэк до сидирования = enabled, никто не блокируется. Группы UI —
существующие (новых ярлыков нет). seedDefaults авто-сидит новые ключи на чтении.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:31:00 +03:00
Maxim Dolgolyov 54be84e74a fix(admin): глобальный мастер-тумблер «Геймификация» в админ-UI
Геймификация (gamification) была только в FS_FEATURES (free-student grid) — UI
позволял выключить её лишь для роли «свободный ученик», а ГЛОБАЛЬНОГО тумблера в
GAME_FEATURES не было, хотя бэкенд флаг feature_gamification_enabled и kill-switch
это поддерживают. Добавлен в GAME_FEATURES как «Геймификация (всё)» — теперь админ
может выключить XP/монеты/ачивки/магазин/стрики/лидерборд глобально из UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:08:59 +03:00
Maxim Dolgolyov dc71d7b4d9 fix(gamification): полнота kill-switch — испытания/стрик/монеты + гейт счётчиков
Аудит выключателя геймификации выявил элементы, НЕ покрытые body.no-gamification:
испытания недели (#ch-section/.ch-widget), календарь стриков (.streak-cal),
стат-кольцо стрика (#sr-streak), монеты в профиле (#p-coins-row), чипы стрик/цель
на карточке питомца. Добавлены в CSS kill-switch (ls.css). Бэкенд: updateChallenges
и onLabExperiment писали прогресс/счётчики без проверки флага — добавлен гейт
isGamificationEnabled() (XP/coins/achievements уже гейтились в award*-функциях).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:04:30 +03:00
Maxim Dolgolyov d8f2a7f98d fix(features): доска уходит из сайдбара при отключении + тумблер «Теория»
1) Доска при feature_board_enabled=0 не пропадала у учителя/админа: showBoardIfAllowed()
   зовётся напрямую на ~20 страницах и показывала доску БЕЗ проверки флага, перекрывая
   hideDisabledFeatures(). Теперь функция сперва грузит features и при board===false
   держит ссылку скрытой (для всех ролей).
2) Добавлен фич-флаг theory: ключ в вайтлист updateFeatures (backend), тумблер «Теория»
   в admin → games (GAME_FEATURES), и /theory,/theory.html в map hideDisabledFeatures
   (скрытие из сайдбара + редирект с /theory при выключении). По умолчанию ВКЛ (opt-in disable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:56:22 +03:00
Maxim Dolgolyov 9d35aaf673 fix(admin/access): нативные confirm() → стилизованная модалка LS.confirm
Раздел «Доступ · контент» использовал браузерный confirm() («Подтвердите
действие на localhost…») для закрытия доступа у всех классов и копирования.
Заменены все 5 вызовов на LS.confirm (та же модалка, что в остальном админ-
разделе): «Закрыть доступ» (danger) и «Скопировать доступ» (danger:false).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:48:24 +03:00
Maxim Dolgolyov bd7dd06e47 fix(exam-prep): репаратор рендеринга ctmath — потерянные \ в опциях + <,> в $…$
Root cause: в seed-ах вариантов 101–121 опции писались как mc('$\sqrt..$') в
обычных кавычках вместо R`…` → JS-парсер съедал \s→s, \d→d и т.п., в БД легло
«$sqrt{17}$», «$dfrac{pi}{3}$» (KaTeX рисует «sqrt17», «dfracpi3»). Плюс литеральные
<,> внутри $…$ ломали HTML до KaTeX. Скрипт fix_ctmath_render.js (dry-run/--apply,
идемпотентный): восстанавливает \ перед командами (+нормализует управляющие символы)
в opts_json и заменяет <,>→\lt,\gt внутри $…$ в text/opts/solution. DRY-RUN: 307
строк (143 opts/128 text/157 sol), остаточных багов 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:34:59 +03:00
Maxim Dolgolyov f381873c34 fix(exam-prep): список «Варианты» показывает метку (ЦТ-2015…), а не «Вариант N»
variants.js хардкодил «Вариант ${n}» в гриде-пикере, заголовке и подписи кнопки,
игнорируя поле label из listVariants (бэкенд его уже отдаёт через examVariantLabel).
Добавлен хелпер labelOf(n) → подставляет v.label с фолбэком. mock.js дропдаун уже
использовал label — там достаточно перезапуска сервера, чтобы бэкенд отдал метки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:20:45 +03:00
Maxim Dolgolyov dd69c026ec content(ctmath): вариант 121 — ЦТ-2011 (А1–А18 + В1–В12, 30 заданий)
Перенабор Вариант 1 из ЦТ 2011 В1-В10.pdf (тест полный, не только В), все 30
ответов сверены с официальной таблицей (полное совпадение). Уточнения по таблице:
A6 степень 3x+4 (→15·2^3x), A9=(3^-2)^-5→1/9, A7 корень -3, A8=10, A10=10π.
Фигурные A1/A2/B6 реконструированы (B6: y=x²-6x+9 ∩ y=1,25 → 4x₁x₂=31). Все В
числовые. Без авторских ссылок. Дедуп-гейт 0, KaTeX 30/30, DRY-RUN 30/30. Метка 121='ЦТ-2011'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:53:06 +03:00
Maxim Dolgolyov 84625cd72a content(ctmath): вариант 120 — ЦТ-2012 (А1–А18 + В1–В12, 30 заданий)
Перенабор Вариант 1 из ЦТ 2012.pdf, все 30 ответов сверены с официальной
таблицей (полное совпадение). Фигурные A1/A13/B6 реконструированы с явными
данными (A1 — углы 70/40→равнобедренный; B6 — середины сторон→S=4). A15
уточнена по таблице: √(5⁵·20)=250, знаменатель ⁴√10 → 25·⁴√10. Все В числовые.
Без авторских ссылок. Дедуп-гейт 0, KaTeX 30/30, DRY-RUN 30/30. Метка 120='ЦТ-2012'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:42:27 +03:00
66 changed files with 4725 additions and 289 deletions
+146
View File
@@ -0,0 +1,146 @@
'use strict';
/* ─────────────────────────────────────────────────────────────────────────
Ремонт банка вопросов через ИИ (шлюз Kilo из настроек ассистента).
Режимы:
--broken починить single/multiple без отмеченного верного варианта
(ИИ выбирает верный среди СУЩЕСТВУЮЩИХ вариантов)
--topics привязать математические вопросы без темы к СУЩЕСТВУЮЩИМ темам
Поток: по умолчанию DRY-RUN — пишет предложения в JSON + сводку, БД не трогает.
С флагом --apply — применяет ранее сгенерированный JSON к БД.
--limit N ограничить число вопросов (для теста)
Пример:
node fix-question-bank.js --topics # сгенерировать предложения
node fix-question-bank.js --topics --apply # применить после вычитки
───────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const fs = require('fs');
const path = require('path');
const DB_PATH = path.resolve(__dirname, '../data/learnspace.db');
const argv = process.argv.slice(2);
const MODE = argv.includes('--broken') ? 'broken' : argv.includes('--topics') ? 'topics' : null;
const APPLY = argv.includes('--apply');
const LIMIT = (() => { const i = argv.indexOf('--limit'); return i >= 0 ? Number(argv[i + 1]) || 0 : 0; })();
if (!MODE) { console.error('Укажите режим: --broken или --topics (опц. --apply, --limit N)'); process.exit(1); }
const OUT = path.join(__dirname, `_qbank_proposals_${MODE}.json`);
const db = new DatabaseSync(DB_PATH);
try { db.exec('PRAGMA busy_timeout=8000'); } catch (e) {}
// node:sqlite (DatabaseSync) НЕ имеет .transaction() — оборачиваем вручную.
function runTx(fn) { db.exec('BEGIN'); try { fn(); db.exec('COMMIT'); } catch (e) { try { db.exec('ROLLBACK'); } catch (_) {} throw e; } }
/* ── провайдер Kilo ── */
function aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key=?').get(k); return r && r.value != null ? r.value : null; }
function pickProvider() {
let arr = []; try { arr = JSON.parse(aset('assistant_providers') || '[]'); } catch (e) {}
const active = arr.find(p => p.id === aset('assistant_active'));
return (active && active.key && active) || arr.find(p => p.key) || null;
}
const PROV = pickProvider();
if (!APPLY && !PROV) { console.error('Нет провайдера ИИ с ключом (настрой в /admin#assistant).'); process.exit(1); }
async function llm(messages, maxTokens) {
const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 40000);
try {
const r = await fetch(PROV.url, {
method: 'POST', signal: ctrl.signal,
headers: Object.assign({ 'Content-Type': 'application/json' }, PROV.key ? { Authorization: 'Bearer ' + PROV.key } : {}),
body: JSON.stringify({ model: PROV.model, temperature: 0.1, max_tokens: maxTokens || 1500, messages }),
});
if (!r.ok) return { err: 'HTTP ' + r.status };
const j = await r.json();
return { text: (j.choices && j.choices[0] && j.choices[0].message && (j.choices[0].message.content || j.choices[0].message.reasoning)) || '' };
} catch (e) { return { err: e.name === 'AbortError' ? 'timeout' : 'network' }; }
finally { clearTimeout(timer); }
}
function parseJson(raw) {
let s = String(raw || '').replace(/```(?:json)?/gi, '').trim();
const a = s.search(/[[{]/); if (a > 0) s = s.slice(a);
const lastArr = s.lastIndexOf(']'), lastObj = s.lastIndexOf('}');
const end = Math.max(lastArr, lastObj); if (end >= 0) s = s.slice(0, end + 1);
try { return JSON.parse(s); } catch (e) { return null; }
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
/* ═══ APPLY: применить сохранённые предложения ═══ */
function applyProposals() {
if (!fs.existsSync(OUT)) { console.error('Нет файла предложений ' + OUT + ' — сначала запусти без --apply.'); process.exit(1); }
const props = JSON.parse(fs.readFileSync(OUT, 'utf8'));
let n = 0;
if (MODE === 'broken') {
const upd = db.prepare('UPDATE options SET is_correct = 1 WHERE id = ? AND question_id = ?');
runTx(() => { for (const p of props) { if (p.optionId) { upd.run(p.optionId, p.id); n++; } } });
console.log(`Применено: отмечено верных вариантов — ${n}`);
} else {
const upd = db.prepare('UPDATE questions SET topic_id = ? WHERE id = ? AND topic_id IS NULL');
runTx(() => { for (const p of props) { if (p.topicId) { upd.run(p.topicId, p.id); n++; } } });
console.log(`Применено: привязано тем — ${n}`);
}
process.exit(0);
}
if (APPLY) applyProposals();
/* ═══ DRY-RUN: сгенерировать предложения ═══ */
(async () => {
if (MODE === 'broken') {
let qs = db.prepare(`
SELECT q.id, q.text FROM questions q
WHERE q.type IN ('single','multiple')
AND NOT EXISTS (SELECT 1 FROM options o WHERE o.question_id=q.id AND o.is_correct=1)
AND EXISTS (SELECT 1 FROM options o WHERE o.question_id=q.id)`).all();
if (LIMIT) qs = qs.slice(0, LIMIT);
console.log(`Битых MCQ к разбору: ${qs.length}`);
const props = [];
for (const q of qs) {
const opts = db.prepare('SELECT id, text FROM options WHERE question_id=? ORDER BY order_index').all(q.id);
const list = opts.map((o, i) => `${i + 1}. ${o.text}`).join('\n');
const sys = 'Ты эксперт-предметник. Определи ЕДИНСТВЕННЫЙ правильный вариант ответа. ' +
'Верни СТРОГО JSON {"correct": N} где N — номер варианта (1..K), либо {"correct": 0} если определить нельзя (например, нужен рисунок/график). Только JSON.';
const user = `Вопрос: ${q.text}\n\nВарианты:\n${list}`;
const r = await llm([{ role: 'system', content: sys }, { role: 'user', content: user }], 300);
const j = r.text ? parseJson(r.text) : null;
const n = j && Number(j.correct);
const opt = (n >= 1 && n <= opts.length) ? opts[n - 1] : null;
props.push({ id: q.id, optionId: opt ? opt.id : null, optionText: opt ? opt.text : null, q: q.text.slice(0, 70), err: r.err || null });
console.log(` #${q.id}: ${opt ? 'верный → «' + String(opt.text).slice(0, 40) + '»' : (r.err || 'не определено (ручная проверка)')}`);
await sleep(400);
}
fs.writeFileSync(OUT, JSON.stringify(props, null, 2));
console.log(`\nПредложения: ${OUT}\nВычитай, затем: node fix-question-bank.js --broken --apply`);
return;
}
// topics: математика, вопросы без темы → существующие темы
const subj = db.prepare("SELECT id FROM subjects WHERE name LIKE '%атематик%' OR slug='math'").get();
if (!subj) { console.error('Предмет «Математика» не найден'); process.exit(1); }
const topics = db.prepare('SELECT id, name FROM topics WHERE subject_id=? ORDER BY id').all(subj.id);
if (!topics.length) { console.error('У математики нет тем'); process.exit(1); }
let qs = db.prepare('SELECT id, text FROM questions WHERE subject_id=? AND topic_id IS NULL').all(subj.id);
if (LIMIT) qs = qs.slice(0, LIMIT);
console.log(`Тем: ${topics.length} | вопросов без темы: ${qs.length}`);
const topicList = topics.map((t, i) => `${i + 1}. ${t.name}`).join('\n');
const props = []; const BATCH = 12;
for (let i = 0; i < qs.length; i += BATCH) {
const chunk = qs.slice(i, i + BATCH);
const sys = 'Ты классифицируешь вопросы по математике по СУЩЕСТВУЮЩИМ темам из списка. ' +
'Для каждого вопроса верни номер наиболее подходящей темы или 0, если ни одна явно не подходит. ' +
'Верни СТРОГО JSON-массив [{"id":<id вопроса>,"t":<номер темы 1..K или 0>}]. Только JSON.';
const user = `Темы:\n${topicList}\n\nВопросы:\n` + chunk.map(q => `[id ${q.id}] ${String(q.text).replace(/\s+/g, ' ').slice(0, 240)}`).join('\n');
const r = await llm([{ role: 'system', content: sys }, { role: 'user', content: user }], 900);
const arr = r.text ? parseJson(r.text) : null;
const map = {}; if (Array.isArray(arr)) arr.forEach(x => { if (x && x.id != null) map[x.id] = Number(x.t); });
for (const q of chunk) {
const n = map[q.id]; const t = (n >= 1 && n <= topics.length) ? topics[n - 1] : null;
props.push({ id: q.id, topicId: t ? t.id : null, topicName: t ? t.name : null, q: String(q.text).replace(/\s+/g, ' ').slice(0, 70) });
}
const done = Math.min(i + BATCH, qs.length);
console.log(` ${done}/${qs.length}${r.err ? ' (ошибка батча: ' + r.err + ')' : ''}`);
await sleep(500);
}
const assigned = props.filter(p => p.topicId).length;
fs.writeFileSync(OUT, JSON.stringify(props, null, 2));
const byTopic = {}; props.forEach(p => { if (p.topicName) byTopic[p.topicName] = (byTopic[p.topicName] || 0) + 1; });
console.log(`\nРазмечено: ${assigned}/${props.length} (остальные — без уверенной темы, останутся как есть)`);
Object.entries(byTopic).sort((a, b) => b[1] - a[1]).slice(0, 12).forEach(([t, n]) => console.log(` ${t}: ${n}`));
console.log(`\nПредложения: ${OUT}\nВычитай, затем: node fix-question-bank.js --topics --apply`);
})();
+171
View File
@@ -0,0 +1,171 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
fix_ctmath_render.js — починка двух дефектов рендеринга в exam_tasks (ctmath).
ПРИЧИНА (root cause):
В seed-скриптах вариантов 101–121 опции писались как mc('$\sqrt{17}$', ...) —
в ОБЫЧНЫХ кавычках, а не в String.raw `…`. JS-парсер съедал управляющие
эскейпы: \s→s, \d→d (теряется «\»), а \f→0x0C, \t→0x09, \b→0x08, \v,\n,\r —
превращались в УПРАВЛЯЮЩИЕ символы. Итог в БД: «$sqrt{17}$», «$dfrac{pi}{3}$»,
KaTeX рендерит их как «sqrt17», «dfracpi3». (text/solution писались через R`…`
и НЕ пострадали — там «\» на месте.)
ВТОРОЙ ДЕФЕКТ: литеральные < и > ВНУТРИ $…$ (напр. «$-1{,}6<x<-1$»). При вставке
в innerHTML браузер парсит «<x…» как HTML-тег ДО запуска KaTeX → ломает карточку.
Лечится заменой < → \lt, > → \gt (только внутри $…$).
ЧТО ДЕЛАЕТ СКРИПТ (идемпотентно, повторный запуск безопасен):
• opts_json: (1) нормализует управляющие символы обратно в \f \t \b \v \n \r;
(2) восстанавливает «\» перед известными KaTeX-командами; (3) < > → \lt \gt.
• text_html, solution_html: только (3) < > → \lt \gt внутри $…$ (HTML-теги вне
математики не трогаются).
Восстановление «\» применяется ТОЛЬКО к opts_json (text/sol не повреждены).
Запуск:
node backend/scripts/fix_ctmath_render.js # DRY-RUN (показывает правки)
node backend/scripts/fix_ctmath_render.js --apply # запись в БД
⚠️ Запись запускает ПОЛЬЗОВАТЕЛЬ. После --apply — перезапуск сервера не нужен
(данные в БД; фронт перечитает их при следующем запросе), но hard-refresh браузера.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
/* ── Управляющие символы → их LaTeX-эскейп (обратная нормализация) ── */
const CTRL_MAP = { '\b': '\\b', '\t': '\\t', '\n': '\\n', '\v': '\\v', '\f': '\\f', '\r': '\\r' };
function normalizeCtrl(s) {
return s.replace(/[\b\t\n\v\f\r]/g, ch => CTRL_MAP[ch] || ch);
}
/* ── Команды, у которых первая буква НЕ из {f,t,b,v,n,r} (их «\» просто пропал,
без управляющего символа). Длинные/составные — РАНЬШЕ коротких префиксов,
чтобы не разорвать слово (dfrac до frac, arccos до cos, leq до le, …). ── */
const BARE_CMDS = [
'arccos', 'arcsin', 'arctg',
'overline', 'operatorname', 'varnothing', 'varphi', 'varepsilon',
'dfrac', 'cdots', 'cdot', 'sqrt', 'left',
'lambda', 'gamma', 'delta', 'sigma', 'omega', 'alpha', 'angle', 'approx',
'infty', 'ldots', 'oplus',
'cos', 'sin', 'cot', 'ctg', 'cup', 'cap', 'leq', 'geq', 'neq',
'sim', 'lim', 'log',
'pm', 'mp', 'le', 'ge', 'ln', 'lg', 'pi', 'mu', 'in',
'phi', 'psi', 'rho', 'chi', 'tau',
];
/* (frac, tfrac, times, theta, tan, tg, text, beta, vec, ne, nu, right, nabla —
приходят из управляющих символов и чинятся normalizeCtrl, поэтому в этом
списке их НЕТ: иначе «dfrac»→«d\frac».) */
function restoreBackslashes(math) {
let s = normalizeCtrl(math);
for (const cmd of BARE_CMDS) {
// «\bcmd», не уже-экранированное (нет «\» перед), как самостоятельное слово
const re = new RegExp('(^|[^\\\\A-Za-z])' + cmd + '(?![A-Za-z])', 'g');
s = s.replace(re, (m, pre) => pre + '\\' + cmd);
}
return s;
}
/* ── < > → \lt \gt ТОЛЬКО внутри $…$ / $$…$$ ── */
function fixAngles(field) {
if (!field) return field;
return String(field).replace(/\$\$[\s\S]*?\$\$|\$[^$]*\$/g, seg =>
seg.replace(/</g, '\\lt ').replace(/>/g, '\\gt '));
}
/* ── Полная починка одной опции (внутри $…$): \ + < > ── */
function fixOptionText(t) {
if (!t) return t;
// обрабатываем содержимое каждого $…$: восстановить «\», затем < >
return String(t).replace(/\$\$[\s\S]*?\$\$|\$[^$]*\$/g, seg => {
const open = seg.startsWith('$$') ? '$$' : '$';
const inner = seg.slice(open.length, seg.length - open.length);
let fixed = restoreBackslashes(inner);
fixed = fixed.replace(/</g, '\\lt ').replace(/>/g, '\\gt ');
return open + fixed + open;
});
}
/* ── Открытие БД ── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const rows = db.prepare(
`SELECT id, variant, task_idx, task_type, text_html, opts_json, solution_html
FROM exam_tasks WHERE exam_key=? ORDER BY variant, task_idx`).all(EXAM);
let changedRows = 0, changedOpts = 0, changedText = 0, changedSol = 0;
const samples = [];
const upd = db.prepare(
`UPDATE exam_tasks SET text_html=?, opts_json=?, solution_html=? WHERE id=?`);
if (APPLY) db.exec('BEGIN');
try {
for (const r of rows) {
let newOpts = r.opts_json;
let newText = r.text_html;
let newSol = r.solution_html;
let touched = false;
// opts_json — восстановление «\» + < >
if (r.opts_json) {
try {
const arr = JSON.parse(r.opts_json);
const fixed = arr.map(([l, t]) => [l, fixOptionText(t)]);
const cand = JSON.stringify(fixed);
if (cand !== r.opts_json) { newOpts = cand; changedOpts++; touched = true; }
} catch { /* не-JSON — пропускаем */ }
}
// text_html / solution_html — только < >
const ft = fixAngles(r.text_html);
if (ft !== r.text_html) { newText = ft; changedText++; touched = true; }
const fs = fixAngles(r.solution_html);
if (fs !== r.solution_html) { newSol = fs; changedSol++; touched = true; }
if (touched) {
changedRows++;
if (samples.length < 12) {
samples.push({ v: r.variant, i: r.task_idx,
beforeOpts: r.opts_json && r.opts_json.length > 90 ? r.opts_json.slice(0, 90) + '…' : r.opts_json,
afterOpts: newOpts && newOpts.length > 90 ? newOpts.slice(0, 90) + '…' : newOpts });
}
if (APPLY) upd.run(newText, newOpts, newSol, r.id);
}
}
if (APPLY) db.exec('COMMIT');
} catch (e) {
if (APPLY) db.exec('ROLLBACK');
console.error('✗ Ошибка, откат:', e.message);
db.close();
process.exit(1);
}
console.log(`\n=== fix_ctmath_render (${APPLY ? 'APPLY' : 'DRY-RUN'}) ===`);
console.log(`Всего задач ctmath: ${rows.length}`);
console.log(`Будет изменено строк: ${changedRows} (opts: ${changedOpts}, text: ${changedText}, sol: ${changedSol})`);
console.log(`\nПримеры (opts до → после):`);
for (const s of samples) {
console.log(`\n v${s.v}#${s.i}`);
console.log(` ДО: ${s.beforeOpts}`);
console.log(` ПОСЛЕ: ${s.afterOpts}`);
}
/* контроль остаточных «голых» команд после починки (для self-check в dry-run) */
if (!APPLY) {
const after = db.prepare(`SELECT opts_json FROM exam_tasks WHERE exam_key=? AND opts_json IS NOT NULL`).all(EXAM);
let leftover = 0;
for (const r of after) {
let arr; try { arr = JSON.parse(r.opts_json); } catch { continue; }
for (const [, t] of arr) {
const fixedNow = fixOptionText(t);
// ищем подозрительные «\bdfrac/sqrt/frac…» БЕЗ слэша уже ПОСЛЕ починки
if (/(^|[^\\A-Za-z])(sqrt|dfrac|frac|tfrac|cdot|times|alpha|beta|theta|pi)(?![A-Za-z])/.test(fixedNow.replace(/\$/g,''))) leftover++;
}
}
console.log(`\nКонтроль: потенциально не починенных опций после прогона: ${leftover}`);
console.log(`\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/fix_ctmath_render.js --apply\n`);
}
db.close();
+61
View File
@@ -0,0 +1,61 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
reset-system.js — CLI «ЧИСТЫЙ ЗАПУСК» (тонкая обёртка над src/services/systemReset.js).
⚠️ ДЕСТРУКТИВНО. По умолчанию DRY-RUN. Выполнение — только с --apply --confirm=RESET.
Перед сбросом сделайте бэкап (control-panel «Бэкап БД» делает автоматически).
Та же логика доступна в админ-веб-панели (POST /api/admin/reset-system).
Запуск:
node backend/scripts/reset-system.js # план
node backend/scripts/reset-system.js --apply --confirm=RESET # выполнить
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const reset = require('../src/services/systemReset');
const APPLY = process.argv.includes('--apply');
const CONFIRM = process.argv.includes('--confirm=RESET');
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const keptAdmin = reset.pickKeptAdmin(db);
if (!keptAdmin) {
console.error('✗ В системе нет ни одного админа — сброс отменён (иначе залочитесь). Создайте админа сначала.');
db.close(); process.exit(1);
}
const plan = reset.classify(db);
console.log(`\n=== reset-system «ЧИСТЫЙ ЗАПУСК» (${APPLY ? (CONFIRM ? 'APPLY' : 'нужен --confirm=RESET') : 'DRY-RUN'}) ===`);
console.log(`Сохраняемый админ: id=${keptAdmin.id} ${keptAdmin.email} «${keptAdmin.name}»`);
console.log(`Пользователей: ${plan.totalUsers} → останется 1, удалится ${plan.totalUsers - 1}\n`);
console.log('REASSIGN (контент → админу):');
plan.reassign.forEach(r => console.log(` ${r.table.padEnd(22)} ${r.col.padEnd(12)} строк: ${r.rows}`));
console.log('\nWIPE (полная очистка):');
plan.wipe.forEach(w => console.log(` ${w.table.padEnd(28)} строк: ${w.rows}`));
console.log(` — всего к удалению (без каскада users): ~${plan.wipeRows}`);
console.log(`\nKEEP (контент/конфиг): ${plan.keepCount} таблиц.`);
if (plan.unknown.length) console.log(`\n⚠️ НЕИЗВЕСТНЫЕ таблицы (НЕ трогаем): ${plan.unknown.join(', ')}`);
if (!APPLY) {
console.log('\nDRY-RUN: ничего не изменено. Выполнить: node backend/scripts/reset-system.js --apply --confirm=RESET\n');
db.close(); process.exit(0);
}
if (!CONFIRM) {
console.error('\n✗ Нужен флаг --confirm=RESET (защита от случайного запуска). Отмена.');
db.close(); process.exit(1);
}
try {
const res = reset.runReset(db, keptAdmin.id);
console.log(`\n✓ ЧИСТЫЙ ЗАПУСК выполнен. Удалено пользователей: ${res.deletedUsers}, осталось: ${res.remainingUsers}.`);
console.log(`✓ Контент сохранён: учебники ${res.kept.textbooks}, вопросы ${res.kept.questions}, тесты ${res.kept.tests}, курсы ${res.kept.courses}, exam-prep ${res.kept.exam_tasks}.`);
if (res.fkDangling) console.log(`⚠️ foreign_key_check: ${res.fkDangling} висячих ссылок — проверьте.`);
console.log(`\nВойдите под ${keptAdmin.email}. Перезапустите сервер.\n`);
} catch (e) {
console.error('\n✗ Ошибка — откат, изменений нет:', e.message);
db.close(); process.exit(1);
}
db.close();
+352
View File
@@ -0,0 +1,352 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_ct2011_v1.js
Чистый вариант-пробник для трека exam-prep `ctmath`.
Источник: Централизованное тестирование (ЦТ) по математике, 2011, Вариант 1.
Формат: Часть А = А1–А18, Часть В = В1–В12 (все В — числовые). Всего 30 заданий.
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2011\ЦТ 2011 В1-В10.pdf
(несмотря на имя «В1-В10», тест полный: А1–А18 + В1–В12; ответы — «Ответы 2011.pdf», столбец 1).
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
совпадение, включая B2=150, B8=16, B10=10, B12=26. variant=121. Прогнан через
дедуп-гейт (check_variant_dups.js) — без повторов с видимым пулом.
Уточнения по таблице (скан неоднозначен по степеням/индексам):
• А6: степень $3x+4$ → $2^{3x+4}-2^{3x}=15\cdot2^{3x}$;
А9: $3^{-12}\cdot(3^{-2})^{-5}=3^{-2}=\tfrac19$;
• А7: корень уравнения с радикалом = $-3$ (корень линейного множителя вне ОДЗ отброшен);
• А10: осевое сечение $=10$ → боковая $=10\pi$.
Реконструкции «с-картинкой» (смысл/ответ сохранены, авто-проверка):
• А1 (tg не определена) → точки в тексте, ровно одна вида $\tfrac{\pi}{2}+\pi k$ ($-\tfrac{5\pi}{2}$);
• А2 (параллелограмм на сетке) → основание/высота числами ($5\times4=20$);
• B6 (парабола+прямая) → парабола $y=x^2-6x+9$ и прямая $y=1{,}25$ заданы явно ($4x_1x_2=31$).
Без авторских ссылок (политика «все учебники наши»).
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
Запуск:
node backend/scripts/seed_ctmath_ct2011_v1.js # DRY-RUN (по умолчанию)
node backend/scripts/seed_ctmath_ct2011_v1.js --apply # запись в БД
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 121;
const N_TASKS = 30;
const PROV = 'ЦТ–2011, Вариант 1';
const R = String.raw;
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
const TASKS = [
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'trigonometry', subtopic: 'trig-circle', diff: 1,
text: R`Функция $y=\operatorname{tg}x$ не определена в точке:`,
opts: mc('$2\pi$', '$-\dfrac{5\pi}{2}$', '$\dfrac{2\pi}{5}$', '$\dfrac{\pi}{4}$', '$-3\pi$'),
answer: 'б',
sol: R`$\operatorname{tg}x$ не определён при $x=\dfrac{\pi}{2}+\pi k$. Из перечисленных таково $-\dfrac{5\pi}{2}=\dfrac{\pi}{2}-3\pi$.` },
{ idx: 2, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 1,
text: R`Параллелограмм изображён на клетчатой бумаге с клетками $1\times1$ см: его основание равно $5$ см, а высота, проведённая к этому основанию, равна $4$ см. Найдите площадь параллелограмма (в квадратных сантиметрах).`,
opts: mc('$10$', '$25$', '$15$', '$20$', '$18$'),
answer: 'г',
sol: R`Площадь параллелограмма $=$ основание $\times$ высоту $=5\cdot4=20$ см².` },
{ idx: 3, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
text: R`Если $7\tfrac29:x=4\tfrac13:3\tfrac35$ — верная пропорция, то число $x$ равно:`,
opts: mc('$\dfrac23$', '$6$', '$\dfrac54$', '$\dfrac49$', '$1{,}5$'),
answer: 'б',
sol: R`$x=\dfrac{7\tfrac29\cdot3\tfrac35}{4\tfrac13}=\dfrac{\tfrac{65}{9}\cdot\tfrac{18}{5}}{\tfrac{13}{3}}=\dfrac{26}{\tfrac{13}{3}}=6$.` },
{ idx: 4, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 1,
text: R`Если $15\%$ некоторого числа равны $33$, то $20\%$ этого числа равны:`,
opts: mc('$44$', '$46$', '$55$', '$56$', '$66$'),
answer: 'а',
sol: R`Число $=\dfrac{33}{0{,}15}=220$, тогда $20\%=0{,}2\cdot220=44$.` },
{ idx: 5, type: 'mc', topic: 'equations', subtopic: 'eq-linear', diff: 1,
text: R`Если $9x-24=0$, то $18x-31$ равно:`,
opts: mc('$13$', '$-17$', '$17$', '$21$', '$-19$'),
answer: 'в',
sol: R`$x=\dfrac{24}{9}=\dfrac83$, поэтому $18x=48$ и $18x-31=17$.` },
{ idx: 6, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Для любого числа $x$ выражение $2^{3x+4}-2^{3x}$ равно:`,
opts: mc('$15\cdot2^{3x}$', '$16$', '$2^{6x+1}$', '$\dfrac23\cdot2^{3x}$', '$2^{3x}$'),
answer: 'а',
sol: R`$2^{3x+4}-2^{3x}=2^{3x}\left(2^{4}-1\right)=15\cdot2^{3x}$.` },
{ idx: 7, type: 'mc', topic: 'equations', subtopic: 'eq-irrational', diff: 2,
text: R`Сумма корней (корень, если он один) уравнения $(x+5)\sqrt{x+3}=0$ равна:`,
opts: mc('$-1$', '$3$', '$1$', '$-3$', '$-2$'),
answer: 'г',
sol: R`ОДЗ: $x\ge-3$. Корень $x=-5$ не входит в ОДЗ, остаётся $x=-3$ (из $\sqrt{x+3}=0$). Единственный корень $-3$.` },
{ idx: 8, type: 'mc', topic: 'equations', subtopic: 'eq-quadratic', diff: 2,
text: R`От листа жести, имеющего форму квадрата, отрезали прямоугольную полосу шириной $7$ дм, после чего площадь оставшейся части листа оказалась равной $30$ дм². Длина стороны квадратного листа (в дециметрах) была равна:`,
opts: mc('$11$', '$12$', '$3$', '$9$', '$10$'),
answer: 'д',
sol: R`Если сторона квадрата $a$, то $a(a-7)=30$, $a^{2}-7a-30=0$, $a=10$ (второй корень $-3$ отброшен).` },
{ idx: 9, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Значение выражения $3^{-12}\cdot\left(3^{-2}\right)^{-5}$ равно:`,
opts: mc('$81$', '$3^{-22}$', '$9$', '$3^{-12}$', '$\dfrac19$'),
answer: 'д',
sol: R`$\left(3^{-2}\right)^{-5}=3^{10}$, поэтому $3^{-12}\cdot3^{10}=3^{-2}=\dfrac19$.` },
{ idx: 10, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 2,
text: R`Площадь осевого сечения цилиндра равна $10$. Площадь его боковой поверхности равна:`,
opts: mc('$5\pi$', '$10\pi$', '$20\pi$', '$100\pi$', '$10$'),
answer: 'б',
sol: R`Осевое сечение — прямоугольник $2r\times h$ площадью $2rh=10$. Боковая поверхность $=2\pi rh=\pi\cdot2rh=10\pi$.` },
{ idx: 11, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
text: R`Найдите значение выражения $230\cdot\dfrac29-\left(\dfrac29+\dfrac1{10}\right):\dfrac1{230}$.`,
opts: mc('$0{,}1$', '$43\tfrac49$', '$-0{,}1$', '$-23$', '$23$'),
answer: 'г',
sol: R`$230\cdot\dfrac29=\dfrac{460}{9}$; $\left(\dfrac{29}{90}\right)\cdot230=\dfrac{667}{9}$. Разность $\dfrac{460-667}{9}=-\dfrac{207}{9}=-23$.` },
{ idx: 12, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
text: R`Упростите выражение $\dfrac{x^{2}-22x+121}{x^{2}-11x}:\dfrac{x^{2}-121}{x^{3}}$.`,
opts: mc('$\dfrac{x}{x+11}$', '$\dfrac{(x-11)^{2}}{x^{4}}$', '$\dfrac{x-11}{x+11}$', '$\dfrac{x^{2}}{x-11}$', '$\dfrac{x^{2}}{x+11}$'),
answer: 'д',
sol: R`$\dfrac{(x-11)^{2}}{x(x-11)}\cdot\dfrac{x^{3}}{(x-11)(x+11)}=\dfrac{x^{2}}{x+11}$.` },
{ idx: 13, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 2,
text: R`Параллельно стороне треугольника, равной $5$, проведена прямая. Длина отрезка этой прямой, заключённого между сторонами треугольника, равна $2$. Найдите отношение площади полученной трапеции к площади исходного треугольника.`,
opts: mc('$\dfrac25$', '$0{,}6$', '$\dfrac{21}{25}$', '$\dfrac{4}{25}$', '$\dfrac{3}{25}$'),
answer: 'в',
sol: R`Отсечённый треугольник подобен исходному с коэффициентом $\dfrac25$, его площадь составляет $\dfrac{4}{25}$. Трапеция: $1-\dfrac{4}{25}=\dfrac{21}{25}$.` },
{ idx: 14, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
text: R`Сумма координат точки пересечения прямых, заданных уравнениями $2x+5y=11$ и $x+y=2(5-y)$, равна:`,
opts: mc('$8$', '$-8$', '$10$', '$-10$', '$6$'),
answer: 'б',
sol: R`Второе уравнение: $x+3y=10$. Из системы $y=9$, $x=-17$. Сумма координат $-17+9=-8$.` },
{ idx: 15, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 3,
text: R`Количество целых решений неравенства $\dfrac{(x+3)^{2}-6x-18}{(x-5)^{2}}>0$ на промежутке $[-4;5]$ равно:`,
opts: mc('$2$', '$7$', '$4$', '$5$', '$3$'),
answer: 'а',
sol: R`Числитель $(x+3)^{2}-6x-18=x^{2}-9$. При $x\ne5$ знаменатель положителен, поэтому неравенство равносильно $x^{2}-9>0$, то есть $x<-3$ или $x>3$. На $[-4;5]$ это $x=-4$ и $x=4$$2$ решения.` },
{ idx: 16, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 3,
text: R`В ромб площадью $18\sqrt5$ вписан круг площадью $5\pi$. Сторона ромба равна:`,
opts: mc('$8$', '$18$', '$\dfrac{9\sqrt5}{5}$', '$\dfrac{18\sqrt5}{5}$', '$9$'),
answer: 'д',
sol: R`Радиус вписанного круга: $\pi r^{2}=5\pi$, $r=\sqrt5$; высота ромба $h=2r=2\sqrt5$. Площадь $=a\cdot h$: $18\sqrt5=a\cdot2\sqrt5$, $a=9$.` },
{ idx: 17, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 2,
text: R`Расположите числа $\sqrt[12]{80}$; $\sqrt[3]{3}$; $\sqrt[4]{4}$ в порядке возрастания.`,
opts: mc('$\sqrt[4]{4};\ \sqrt[3]{3};\ \sqrt[12]{80}$', '$\sqrt[3]{3};\ \sqrt[4]{4};\ \sqrt[12]{80}$', '$\sqrt[3]{3};\ \sqrt[12]{80};\ \sqrt[4]{4}$', '$\sqrt[4]{4};\ \sqrt[12]{80};\ \sqrt[3]{3}$', '$\sqrt[12]{80};\ \sqrt[3]{3};\ \sqrt[4]{4}$'),
answer: 'г',
sol: R`Возведём в $12$-ю степень: $\left(\sqrt[12]{80}\right)^{12}=80$, $\left(\sqrt[3]{3}\right)^{12}=81$, $\left(\sqrt[4]{4}\right)^{12}=64$. Так как $64<80<81$, порядок: $\sqrt[4]{4};\ \sqrt[12]{80};\ \sqrt[3]{3}$.` },
{ idx: 18, type: 'mc', topic: 'trigonometry', subtopic: 'trig-equations', diff: 3,
text: R`Найдите наименьший положительный корень уравнения $4\sin^{2}x+12\cos x-9=0$.`,
opts: mc('$\dfrac{2\pi}{3}$', '$\arccos\dfrac52$', '$\dfrac{\pi}{3}$', '$\dfrac{\pi}{6}$', '$\pi-\arccos\dfrac52$'),
answer: 'в',
sol: R`$4(1-\cos^{2}x)+12\cos x-9=0$, то есть $4\cos^{2}x-12\cos x+5=0$, $\cos x=\dfrac12$ (второй корень $\dfrac52$ невозможен). Наименьший положительный корень $\dfrac{\pi}{3}$.` },
// ── Часть B: В1–В12 (все числовые) ───────────────────────────────────────
{ idx: 19, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 3,
text: R`Найдите произведение корней уравнения $\dfrac{3}{x+1}+1=\dfrac{10}{x^{2}+2x+1}$.`,
answer: '-6',
sol: R`Пусть $u=x+1$: $\dfrac3u+1=\dfrac{10}{u^{2}}$, $u^{2}+3u-10=0$, $u=2$ или $u=-5$. Тогда $x=1$ или $x=-6$, произведение $-6$.` },
{ idx: 20, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 4,
text: R`Диагонали трапеции равны $15$ и $20$. Найдите площадь трапеции, если её средняя линия равна $12{,}5$.`,
answer: '150',
sol: R`Площадь трапеции равна площади треугольника со сторонами, равными диагоналям ($15$ и $20$), и основанием, равным сумме оснований $=2\cdot12{,}5=25$. Так как $15^{2}+20^{2}=25^{2}$, треугольник прямоугольный: площадь $=\tfrac12\cdot15\cdot20=150$.` },
{ idx: 21, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 4,
text: R`Найдите сумму корней (или корень, если он один) уравнения $2\cdot6^{\log_7 x}=108-x^{\log_7 6}$.`,
answer: '49',
sol: R`Так как $x^{\log_7 6}=6^{\log_7 x}$, обозначим $t=6^{\log_7 x}$: $2t=108-t$, $t=36=6^{2}$. Тогда $\log_7 x=2$, $x=49$ — единственный корень.` },
{ idx: 22, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
text: R`Найдите сумму целых решений неравенства $2^{3x+4}-10\cdot4^{x}+2^{x}\le0$.`,
answer: '-6',
sol: R`Пусть $u=2^{x}>0$: $16u^{3}-10u^{2}+u\le0$, $u(16u^{2}-10u+1)\le0$, $\dfrac18\le u\le\dfrac12$. Значит $-3\le x\le-1$; сумма целых $-3-2-1=-6$.` },
{ idx: 23, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 4,
text: R`По двум перпендикулярным прямым, которые пересекаются в точке $O$, движутся две точки $M_1$ и $M_2$ по направлению к точке $O$ со скоростями $1$ м/с и $2$ м/с соответственно. Достигнув точки $O$, они продолжают своё движение. В первоначальный момент времени $M_1O=5$ м, $M_2O=20$ м. Через сколько секунд расстояние между точками $M_1$ и $M_2$ будет минимальным?`,
answer: '9',
sol: R`Расстояние: $d^{2}=(5-t)^{2}+(20-2t)^{2}=5t^{2}-90t+425$. Минимум при $t=\dfrac{90}{10}=9$ с.` },
{ idx: 24, type: 'open', topic: 'functions', subtopic: 'fn-graphs', diff: 4,
text: R`Парабола $y=x^{2}-6x+9$ и горизонтальная прямая $y=1{,}25$ пересекаются в точках с абсциссами $x_1$ и $x_2$. Найдите значение выражения $4x_1\cdot x_2$.`,
answer: '31',
sol: R`$x^{2}-6x+9=1{,}25$, то есть $x^{2}-6x+7{,}75=0$. По теореме Виета $x_1 x_2=7{,}75$, поэтому $4x_1 x_2=31$.` },
{ idx: 25, type: 'open', topic: 'planimetry', subtopic: 'plan-circle', diff: 4,
text: R`Четырёхугольник $ABCD$ вписан в окружность. Если $\angle BAC=40^\circ$ и $\angle ABD=75^\circ$, то градусная мера угла между прямыми $AB$ и $CD$ равна … .`,
answer: '35',
sol: R`$\angle BAC=40^\circ$ опирается на дугу $BC=80^\circ$, $\angle ABD=75^\circ$ — на дугу $AD=150^\circ$. Угол между прямыми $AB$ и $CD$ равен полуразности дуг: $\dfrac{150^\circ-80^\circ}{2}=35^\circ$.` },
{ idx: 26, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 4,
text: R`Найдите значение выражения $\dfrac{\sin^{2}184^\circ}{4\sin^{2}23^\circ\cdot\sin^{2}2^\circ\cdot\sin^{2}44^\circ\cdot\sin^{2}67^\circ}$.`,
answer: '16',
sol: R`$\sin67^\circ=\cos23^\circ$ и $\sin46^\circ=\cos44^\circ$ дают знаменатель $=\dfrac1{16}\sin^{2}4^\circ$. Числитель $\sin^{2}184^\circ=\sin^{2}4^\circ$. Отношение $=16$.` },
{ idx: 27, type: 'open', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 4,
text: R`В арифметической прогрессии $130$ членов, их сумма равна $130$, а сумма членов с чётными номерами на $130$ больше суммы членов с нечётными номерами. Найдите сотый член этой прогрессии.`,
answer: '70',
sol: R`Сумма чётных членов равна $130$, нечётных — $0$. Разность сумм $=65d=130$, поэтому $d=2$. Из общей суммы $a_1=-128$, тогда $a_{100}=a_1+99d=-128+198=70$.` },
{ idx: 28, type: 'open', topic: 'stereometry', subtopic: 'ster-angles-distances', diff: 5,
text: R`В равнобокой трапеции бóльшее основание вдвое больше каждой из остальных сторон и лежит в плоскости $\alpha$. Боковая сторона образует с плоскостью $\alpha$ угол, синус которого равен $\dfrac{5\sqrt3}{18}$. Найдите $36\sin\beta$, где $\beta$ — угол между диагональю трапеции и плоскостью $\alpha$.`,
answer: '10',
sol: R`Пусть боковая сторона $=b$, тогда основания $b$ и $2b$, высота трапеции $\dfrac{b\sqrt3}{2}$. Из условия $\dfrac{\sqrt3}{2}\sin\theta=\dfrac{5\sqrt3}{18}$ получаем $\sin\theta=\dfrac59$. Длина диагонали $=b\sqrt3$, и $\sin\beta=\dfrac{\sin\theta}{2}=\dfrac{5}{18}$. Значит $36\sin\beta=10$.` },
{ idx: 29, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 5,
text: R`Количество целых решений неравенства $2^{x+6}+\log_{0{,}5}(6-x)>13$ равно … .`,
answer: '7',
sol: R`ОДЗ: $x<6$. При $x=-2$ левая часть равна ровно $13$ (не годится), при $x\le-3$ меньше $13$, а при $-1\le x\le5$ — больше $13$. Целые решения: $-1,0,1,2,3,4,5$ — всего $7$.` },
{ idx: 30, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 5,
text: R`Основанием пирамиды $SABCD$ является ромб со стороной $2\sqrt3$ и углом $BAD$, равным $\arccos\dfrac34$. Ребро $SD$ перпендикулярно основанию, а ребро $SB$ образует с основанием угол $60^\circ$. Найдите радиус $R$ сферы, проходящей через точки $A$, $B$, $C$ и середину ребра $SB$. В ответ запишите $R^{2}$.`,
answer: '26',
sol: R`Диагональ $BD=\sqrt{2\cdot12\left(1-\tfrac34\right)}=\sqrt6$, $SD=BD\cdot\operatorname{tg}60^\circ=3\sqrt2$. В координатах с центром ромба: $A(0;\tfrac{\sqrt{42}}2;0)$, $B(\tfrac{\sqrt6}2;0;0)$, $C(0;-\tfrac{\sqrt{42}}2;0)$, середина $SB$ $=(0;0;\tfrac{3\sqrt2}2)$. Центр сферы $\left(-\tfrac{3\sqrt6}2;0;-\sqrt2\right)$, $R^{2}=\tfrac{54}{4}+\tfrac{42}{4}+2=26$.` },
];
/* ── Сборка solution_html ────────────────────────────────────────────────── */
function ansShowOf(t) {
if (t.ansShow != null) return t.ansShow;
if (t.type === 'mc') return `${t.answer})`;
return `$${t.answer}$`;
}
function buildSolution(t) {
const ans = ansShowOf(t);
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(userInput, canonical) {
if (userInput == null || canonical == null) return false;
const c = String(canonical).trim();
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false;
const cn = srvToNumber(c), un = srvToNumber(userInput);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
/* ── Валидация набора ──────────────────────────────────────────────────────── */
const problems = [];
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
console.log(`\n=== seed_ctmath_ct2011_v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
console.log('Типы:', JSON.stringify(byType), '\n');
console.log('idx | type | subtopic | d | answer');
console.log('----+------+-----------------------+---+----------');
for (const t of TASKS) {
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
}
if (problems.length) {
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
problems.forEach(p => console.error(' - ' + p));
console.error('\nЗапись отменена из-за ошибок валидации.');
db.close();
process.exit(1);
}
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
if (!APPLY) {
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2011_v1.js --apply\n');
db.close();
process.exit(0);
}
const upsert = db.prepare(`
INSERT INTO exam_tasks
(exam_key, variant, task_idx, task_type, text_html, figure_html,
opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type = excluded.task_type,
text_html = excluded.text_html,
figure_html = excluded.figure_html,
opts_json = excluded.opts_json,
answer = excluded.answer,
solution_html = excluded.solution_html,
topic = excluded.topic,
subtopic = excluded.subtopic,
difficulty = excluded.difficulty
`);
let n = 0;
db.exec('BEGIN');
try {
for (const t of TASKS) {
upsert.run(
EXAM, VARIANT, t.idx, t.type,
t.text,
t.fig || null,
t.type === 'mc' ? JSON.stringify(t.opts) : null,
t.answer,
buildSolution(t),
t.topic, t.subtopic, t.diff
);
n++;
}
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2011».\n`);
} catch (e) {
db.exec('ROLLBACK');
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
process.exitCode = 1;
}
db.close();
+348
View File
@@ -0,0 +1,348 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_ct2012_v1.js
Чистый вариант-пробник для трека exam-prep `ctmath`.
Источник: Централизованное тестирование (ЦТ) по математике, 2012, Вариант 1.
Формат: Часть А = А1–А18, Часть В = В1–В12 (все В — числовые). Всего 30 заданий.
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2012\ЦТ 2012.pdf
(ответы — отдельный файл «Ответы 2012.pdf», столбец «Вариант 1»).
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
совпадение, включая B7=9, B10=84, B11=90, B12=-180. variant=120. Прогнан через
дедуп-гейт (check_variant_dups.js) — без повторов с видимым пулом.
Реконструкции заданий-«с-картинкой» (смысл/ответ сохранены, авто-проверка):
• А1 (равнобедренный треугольник) → пары углов даны числами (70°,40° → равнобедренный, №3);
• А13 (прямая/плоскость/двугранный угол) → все данные в тексте (площадь 14√3);
• B6 (середины сторон прямоугольника) → расположение M,N,P,Q задано в тексте (площадь 4).
А15 уточнена по таблице: радикал $\sqrt{5^{5}\cdot20}=250$, знаменатель $\sqrt[4]{10}$ → $25\sqrt[4]{10}$.
Без авторских ссылок (политика «все учебники наши»).
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
Запуск:
node backend/scripts/seed_ctmath_ct2012_v1.js # DRY-RUN (по умолчанию)
node backend/scripts/seed_ctmath_ct2012_v1.js --apply # запись в БД
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 120;
const N_TASKS = 30;
const PROV = 'ЦТ–2012, Вариант 1';
const R = String.raw;
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
const TASKS = [
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 1,
text: R`У каждого из пяти треугольников на рисунке известны два угла. Укажите номер треугольника, который является равнобедренным: $1)\ 55^\circ$ и $40^\circ$; $\ 2)\ 60^\circ$ и $40^\circ$; $\ 3)\ 70^\circ$ и $40^\circ$; $\ 4)\ 65^\circ$ и $40^\circ$; $\ 5)\ 75^\circ$ и $40^\circ$.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'в',
sol: R`Третий угол равен $180^\circ$ минус два данных. Для пары $70^\circ$ и $40^\circ$ третий угол $=70^\circ$, появляются два равных угла — треугольник равнобедренный (№3).` },
{ idx: 2, type: 'mc', topic: 'expressions', subtopic: 'expr-logarithms', diff: 2,
text: R`Укажите верное равенство:<br>$1)\ 3^{\log_3 3}=5$; $\ 2)\ \log_7 7=7$; $\ 3)\ \log_{31}\dfrac{1}{31}=-1$; $\ 4)\ \log_5 25=5$; $\ 5)\ \log_{23} 23=0$.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'в',
sol: R`$\log_{31}\dfrac{1}{31}=\log_{31}31^{-1}=-1$ — верно (равенство 3). Остальные ложны: $3^{\log_3 3}=3$, $\log_7 7=1$, $\log_5 25=2$, $\log_{23}23=1$.` },
{ idx: 3, type: 'mc', topic: 'numbers', subtopic: 'num-divisibility', diff: 1,
text: R`Сумма всех натуральных делителей числа $28$ равна:`,
opts: mc('$55$', '$11$', '$9$', '$27$', '$56$'),
answer: 'д',
sol: R`Делители $28$: $1,2,4,7,14,28$. Их сумма $=56$.` },
{ idx: 4, type: 'mc', topic: 'equations', subtopic: 'eq-quadratic', diff: 2,
text: R`Даны квадратные уравнения: $1)\ 4x^{2}-3x-3=0$; $\ 2)\ 5x^{2}+20x+20=0$; $\ 3)\ 2x^{2}+3x+12=0$; $\ 4)\ 7x^{2}-4x-5=0$; $\ 5)\ 4x^{2}+8x+4=0$. Укажите уравнение, которое не имеет корней.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'в',
sol: R`Корней нет при $D<0$. Для $2x^{2}+3x+12=0$: $D=9-96=-87<0$ (№3). У остальных $D\ge0$.` },
{ idx: 5, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
text: R`Если $10^{2}\cdot\alpha=741{,}63287$, то значение $\alpha$ с точностью до сотых равно:`,
opts: mc('$74{,}16$', '$7{,}42$', '$7{,}41$', '$74\,163{,}29$', '$7416{,}33$'),
answer: 'б',
sol: R`$\alpha=\dfrac{741{,}63287}{100}=7{,}4163287\approx7{,}42$.` },
{ idx: 6, type: 'mc', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 2,
text: R`Число $133$ является членом арифметической прогрессии $4,\ 7,\ 10,\ 13,\ \ldots$ Укажите его номер.`,
opts: mc('$44$', '$42$', '$40$', '$46$', '$48$'),
answer: 'а',
sol: R`$a_n=4+3(n-1)=3n+1$. Из $3n+1=133$ получаем $n=44$.` },
{ idx: 7, type: 'mc', topic: 'equations', subtopic: 'eq-modulus', diff: 2,
text: R`Решите неравенство $|-x|\ge5$.`,
opts: mc('$x\in[5;+\infty)$', '$x\in(-\infty;-5]$', '$x\in[-5;5]$', '$x\in(-\infty;-5]\cup[5;+\infty)$', '$x_1=-5,\ x_2=5$'),
answer: 'г',
sol: R`$|-x|=|x|\ge5$ равносильно $x\le-5$ или $x\ge5$, то есть $x\in(-\infty;-5]\cup[5;+\infty)$.` },
{ idx: 8, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
text: R`Вычислите $\dfrac{3{,}2+0{,}8:\left(\tfrac16+\tfrac13\right)}{0{,}1}$.`,
opts: mc('$48$', '$0{,}48$', '$4{,}8$', '$80$', '$0{,}8$'),
answer: 'а',
sol: R`$\tfrac16+\tfrac13=\tfrac12$, $0{,}8:\tfrac12=1{,}6$, числитель $=3{,}2+1{,}6=4{,}8$. Делим на $0{,}1$: $48$.` },
{ idx: 9, type: 'mc', topic: 'planimetry', subtopic: 'plan-circles', diff: 1,
text: R`Площадь круга равна $81\pi$. Диаметр этого круга равен:`,
opts: mc('$18$', '$18\pi$', '$9$', '$9\pi$', '$81$'),
answer: 'а',
sol: R`$\pi r^{2}=81\pi$, $r=9$, диаметр $=18$.` },
{ idx: 10, type: 'mc', topic: 'trigonometry', subtopic: 'trig-equations', diff: 2,
text: R`Найдите наименьший положительный корень уравнения $\sin2x=\dfrac12$.`,
opts: mc('$\dfrac{\pi}{6}$', '$\dfrac{\pi}{12}$', '$\dfrac{\pi}{3}$', '$\dfrac{5\pi}{12}$', '$\dfrac{\pi}{8}$'),
answer: 'б',
sol: R`$2x=\dfrac{\pi}{6}+2\pi k$ или $2x=\dfrac{5\pi}{6}+2\pi k$, поэтому $x=\dfrac{\pi}{12}+\pi k$ или $x=\dfrac{5\pi}{12}+\pi k$. Наименьший положительный — $\dfrac{\pi}{12}$.` },
{ idx: 11, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
text: R`Четырёхугольник $MNPK$, в котором $\angle N=128^\circ$, вписан в окружность. Найдите градусную меру угла $K$.`,
opts: mc('$64^\circ$', '$128^\circ$', '$100^\circ$', '$180^\circ$', '$52^\circ$'),
answer: 'д',
sol: R`У вписанного четырёхугольника суммы противоположных углов равны $180^\circ$. Углы $N$ и $K$ противоположны, поэтому $\angle K=180^\circ-128^\circ=52^\circ$.` },
{ idx: 12, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 2,
text: R`На одной чаше уравновешенных весов лежат $3$ яблока и $1$ груша, на другой — $2$ яблока, $2$ груши и гирька весом $20$ г. Каков вес одного яблока (в граммах), если все фрукты вместе весят $780$ г? Считайте все яблоки одинаковыми по весу и все груши одинаковыми по весу.`,
opts: mc('$95$', '$105$', '$100$', '$125$', '$115$'),
answer: 'б',
sol: R`Равновесие: $3a+p=2a+2p+20$, то есть $a-p=20$. Все фрукты: $5a+3p=780$. Отсюда $a=105$, $p=85$.` },
{ idx: 13, type: 'mc', topic: 'stereometry', subtopic: 'ster-lines-planes', diff: 3,
text: R`Прямая $a$, параллельная плоскости $\alpha$, находится от неё на расстоянии $6$. Через прямую $a$ проведена плоскость $\beta$, пересекающая плоскость $\alpha$ по прямой $b$ и образующая с ней угол $60^\circ$. Найдите площадь четырёхугольника $ABCD$, если $A$ и $B$ — точки прямой $a$, причём $AB=4$, а $C$ и $D$ — такие точки прямой $b$, что $CD=3$.`,
opts: mc('$42$', '$42\sqrt3$', '$\dfrac{21\sqrt3}{2}$', '$10{,}5$', '$14\sqrt3$'),
answer: 'д',
sol: R`Прямые $a$ и $b$ параллельны, поэтому $ABCD$ — трапеция с основаниями $AB=4$ и $CD=3$. Её высота (расстояние между $a$ и $b$ в плоскости $\beta$) равна $\dfrac{6}{\sin60^\circ}=4\sqrt3$. Площадь $=\dfrac{4+3}{2}\cdot4\sqrt3=14\sqrt3$.` },
{ idx: 14, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Упростите выражение $\dfrac{125^{x}+25^{x}-12\cdot5^{x}}{5^{x}\left(5^{x}-3\right)}$.`,
opts: mc('$5^{x}$', '$125^{x}-4$', '$5^{x}+4$', '$5^{x}-4$', '$2\cdot5^{x}$'),
answer: 'в',
sol: R`Пусть $u=5^{x}$. Числитель $=u^{3}+u^{2}-12u=u(u+4)(u-3)$, знаменатель $=u(u-3)$. Дробь $=u+4=5^{x}+4$.` },
{ idx: 15, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 3,
text: R`Корень уравнения $\sqrt{10}\cdot x=\dfrac{\sqrt{5^{5}\cdot20}}{\sqrt[4]{10}}$ равен:`,
opts: mc('$25\sqrt[4]{10}$', '$50\sqrt2$', '$25\sqrt[5]{50}$', '$4\sqrt[3]{20}$', '$10\sqrt{10}$'),
answer: 'а',
sol: R`$\sqrt{5^{5}\cdot20}=\sqrt{5^{6}\cdot4}=5^{3}\cdot2=250$, поэтому $x=\dfrac{250}{\sqrt{10}\cdot\sqrt[4]{10}}=\dfrac{250}{10^{3/4}}=25\cdot10^{1/4}=25\sqrt[4]{10}$.` },
{ idx: 16, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 2,
text: R`Какая из прямых $1)\ y=-3$; $\ 2)\ y=-1{,}5$; $\ 3)\ y=0$; $\ 4)\ y=4{,}3$; $\ 5)\ y=2$ пересекает график функции $y=\dfrac14 x^{2}-3x+11$ в двух точках?`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'г',
sol: R`Вершина параболы: $x=6$, $y_{\min}=\dfrac14\cdot36-18+11=2$, ветви вверх. Прямая $y=c$ пересекает график в двух точках при $c>2$. Это $y=4{,}3$ (№4).` },
{ idx: 17, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
text: R`Если $\dfrac{5x}{y}=\dfrac12$, то значение выражения $\dfrac{3y+9x}{13x-y}$ равно:`,
opts: mc('$12$', '$13$', '$\dfrac{11}{7}$', '$\dfrac{93}{129}$', '$\dfrac{1}{13}$'),
answer: 'б',
sol: R`Из $\dfrac{5x}{y}=\dfrac12$ следует $y=10x$. Тогда $\dfrac{3\cdot10x+9x}{13x-10x}=\dfrac{39x}{3x}=13$.` },
{ idx: 18, type: 'mc', topic: 'equations', subtopic: 'eq-logarithmic', diff: 3,
text: R`Наименьшее целое решение неравенства $\lg(x^{2}-2x-8)-\lg(x+2)\le\lg4$ равно:`,
opts: mc('$1$', '$-2$', '$4$', '$5$', '$8$'),
answer: 'г',
sol: R`ОДЗ: $x>4$. На нём $\dfrac{x^{2}-2x-8}{x+2}=x-4$, и неравенство $\lg(x-4)\le\lg4$ даёт $x\le8$. Итого $4<x\le8$; наименьшее целое — $5$.` },
// ── Часть B: В1–В12 (все числовые) ───────────────────────────────────────
{ idx: 19, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 3,
text: R`Если в правильной четырёхугольной пирамиде высота равна $4$, а площадь диагонального сечения равна $12$, то её объём равен … .`,
answer: '24',
sol: R`Диагональное сечение — треугольник с основанием $d$ (диагональ квадрата) и высотой $4$: $\tfrac12 d\cdot4=12$, $d=6$. Сторона основания $a=\dfrac{d}{\sqrt2}=3\sqrt2$, площадь основания $=18$. Объём $=\tfrac13\cdot18\cdot4=24$.` },
{ idx: 20, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 2,
text: R`Найдите количество всех целых решений неравенства $\dfrac{64x-x^{3}}{5x}>0$.`,
answer: '14',
sol: R`При $x\ne0$ неравенство равносильно $\dfrac{64-x^{2}}{5}>0$, то есть $-8<x<8$. Целые (без $0$): от $-7$ до $7$ — это $14$ чисел.` },
{ idx: 21, type: 'open', topic: 'planimetry', subtopic: 'plan-coordinates', diff: 3,
text: R`Точки $A(1;2)$, $B(5;6)$ и $C(8;6)$ — вершины трапеции $ABCD$ ($AD\parallel BC$). Найдите сумму координат точки $D$, если $BD=4\sqrt2$.`,
answer: '11',
sol: R`$BC$ горизонтальна, значит $AD$ тоже горизонтальна и $D$ имеет ординату $2$. Из $BD^{2}=(d-5)^{2}+16=32$ получаем $d=9$ ($d=1$ даёт $D=A$). Тогда $D(9;2)$, сумма координат $11$.` },
{ idx: 22, type: 'open', topic: 'planimetry', subtopic: 'plan-polygons', diff: 3,
text: R`Найдите периметр правильного шестиугольника, меньшая диагональ которого равна $10\sqrt3$.`,
answer: '60',
sol: R`У правильного шестиугольника меньшая диагональ равна $a\sqrt3$, поэтому $a\sqrt3=10\sqrt3$, $a=10$. Периметр $=6a=60$.` },
{ idx: 23, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
text: R`Найдите произведение корней уравнения $4^{x^{2}}+128=3^{1-x^{2}}\cdot12^{x^{2}}$.`,
answer: '-3',
sol: R`Пусть $u=x^{2}$. Так как $3^{1-u}\cdot12^{u}=3\cdot4^{u}$, уравнение даёт $4^{u}+128=3\cdot4^{u}$, $4^{u}=64$, $u=3$. Тогда $x=\pm\sqrt3$, произведение корней $-3$.` },
{ idx: 24, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 5,
text: R`Площадь прямоугольника $ABCD$ равна $20$. Точки $M$, $N$, $P$, $Q$ — середины его сторон $AB$, $BC$, $CD$, $DA$ соответственно. Найдите площадь четырёхугольника, заключённого между прямыми $AN$, $BP$, $CQ$ и $DM$.`,
answer: '4',
sol: R`Прямые $AN\parallel CQ$ и $BP\parallel DM$, поэтому внутренний четырёхугольник — параллелограмм. Координатный расчёт показывает, что его площадь составляет $\dfrac15$ площади прямоугольника: $\dfrac{20}{5}=4$.` },
{ idx: 25, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 4,
text: R`Решите уравнение $x^{2}-7x+10=\dfrac{7}{x^{2}-11x+28}$ и найдите сумму его корней.`,
answer: '9',
sol: R`Уравнение приводится к $(x^{2}-9x+21)(x^{2}-9x+13)=0$. Первый множитель действительных корней не имеет ($D<0$), второй даёт корни с суммой $9$.` },
{ idx: 26, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 4,
text: R`Найдите значение выражения $16\sin\left(\alpha-\dfrac{\pi}{4}\right)$, если $\sin2\alpha=\dfrac{23}{32}$ и $2\alpha\in\left(0;\dfrac{\pi}{2}\right)$.`,
answer: '-6',
sol: R`$16\sin\left(\alpha-\tfrac{\pi}{4}\right)=8\sqrt2(\sin\alpha-\cos\alpha)$. Так как $(\sin\alpha-\cos\alpha)^{2}=1-\sin2\alpha=\tfrac{9}{32}$ и при $\alpha<\tfrac{\pi}{4}$ разность отрицательна, $\sin\alpha-\cos\alpha=-\tfrac{3\sqrt2}{8}$. Значение $=8\sqrt2\cdot\left(-\tfrac{3\sqrt2}{8}\right)=-6$.` },
{ idx: 27, type: 'open', topic: 'functions', subtopic: 'fn-properties', diff: 4,
text: R`Найдите сумму целых значений $x$, принадлежащих области определения функции $y=\log_{2-x}\left(12-x-x^{2}\right)$.`,
answer: '-6',
sol: R`Условия: $2-x>0$, $2-x\ne1$ и $12-x-x^{2}>0$. Получаем $-4<x<2$, $x\ne1$. Целые: $-3,-2,-1,0$; их сумма $-6$.` },
{ idx: 28, type: 'open', topic: 'stereometry', subtopic: 'ster-rotation', diff: 5,
text: R`Прямоугольный треугольник с катетами, равными $6$ и $2\sqrt7$, вращается вокруг оси, содержащей его гипотенузу. Найдите значение выражения $\dfrac{2V}{\pi}$, где $V$ — объём фигуры вращения.`,
answer: '84',
sol: R`Гипотенуза $=\sqrt{36+28}=8$, высота к ней $h=\dfrac{6\cdot2\sqrt7}{8}=\dfrac{3\sqrt7}{2}$. Фигура — два конуса с общим основанием: $V=\tfrac13\pi h^{2}\cdot8=\tfrac13\pi\cdot\tfrac{63}{4}\cdot8=42\pi$. Тогда $\dfrac{2V}{\pi}=84$.` },
{ idx: 29, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 5,
text: R`Из двух растворов с различным процентным содержанием спирта массой $100$ г и $900$ г отлили по одинаковому количеству. Каждый из отлитых растворов долили в остаток другого раствора, после чего процентное содержание спирта в обоих растворах стало одинаковым. Найдите, сколько раствора (в граммах) было отлито из каждого раствора.`,
answer: '90',
sol: R`Пусть отлито по $m$ г. Равенство итоговых концентраций приводит к $(900-10m)(c_1-c_2)=0$. Поскольку концентрации различны, $900-10m=0$, $m=90$.` },
{ idx: 30, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 5,
text: R`Найдите произведение корней уравнения $x-\sqrt{x^{2}-36}=\dfrac{(x-6)^{2}}{2x+12}$.`,
answer: '-180',
sol: R`ОДЗ: $|x|\ge6$. После преобразований и возведения в квадрат получаем $x^{4}-168x^{2}-2160=0$, откуда $x^{2}=180$, то есть $x=\pm6\sqrt5$ (оба корня подходят). Произведение $=-180$.` },
];
/* ── Сборка solution_html ────────────────────────────────────────────────── */
function ansShowOf(t) {
if (t.ansShow != null) return t.ansShow;
if (t.type === 'mc') return `${t.answer})`;
return `$${t.answer}$`;
}
function buildSolution(t) {
const ans = ansShowOf(t);
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(userInput, canonical) {
if (userInput == null || canonical == null) return false;
const c = String(canonical).trim();
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false;
const cn = srvToNumber(c), un = srvToNumber(userInput);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
/* ── Валидация набора ──────────────────────────────────────────────────────── */
const problems = [];
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
console.log(`\n=== seed_ctmath_ct2012_v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
console.log('Типы:', JSON.stringify(byType), '\n');
console.log('idx | type | subtopic | d | answer');
console.log('----+------+-----------------------+---+----------');
for (const t of TASKS) {
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
}
if (problems.length) {
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
problems.forEach(p => console.error(' - ' + p));
console.error('\nЗапись отменена из-за ошибок валидации.');
db.close();
process.exit(1);
}
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
if (!APPLY) {
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2012_v1.js --apply\n');
db.close();
process.exit(0);
}
const upsert = db.prepare(`
INSERT INTO exam_tasks
(exam_key, variant, task_idx, task_type, text_html, figure_html,
opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type = excluded.task_type,
text_html = excluded.text_html,
figure_html = excluded.figure_html,
opts_json = excluded.opts_json,
answer = excluded.answer,
solution_html = excluded.solution_html,
topic = excluded.topic,
subtopic = excluded.subtopic,
difficulty = excluded.difficulty
`);
let n = 0;
db.exec('BEGIN');
try {
for (const t of TASKS) {
upsert.run(
EXAM, VARIANT, t.idx, t.type,
t.text,
t.fig || null,
t.type === 'mc' ? JSON.stringify(t.opts) : null,
t.answer,
buildSolution(t),
t.topic, t.subtopic, t.diff
);
n++;
}
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2012».\n`);
} catch (e) {
db.exec('ROLLBACK');
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
process.exitCode = 1;
}
db.close();
+58
View File
@@ -0,0 +1,58 @@
'use strict';
/* Авто-здоровье LLM-провайдеров Квантика: периодический пинг каждого провайдера
* (lightweight pingLLM) + авто-понижение активного, если он стабильно не отвечает,
* а есть здоровый запасной. Результат — в app_settings.assistant_health (JSON-карта
* { id: { ok, at, error, ms, fails } }). Авто-переключение пишет тот же
* assistant_failover, что показывает баннер в админке. Период — 15 мин (вкл. по
* умолчанию; app_settings.assistant_health_enabled='0' выключает). */
const db = require('./db/db');
const logger = require('./utils/logger');
function _get(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key=?').get(k); return r && r.value != null ? r.value : null; }
function _set(k, v) { db.prepare('INSERT OR REPLACE INTO app_settings (key,value) VALUES (?,?)').run(k, v); }
function _providers() { try { return JSON.parse(_get('assistant_providers') || '[]') || []; } catch (e) { return []; } }
function _enabled() { return _get('assistant_health_enabled') !== '0'; }
function _noKey(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || '') || /\/\/[^/]*\bpollinations\.ai\b/i.test(u || ''); }
async function runHealthCheck() {
if (!_enabled()) return { skipped: true };
const provs = _providers();
if (!provs.length) return { providers: 0 };
const a = require('./controllers/assistantController');
let prev = {}; try { prev = JSON.parse(_get('assistant_health') || '{}') || {}; } catch (e) {}
const health = {};
for (const p of provs) {
// нет ключа и не keyless-шлюз — не пингуем (в FAQ-режиме), помечаем как «нет ключа»
if (!p.key && !_noKey(p.url)) { health[p.id] = { ok: false, at: new Date().toISOString(), error: 'нет ключа', ms: 0, fails: (prev[p.id] && prev[p.id].fails || 0) }; continue; }
const t0 = Date.now();
let r; try { r = await a.pingLLM({ url: p.url, model: p.model, key: p.key }); } catch (e) { r = { ok: false, error: 'сбой' }; }
const ok = !!(r && r.ok);
health[p.id] = {
ok, at: new Date().toISOString(), ms: Date.now() - t0,
error: ok ? null : String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 140),
fails: ok ? 0 : ((prev[p.id] && prev[p.id].fails || 0) + 1),
};
}
_set('assistant_health', JSON.stringify(health));
// авто-понижение активного: 2+ подряд неудач И есть здоровый рабочий запасной
const activeId = _get('assistant_active');
const active = provs.find(p => p.id === activeId);
if (active && health[activeId] && !health[activeId].ok && health[activeId].fails >= 2) {
const healthy = provs.find(p => p.id !== activeId && health[p.id] && health[p.id].ok && (p.key || _noKey(p.url)));
if (healthy) {
_set('assistant_active', healthy.id);
_set('assistant_failover', JSON.stringify({ at: new Date().toISOString(), failedId: activeId, failedName: active.name, servedId: healthy.id, servedName: healthy.name, reason: 'health', auto: true }));
logger.info('assistant-health auto-demote', { from: active.name, to: healthy.name, fails: health[activeId].fails });
}
}
return { providers: provs.length, health };
}
function schedule() {
const run = () => { runHealthCheck().catch(() => {}); };
setTimeout(run, 90_000).unref(); // первый прогон через 1.5 мин после старта
setInterval(run, 15 * 60 * 1000).unref(); // далее каждые 15 минут
}
module.exports = { runHealthCheck, schedule };
+194 -20
View File
@@ -1,7 +1,10 @@
const db = require('../db/db');
const fs = require('fs');
const path = require('path');
const { stripTags } = require('../utils/sanitize');
const { audit } = require('../utils/audit');
const { purgeAccessFor } = require('../services/contentAccess');
const sysReset = require('../services/systemReset');
/* ── Prepared statements ──────────────────────────────────────────────── */
const stmts = {
@@ -292,13 +295,18 @@ function getUserSessions(req, res) {
/* ── GET /api/admin/sessions ─────────────────────────────────────────── */
function getAllSessions(req, res) {
const { subject, user_id } = req.query;
const { subject, user_id, status } = req.query;
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 200));
const offset = Math.max(0, Number(req.query.offset) || 0);
const where = ['ts.status = \'completed\''];
// По умолчанию показываем и завершённые, и НЕзавершённые (in_progress) — иначе зависшие
// сессии не находились в списке (см. алерт «Зависла»). Опционально сужаем по ?status=.
const where = [];
const params = [];
if (status && ['completed', 'in_progress', 'abandoned'].includes(status)) {
where.push('ts.status = ?'); params.push(status);
}
if (subject) { where.push('s.slug = ?'); params.push(subject); }
if (user_id) { where.push('ts.user_id = ?'); params.push(Number(user_id)); }
@@ -314,7 +322,7 @@ function getAllSessions(req, res) {
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
JOIN users u ON u.id = ts.user_id
WHERE ${where.join(' AND ')}
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY ts.started_at DESC
LIMIT ? OFFSET ?
`).all(...params);
@@ -525,7 +533,7 @@ function getFeatures(_req, res) {
function updateFeatures(req, res) {
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
'gamification', 'assistant', 'sim_builder', 'quantik'];
'gamification', 'assistant', 'sim_builder', 'quantik', 'theory', 'lab', 'sitemap', 'wishes'];
const updates = req.body;
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
@@ -586,6 +594,56 @@ function updateFreeStudentFeatures(req, res) {
res.json({ ok: true });
}
/* ── GET /api/admin/reset-system/plan ──────────────────────────────────
План «чистого запуска»: что переназначится / сотрётся / неизвестно. Без изменений. */
function getResetPlan(req, res) {
try {
const plan = sysReset.classify(db);
// Текущий админ остаётся залогиненным — сохраняем именно его, не min-id.
res.json({ ...plan, keptAdmin: { id: req.user.id, email: req.user.email, name: req.user.name } });
} catch (e) {
res.status(500).json({ error: 'Не удалось построить план: ' + e.message });
}
}
/* ── POST /api/admin/reset-system ──────────────────────────────────────
⚠️ ДЕСТРУКТИВНО. Только admin. Требует body.confirm === 'СБРОС' (или 'RESET').
Делает бэкап БД, сохраняет ТЕКУЩЕГО админа (оператор остаётся в системе),
стирает остальных пользователей + активность, переназначает контент. */
function resetSystem(req, res) {
const confirm = (req.body && req.body.confirm) || '';
if (confirm !== 'СБРОС' && confirm !== 'RESET') {
return res.status(400).json({ error: 'Подтверждение не совпало. Введите СБРОС.' });
}
const keptId = req.user.id;
// 1) Бэкап ДО любых изменений (checkpoint WAL → копия основного файла).
let backupName = null;
try {
const dbPath = db._path;
if (!dbPath) throw new Error('путь к БД неизвестен');
const backupsDir = path.join(path.dirname(dbPath), 'backups');
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* не WAL — ок */ }
const d = new Date();
const p2 = n => String(n).padStart(2, '0');
const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
backupName = `learnspace-prereset-${ts}.db`;
fs.copyFileSync(dbPath, path.join(backupsDir, backupName));
} catch (e) {
return res.status(500).json({ error: 'Бэкап не удался — сброс отменён: ' + e.message });
}
// 2) Сброс (бросает при ошибке → откат внутри сервиса, данные целы).
let summary;
try {
summary = sysReset.runReset(db, keptId);
} catch (e) {
return res.status(500).json({ error: 'Сброс не выполнен (откат): ' + e.message, backup: backupName });
}
// 3) Аудит ПОСЛЕ сброса (admin_audit_log очищается сбросом — пишем первой записью).
try { audit(req, 'system.reset', 'system', `keptAdmin=${keptId} backup=${backupName} deleted=${summary.deletedUsers}`); } catch {}
res.json({ ok: true, backup: backupName, ...summary });
}
/* ── GET /api/admin/audit-log ───────────────────────────────────────── */
function getAuditLog(req, res) {
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
@@ -659,8 +717,6 @@ function clearSecurityLog(req, res) {
/* ── GET /api/admin/health ─────────────────────────────────────────── */
const os = require('os');
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const { monitorEventLoopDelay } = require('perf_hooks');
const sse = require('../sse');
@@ -879,29 +935,41 @@ function broadcast(req, res) {
/* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */
const ASSISTANT_PRESETS = [
{ name: 'Kilo Code (бесплатно)', url: 'https://kilocode.ai/api/openrouter/chat/completions', model: 'nvidia/nemotron-3-ultra-550b-a55b:free' },
{ name: 'Kilo Code (бесплатно)', url: 'https://kilocode.ai/api/openrouter/chat/completions', model: 'nvidia/nemotron-3-super-120b-a12b:free' },
{ name: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-2.5-flash' },
{ name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' },
{ name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' },
{ name: 'HuggingFace Router', url: 'https://router.huggingface.co/v1/chat/completions', model: 'Qwen/Qwen2.5-72B-Instruct' },
{ name: 'Pollinations (без ключа)', url: 'https://text.pollinations.ai/openai', model: 'openai' },
{ name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' },
];
// Проверенные бесплатные модели Kilo (чистый русский) — для выпадающего списка
// Проверенные бесплатные модели шлюза Kilo (отдают чистый русский). Порядок — от мощных к лёгким.
// ctx — окно контекста, out — макс. токенов в ответе (данные из /api/openrouter/models). Все бесплатные ($0).
// Сверено с live-списком шлюза и протестировано на русский 2026-06-24 (% — доля кириллицы в тест-ответе):
// owl-alpha 95%, nemotron-super 91%, nano-omni 99%, laguna-xs.2 92%, openrouter/free 100% — чисто;
// nemotron-ultra/laguna-m.1 — существуют, но на free-тарифе бывает таймаут; nex-n2-pro удалён со шлюза.
const KILO_MODELS = [
{ id: 'nvidia/nemotron-3-ultra-550b-a55b:free', label: 'Nemotron 550B — флагман (1M)', ctx: 1000000, out: 65536 },
{ id: 'nvidia/nemotron-3-super-120b-a12b:free', label: 'Nemotron 120B — баланс (1M)', ctx: 1000000, out: 262144 },
{ id: 'nex-agi/nex-n2-pro:free', label: 'Nex N2 Pro — чистый русский (262K)', ctx: 262144, out: 65536 },
{ id: 'openrouter/owl-alpha', label: 'Owl Alphaчистый русский (1M)', ctx: 1048756, out: 262144 },
{ id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', label: 'Nemotron Nano 30B — быстрая (256K)', ctx: 256000, out: 65536 },
{ id: 'poolside/laguna-m.1:free', label: 'Laguna M.1 — быстрая (262K)', ctx: 262144, out: 32768 },
{ id: 'poolside/laguna-xs.2:free', label: 'Laguna XS — лёгкая (262K)', ctx: 262144, out: 32768 },
{ id: 'nvidia/nemotron-3-super-120b-a12b:free', label: 'Nemotron 120B — баланс, быстрый (262K)', ctx: 262144, out: 262144 },
{ id: 'openrouter/owl-alpha', label: 'Owl Alpha — чистый русский (1M)', ctx: 1048576, out: 262144 },
{ id: 'nvidia/nemotron-3-ultra-550b-a55b:free', label: 'Nemotron 550B — флагман, медленный (1M)', ctx: 1000000, out: 65536 },
{ id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', label: 'Nemotron Nano 30Bбыстрый, мультимодальный (256K)', ctx: 256000, out: 65536 },
{ id: 'poolside/laguna-m.1:free', label: 'Laguna M.1 (262K)', ctx: 262144, out: 32768 },
{ id: 'poolside/laguna-xs.2:free', label: 'Laguna XS.2 — лёгкая, быстрая (262K)', ctx: 262144, out: 32768 },
{ id: 'openrouter/free', label: 'Авто-роутер (бесплатные модели)', ctx: 200000, out: 32768 },
];
function _aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; }
// Рабочий список бесплатных моделей: обновлённый сканом (app_settings) либо хардкод KILO_MODELS как сид.
function _kiloModels() {
try { const r = _aset('assistant_kilo_models'); if (r) { const a = JSON.parse(r); if (Array.isArray(a) && a.length) return a; } } catch (e) {}
return KILO_MODELS;
}
function _aProviders() { try { return JSON.parse(_aset('assistant_providers') || '[]') || []; } catch (e) { return []; } }
function _aSetProviders(arr) { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_providers', ?)").run(JSON.stringify(arr)); }
function _aIsLocal(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || ''); }
// Шлюзы с бесплатным инференсом без ключа (как localhost): ключ не обязателен.
function _aNoKey(u) { return _aIsLocal(u) || /\/\/[^/]*\bpollinations\.ai\b/i.test(u || ''); }
function getAssistant(_req, res) {
// Миграция legacy-настроек в список провайдеров (один раз)
@@ -915,10 +983,10 @@ function getAssistant(_req, res) {
}
}
const list = _aProviders();
const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key, ctx: p.ctx || null, out: p.out || null, free: (typeof p.free === 'boolean' ? p.free : null) }));
const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key, noKey: _aNoKey(p.url), ctx: p.ctx || null, out: p.out || null, free: (typeof p.free === 'boolean' ? p.free : null) }));
const activeId = _aset('assistant_active') || (providers[0] && providers[0].id) || null;
const ap = list.find(p => p.id === activeId);
const active = !!(ap && (ap.key || _aIsLocal(ap.url)));
const active = !!(ap && (ap.key || _aNoKey(ap.url)));
let chunks = 0, usage = { model_calls: 0, cache_hits: 0, faq: 0 }, usage30 = { model_calls: 0, cache_hits: 0, faq: 0 };
try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {}
@@ -939,8 +1007,11 @@ function getAssistant(_req, res) {
res.json({
providers, activeId, active,
rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1',
memory: _aset('assistant_memory') !== '0',
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: KILO_MODELS,
memory: _aset('assistant_memory') !== '0', socratic: _aset('assistant_socratic') === '1',
healthEnabled: _aset('assistant_health_enabled') !== '0',
health: (() => { try { return JSON.parse(_aset('assistant_health') || '{}') || {}; } catch (e) { return {}; } })(),
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS,
kiloModels: _kiloModels(), kiloModelsCustom: !!_aset('assistant_kilo_models'),
});
}
@@ -951,6 +1022,8 @@ function saveAssistant(req, res) {
if (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0');
if (typeof b.examButtons === 'boolean') set('assistant_exam_buttons', b.examButtons ? '1' : '0');
if (typeof b.memory === 'boolean') set('assistant_memory', b.memory ? '1' : '0');
if (typeof b.socratic === 'boolean') set('assistant_socratic', b.socratic ? '1' : '0');
if (typeof b.healthEnabled === 'boolean') set('assistant_health_enabled', b.healthEnabled ? '1' : '0');
if (b.dismissFailover) { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} }
audit(req, 'assistant.config', 'assistant', 'настройки');
res.json({ ok: true });
@@ -1050,6 +1123,105 @@ async function getProviderModels(req, res) {
res.json({ models: r.models, current });
}
/* ── Сканер бесплатных моделей шлюза (наполняет список KILO_MODELS) ───────── */
// Заведомо не-чат модели (музыка/картинки/эмбеддинги/модерация) — не тестируем.
const _NONCHAT_RE = /(lyria|whisper|tts|embed|rerank|moderation|content-safety|guard|dall-?e|imagen|sora|veo|\bmusic\b)/i;
// Kilo-провайдер (со шлюзом kilocode.ai): по id, иначе активный, иначе первый с ключом.
function _pickKiloProvider(id) {
const arr = _aProviders();
if (id) return arr.find(p => p.id === id) || null;
const active = arr.find(p => p.id === _aset('assistant_active'));
if (active && /kilocode\.ai/.test(active.url || '') && active.key) return active;
return arr.find(p => /kilocode\.ai/.test(p.url || '') && p.key)
|| arr.find(p => /kilocode\.ai/.test(p.url || '')) || null;
}
/* POST /api/admin/assistant/scan { id? } — найти бесплатные модели на шлюзе провайдера.
* Без инференса: список + сверка с текущим рабочим списком (что новое / что исчезло). */
async function scanModels(req, res) {
const prov = _pickKiloProvider(req.body && req.body.id);
if (!prov) return res.json({ error: 'Нет Kilo-провайдера. Добавьте провайдера со шлюзом kilocode.ai с ключом.' });
const r = await _fetchModels(prov.url, prov.key);
if (r.error) return res.json({ error: r.error, status: r.status });
const cur = _kiloModels();
const curIds = new Set(cur.map(m => m.id));
const liveIds = new Set(r.models.map(m => m.id));
const free = r.models
.filter(m => (m.free === true || /:free$/.test(m.id)) && !_NONCHAT_RE.test(m.id))
.map(m => ({ id: m.id, ctx: m.ctx, out: m.out, status: curIds.has(m.id) ? 'current' : 'new' }));
free.sort((a, b) => (a.status === b.status ? (b.ctx || 0) - (a.ctx || 0) : a.status === 'current' ? -1 : 1));
const gone = cur.filter(m => !liveIds.has(m.id)).map(m => ({ id: m.id, label: m.label }));
res.json({ providerId: prov.id, providerName: prov.name, total: r.models.length, models: free, gone, current: cur });
}
/* POST /api/admin/assistant/probe { id?, model } — один тест-запрос на русском. */
async function probeModel(req, res) {
const b = req.body || {};
const prov = _pickKiloProvider(b.id);
if (!prov) return res.json({ ok: false, error: 'нет провайдера' });
const model = String(b.model || '').trim().slice(0, 120);
if (!model) return res.json({ ok: false, error: 'нет модели' });
if (typeof fetch !== 'function') return res.json({ ok: false, error: 'fetch недоступен' });
const PROMPT = 'Ученик 9 класса спрашивает: что такое синус острого угла в прямоугольном треугольнике? Объясни кратко и понятно. Отвечай только на русском языке.';
const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 22000);
const t0 = Date.now();
try {
const r = await fetch(prov.url, {
method: 'POST', signal: ctrl.signal,
headers: Object.assign({ 'Content-Type': 'application/json' }, prov.key ? { Authorization: 'Bearer ' + prov.key } : {}),
body: JSON.stringify({ model, max_tokens: 160, temperature: 0.3, messages: [{ role: 'user', content: PROMPT }] }),
});
const ms = Date.now() - t0;
const txt = await r.text();
if (!r.ok) {
let msg = txt.slice(0, 200);
try { const j = JSON.parse(txt); if (j && j.error) msg = String(j.error.message || JSON.stringify(j.error)).slice(0, 200); } catch (e) {}
return res.json({ ok: false, status: r.status, ms, error: msg });
}
let sample = '';
try { const j = JSON.parse(txt); const m = j.choices && j.choices[0] && j.choices[0].message; sample = String((m && (m.content || m.reasoning)) || '').trim(); } catch (e) {}
const letters = (sample.match(/[A-Za-zА-Яа-яЁё一-鿿]/g) || []).length;
const cyr = (sample.match(/[А-Яа-яЁё]/g) || []).length;
const cjk = (sample.match(/[一-鿿]/g) || []).length;
const ratio = letters ? cyr / letters : 0;
const verdict = !sample ? 'пусто' : cjk > 0 ? 'иероглифы' : ratio > 0.55 ? 'чистый русский' : ratio > 0.2 ? 'смешанный' : 'не русский';
res.json({ ok: true, ms, ratio: Math.round(ratio * 100), cjk, verdict, sample: sample.replace(/\s+/g, ' ').slice(0, 180) });
} catch (e) { res.json({ ok: false, ms: Date.now() - t0, error: e.name === 'AbortError' ? 'таймаут' : 'сеть' }); }
finally { clearTimeout(timer); }
}
/* POST /api/admin/assistant/models/apply { models:[{id,label,ctx,out}] | reset:true } */
function applyModels(req, res) {
const b = req.body || {};
if (b.reset) {
try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_kilo_models'").run(); } catch (e) {}
audit(req, 'assistant.models', 'kilo', 'сброс к встроенному');
return res.json({ ok: true, reset: true });
}
const arr = Array.isArray(b.models) ? b.models : null;
if (!arr) return res.status(400).json({ error: 'models[] обязателен' });
const clean = [];
for (const m of arr.slice(0, 40)) {
const id = String((m && m.id) || '').trim().slice(0, 120);
if (!id) continue;
clean.push({ id, label: String((m && m.label) || id).trim().slice(0, 80), ctx: Number(m && m.ctx) || null, out: Number(m && m.out) || null });
}
if (!clean.length) return res.status(400).json({ error: 'пустой список' });
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_kilo_models', ?)").run(JSON.stringify(clean));
audit(req, 'assistant.models', 'kilo', clean.length + ' моделей');
res.json({ ok: true, count: clean.length });
}
/* POST /api/admin/assistant/health — прогнать проверку здоровья провайдеров сейчас */
async function runHealth(req, res) {
try {
const r = await require('../assistant-health').runHealthCheck();
audit(req, 'assistant.health', 'assistant', 'ручная проверка');
res.json({ ok: true, result: r });
} catch (e) { res.status(500).json({ ok: false, error: e.message || 'ошибка' }); }
}
/* POST /api/admin/assistant/active { id } — выбрать активного провайдера */
function setActiveProvider(req, res) {
const id = String((req.body && req.body.id) || '');
@@ -1087,7 +1259,7 @@ async function testAssistant(req, res) {
};
}
override.local = _aIsLocal(override.url);
override.on = !!(override.key || override.local);
override.on = !!(override.key || _aNoKey(override.url));
const r = await a.pingLLM(override);
// Успешный тест активного провайдера снимает устаревший флаг failover
try { const activeId = _aset('assistant_active'); if (r && r.ok && (!b.id || b.id === activeId)) a.clearFailover(); } catch (e) {}
@@ -1157,9 +1329,11 @@ module.exports = {
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
getResetPlan, resetSystem,
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
getSecurityLog, clearSecurityLog,
getTopics, createTopic, updateTopic, deleteTopic,
broadcast,
getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels,
scanModels, probeModel, applyModels, runHealth,
};
@@ -2,6 +2,7 @@ const db = require('../db/db');
const { pushNotif } = require('../utils/notifications');
const { stripTags } = require('../utils/sanitize');
const { SESSION_MODES } = require('../constants');
const AssignmentUtils = require('../../../frontend/js/assignment-utils.js'); // единый источник: тип/«сдано»
const VALID_ASSIGN_MODES = SESSION_MODES;
@@ -256,9 +257,9 @@ function teacherAssignments(req, res) {
res.json(rows);
}
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
function myAssignments(req, res) {
const uid = req.user.id;
/* Собрать все задания пользователя (классовые + личные) с вычисленным статусом.
Переиспользуется в /assignments/my и в обзоре задолженностей класса. */
function assignmentRowsForUser(uid) {
const rows = db.prepare(`
SELECT * FROM (
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
@@ -267,6 +268,7 @@ function myAssignments(req, res) {
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
tp.paragraphs_read AS textbook_read,
c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
a.created_by AS created_by,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
@@ -295,6 +297,7 @@ function myAssignments(req, res) {
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
tp.paragraphs_read AS textbook_read,
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
a.created_by AS created_by,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
@@ -334,8 +337,78 @@ function myAssignments(req, res) {
// Strip raw paragraphs_read JSON from response (not needed by client)
delete r.textbook_read;
}
return rows;
}
res.json(rows);
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
function myAssignments(req, res) {
res.json(assignmentRowsForUser(req.user.id));
}
/* ── GET /api/classes/:id/outstanding ── что «висит» у каждого ученика класса ──
Учитель/админ видят по каждому ученику его НЕзакрытые задания (классовые + личные
от этого учителя) со статусом: не начато / в процессе / на доработке / просрочено. */
function classOutstanding(req, res) {
const cid = req.params.id;
const cls = db.prepare('SELECT id, name, teacher_id FROM classes WHERE id = ?').get(cid);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const members = db.prepare(`
SELECT u.id, u.name, u.email FROM class_members cm
JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name
`).all(cid);
// Последняя сдача по (задание, ученик) в этом классе — для upload/file done-статуса.
const subRows = db.prepare(`
SELECT s.assignment_id, s.student_id, s.status
FROM submissions s
JOIN (SELECT assignment_id, student_id, MAX(id) AS mid FROM submissions
WHERE class_id = ? GROUP BY assignment_id, student_id) last ON last.mid = s.id
`).all(cid);
const subMap = new Map();
for (const s of subRows) subMap.set(s.assignment_id + '_' + s.student_id, s.status);
const now = Date.now();
const cidNum = Number(cid);
const RANK = { overdue: 0, revision: 1, in_progress: 2, not_started: 3 };
const students = members.map(m => {
// Только задания ЭТОГО класса + личные, созданные учителем этого класса.
const rows = assignmentRowsForUser(m.id).filter(r =>
r.class_id === cidNum || (r.class_id === 0 && r.created_by === cls.teacher_id)
);
const pending = [];
for (const r of rows) {
const t = AssignmentUtils.type(r);
const st = (t === 'upload' || t === 'file') ? subMap.get(r.id + '_' + m.id) : null;
// Учительская семантика: любая сдача не на доработке = не долг (default opts).
if (AssignmentUtils.isDone(r, st ? { status: st } : null)) continue;
const overdue = r.deadline && new Date(r.deadline).getTime() < now;
let status = overdue ? 'overdue' : 'not_started';
if (st === 'revision') status = 'revision'; // вернули на доработку
else if (t === 'test' && r.session_status === 'in_progress') status = 'in_progress';
pending.push({
assignment_id: r.id, title: r.title, type: t, deadline: r.deadline,
status, is_homework: r.is_homework ? 1 : 0,
scope: r.class_id === cidNum ? 'class' : 'direct',
});
}
pending.sort((a, b) => (RANK[a.status] - RANK[b.status]) ||
((a.deadline ? new Date(a.deadline).getTime() : Infinity) -
(b.deadline ? new Date(b.deadline).getTime() : Infinity)));
const counts = { total: pending.length, overdue: 0, in_progress: 0, not_started: 0, revision: 0 };
pending.forEach(p => { counts[p.status]++; });
return { id: m.id, name: m.name, email: m.email, pending, counts };
});
const summary = {
students_total: members.length,
debtors: students.filter(s => s.counts.total > 0).length,
overdue: students.reduce((a, s) => a + s.counts.overdue, 0),
};
res.json({ className: cls.name, summary, students });
}
/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,4,5] or [1,3,7] etc.
@@ -732,6 +805,7 @@ module.exports = {
deleteAssignment,
teacherAssignments,
myAssignments,
classOutstanding,
startAssignment,
assignmentResults,
assignmentQuestionStats,
+195 -5
View File
@@ -339,6 +339,8 @@ function searchFaq(q, n) {
* на ENV и дефолты. Если ключа нет и URL не локальный — работаем как FAQ. */
function _setting(k) { try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } catch (e) { return null; } }
function _isLocal(url) { return /\/\/(localhost|127\.0\.0\.1)/.test(url || ''); }
// Шлюзы с бесплатным инференсом БЕЗ ключа (наряду с localhost): ключ не обязателен.
function _noKeyNeeded(url) { return _isLocal(url) || /\/\/[^/]*\bpollinations\.ai\b/i.test(url || ''); }
/* Список провайдеров (несколько ключей/моделей). Хранится JSON в app_settings.
* Если списка нет — синтезируем из legacy-настроек/ENV, чтобы ничего не сломать. */
@@ -357,7 +359,7 @@ function _providers() {
/* Конфиги в порядке использования: активный первым, затем остальные с ключом
* (для авто-перехвата при лимите/ошибке). */
function providersOrdered() {
const arr = _providers().filter(p => p && (p.key || _isLocal(p.url)));
const arr = _providers().filter(p => p && (p.key || _noKeyNeeded(p.url)));
const activeId = _setting('assistant_active');
const active = arr.filter(p => p.id === activeId);
const rest = arr.filter(p => p.id !== activeId);
@@ -451,11 +453,69 @@ async function callLLMFailover(messages, maxTokens) {
return last;
}
/* Потоковый вызов OpenAI-совместимого chat/completions (stream:true).
* onDelta(piece) — на каждый кусок текста. Возвращает { text, any, error }. */
async function callLLMStream(messages, maxTokens, cfg, onDelta) {
if (typeof fetch !== 'function' || !cfg.on) return { text: null, any: false, error: 'off' };
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 60000); // стриминг длиннее обычного
try {
const r = await fetch(cfg.url, {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}),
body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: maxTokens || 1200, messages, stream: true }),
signal: ctrl.signal,
});
if (!r.ok) return { text: null, any: false, error: r.status === 429 ? 'rate_limit' : 'http', status: r.status };
if (!r.body) return { text: null, any: false, error: 'empty' };
const dec = new TextDecoder();
let buf = '', full = '', any = false;
for await (const chunk of r.body) {
buf += dec.decode(chunk, { stream: true });
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') return { text: full || null, any, error: full ? null : 'empty' };
try {
const j = JSON.parse(data);
const d = j.choices && j.choices[0] && j.choices[0].delta;
const piece = d && d.content;
if (piece) { full += piece; any = true; onDelta(piece); }
} catch (e) { /* частичный/служебный кусок — пропускаем */ }
}
}
return { text: full || null, any, error: full ? null : 'empty' };
} catch (e) { return { text: null, any: false, error: e.name === 'AbortError' ? 'timeout' : 'network' }; }
finally { clearTimeout(timer); }
}
/* Стриминг с перебором провайдеров. Failover возможен ТОЛЬКО до первого куска;
* как только клиенту ушёл текст (any) — остаёмся на этом провайдере. */
async function callLLMStreamFailover(messages, maxTokens, onDelta) {
const cfgs = providersOrdered();
if (!cfgs.length) return { text: null, error: 'off' };
let firstErr = null;
for (let i = 0; i < cfgs.length; i++) {
const res = await callLLMStream(messages, maxTokens, cfgs[i], onDelta);
if (i === 0) firstErr = res.error;
if (res.text) {
if (i === 0) _clearFailover(); else _recordFailover(cfgs[0], cfgs[i], firstErr);
return res;
}
if (res.any) return res; // часть уже улетела клиенту — переключиться нельзя
if (!_RETRYABLE[res.error]) break;
}
if (_RETRYABLE[firstErr]) _recordFailover(cfgs[0], null, firstErr);
return { text: null, error: firstErr || 'error' };
}
/* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */
async function pingLLM(override) {
const cfg = override || llmConfig();
if (!cfg.url) return { ok: false, error: 'URL не задан' };
if (!cfg.key && !/\/\/(localhost|127\.0\.0\.1)/.test(cfg.url)) return { ok: false, error: 'Ключ не задан' };
if (!cfg.key && !_noKeyNeeded(cfg.url)) return { ok: false, error: 'Ключ не задан' };
if (typeof fetch !== 'function') return { ok: false, error: 'fetch недоступен' };
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
@@ -496,7 +556,12 @@ const META_RE = new RegExp('(' + _SELF + '[\\sа-яёa-z0-9,?!.-]{0,25}' + _TERM
'|на\\s+ч[её]м\\s+ты\\s+(?:работа|сдела|постро|основ)|кто\\s+тебя\\s+(?:сделал|создал|обуч|разработ|написал)|систем[а-яё]*\\s+промпт|what\\s+model\\s+are\\s+you|which\\s+(?:ai\\s+)?model|your\\s+system\\s+prompt)', 'i');
const META_ANSWER = 'Я — Квантик, помощник LearnSpace. Помогаю с учёбой и навигацией по платформе. Давай вернёмся к делу — что объяснить или подсказать?';
async function askModel(q, hits, context, history, role, mode, mem) {
// Анти-чит: явная просьба «сделай за меня» (а не «помоги разобраться»).
const _CHEAT_RE = /за\s+меня|вместо\s+меня|do\s+my\s+homework|(сделай|реши|выполни|напиши)\s+([а-яёА-ЯЁ]+\s+)?(дз|домашк|контрольн)/i;
function _socraticOn() { return _setting('assistant_socratic') === '1'; }
// Сборка messages+cap для модели — общая для обычного и стримингового ответа.
function buildAskMessages(q, hits, context, history, role, mode, mem, socratic) {
const ref = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)';
const user = (context ? `Контекст (опирайся на него, если относится к вопросу):\n${context}\n\n` : '') +
`Справка по платформе:\n${ref}\n\nВопрос: ${q}`;
@@ -510,15 +575,33 @@ async function askModel(q, hits, context, history, role, mode, mem) {
sys += ' РЕЖИМ ПОДСКАЗКИ: дай ТОЛЬКО наводящую подсказку или следующий шаг к решению. Не давай готовый ответ — пусть ученик додумает сам.';
} else if (mode === 'check') {
sys += ' РЕЖИМ ПРОВЕРКИ: ученик прислал своё решение. Скажи, верно оно или нет, и укажи КОНКРЕТНО, где ошибка (если есть). Не выдавай сразу полный правильный ответ — дай шанс исправить.';
} else if (socratic) {
// Сократический режим (для учеников): теория — полно, но задачи не решаем «под ключ».
sys += ' СОКРАТИЧЕСКИЙ РЕЖИМ: понятия, определения и теорию объясняй полно и по существу. ' +
'Но если просят РЕШИТЬ конкретную задачу/пример/уравнение или «сделать» задание — НЕ выдавай готовое решение и итоговый ответ. ' +
'Вместо этого назови нужный метод/формулу, разбери первый шаг и задай наводящий вопрос, предложи ученику продолжить самому. ' +
'Если ученик пришлёт свой шаг или ответ — проверь и мягко направь дальше. Будь доброжелателен, подбадривай.';
}
const msgs = [{ role: 'system', content: sys }];
(history || []).forEach(m => { if (m && (m.role === 'user' || m.role === 'assistant') && m.content) msgs.push({ role: m.role, content: String(m.content).slice(0, 1500) }); });
msgs.push({ role: 'user', content: user });
// подсказка короткая; ответ/проверка — длиннее, чтобы пошаговое решение с формулами не обрезалось на середине
const cap = mode === 'hint' ? 320 : (mode === 'check' ? 900 : 1200);
return { msgs, cap };
}
async function askModel(q, hits, context, history, role, mode, mem, socratic) {
const { msgs, cap } = buildAskMessages(q, hits, context, history, role, mode, mem, socratic);
return callLLMFailover(msgs, cap);
}
// Сократический режим включается для УЧЕНИКА: если включён тумблер ИЛИ явная просьба «сделай за меня».
function _socraticFor(role, mode, q) {
if (role && role !== 'student') return false; // учителям/админам не ограничиваем
if (mode !== 'answer') return false; // hint/check уже наводящие
return _socraticOn() || _CHEAT_RE.test(q || '');
}
/* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─
* Грунтуем ответ топ-FAQ (+ опц. контекст страницы + история диалога). Если
* LLM настроена — её ответ (source:'model'), иначе FAQ (source:'faq'). */
@@ -551,8 +634,9 @@ async function ask(req, res) {
let context = pageCtx;
if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
const socratic = _socraticFor(req.user && req.user.role, mode, q);
let r = { text: null, error: 'network' };
try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode, mem); } catch (e) { r = { text: null, error: 'network' }; }
try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode, mem, socratic); } catch (e) { r = { text: null, error: 'network' }; }
const answer = r && r.text;
if (answer) {
@@ -572,6 +656,66 @@ async function ask(req, res) {
res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] });
}
/* ── POST /api/assistant/ask/stream ── то же, что ask, но ответ модели стримится
* по SSE (event: meta|delta|done). Быстрые пути (FAQ/кэш/мета) отдаются одним done. */
async function askStream(req, res) {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // не буферизовать за прокси
if (res.flushHeaders) res.flushHeaders();
const sse = (event, data) => { try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch (e) {} };
const q = String((req.body && req.body.q) || '').trim().slice(0, 500);
if (!q || q.length < 2) { sse('done', { source: 'faq', answer: null, answers: [] }); return res.end(); }
if (META_RE.test(q)) { sse('delta', { t: META_ANSWER }); sse('done', { source: 'model', answers: [], sources: [] }); return res.end(); }
const pageCtx = String((req.body && req.body.context) || '').slice(0, 4000);
const mode = ['hint', 'check'].includes(req.body && req.body.mode) ? req.body.mode : 'answer';
let history = (req.body && req.body.history);
history = Array.isArray(history) ? history.slice(-6) : [];
const hits = searchFaq(q, 3);
const faqJson = hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null }));
sse('meta', { answers: faqJson });
if (!providersOrdered().length) { bumpUsage('faq'); sse('done', { source: 'faq', answer: null, answers: faqJson, sources: [] }); return res.end(); }
const rag = ragContext(q);
const mem = _memoryBlock(req.user.id);
const cacheable = mode === 'answer' && !pageCtx && !history.length && !mem;
const qhash = q.toLowerCase().replace(/\s+/g, ' ').trim();
if (cacheable) {
try {
const c = db.prepare("SELECT answer FROM assistant_cache WHERE qhash = ? AND created_at > datetime('now','-7 days')").get(qhash);
if (c) { bumpUsage('cache_hits'); sse('delta', { t: c.answer }); sse('done', { source: 'model', answers: faqJson, sources: rag.sources, cached: true }); return res.end(); }
} catch (e) {}
}
if (rag.sources && rag.sources.length) sse('meta', { sources: rag.sources });
let context = pageCtx;
if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
const socratic = _socraticFor(req.user && req.user.role, mode, q);
const { msgs, cap } = buildAskMessages(q, hits, context, history, req.user && req.user.role, mode, mem, socratic);
let full = '';
let r = { text: null, error: 'network' };
try { r = await callLLMStreamFailover(msgs, cap, (piece) => { full += piece; sse('delta', { t: piece }); }); }
catch (e) { r = { text: null, error: 'network' }; }
const answer = (r && r.text) || full;
if (answer) {
bumpUsage('model_calls');
if (cacheable) { try { db.prepare("INSERT OR REPLACE INTO assistant_cache (qhash, answer, created_at) VALUES (?, ?, datetime('now'))").run(qhash, answer); } catch (e) {} }
if (_setting('assistant_memory') !== '0' && (mode === 'check' || history.length >= 4)) _extractMemory(req.user.id, q, answer);
sse('done', { source: 'model', answers: faqJson, sources: rag.sources });
return res.end();
}
bumpUsage('faq');
if (r && r.error === 'rate_limit') sse('done', { source: 'limit', answer: 'Сейчас слишком много запросов к ИИ за короткое время — подожди минутку и спроси снова. Память диалога не потеряется.', answers: faqJson, sources: [] });
else if (r && (r.error === 'timeout' || r.error === 'network' || r.error === 'http')) sse('done', { source: 'error', answer: 'Не получилось обратиться к ИИ. Попробуй ещё раз чуть позже.', answers: faqJson, sources: [] });
else sse('done', { source: 'faq', answer: null, answers: faqJson, sources: [] });
res.end();
}
/* ── POST /api/assistant/feedback { rating, q? } ── лайк/дизлайк ответа ── */
function feedback(req, res) {
const rating = (req.body && req.body.rating) === 1 ? 1 : ((req.body && req.body.rating) === -1 ? -1 : 0);
@@ -621,4 +765,50 @@ async function flashcardsFromText(req, res) {
res.json({ title, cards });
}
module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, feedback, getMemory, clearMemory, getStudentProfile, llmConfig, pingLLM, clearFailover: _clearFailover, callLLMFailover };
/* ── POST /api/assistant/questions { text, count? } ── учитель: сгенерировать
* тестовые вопросы (single-choice) из темы/текста для банка вопросов. */
async function questionsFromText(req, res) {
if (!providersOrdered().length) return res.status(503).json({ error: 'LLM не настроена' });
const text = String((req.body && req.body.text) || '').trim().slice(0, 6000);
let count = Number(req.body && req.body.count);
count = Number.isFinite(count) ? Math.max(3, Math.min(10, Math.round(count))) : 5;
if (text.length < 3) return res.status(400).json({ error: 'Введите тему или текст' });
const sys = 'Ты составляешь тестовые вопросы с выбором одного верного ответа для школьников. ' +
'Если дан учебный текст/параграф — делай вопросы СТРОГО по нему; если дана короткая тема — раскрой её по школьной программе. ' +
'Верни СТРОГО JSON-массив из ' + count + ' объектов вида ' +
'{"q":"текст вопроса","options":["вариант1","вариант2","вариант3","вариант4"],"correct":0,"explanation":"кратко, почему верен"}. ' +
'РОВНО 4 варианта; correct — индекс правильного (0..3); ровно один правильный. ' +
'По-русски, формулы в LaTeX между $...$. Никакого текста вне JSON, без markdown.';
let rr;
try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 2200); }
catch (e) { return res.status(502).json({ error: 'Не удалось обратиться к ИИ' }); }
const raw = rr && rr.text;
let questions = [];
if (raw) {
let s = raw.replace(/```(?:json)?/gi, '').trim();
const a = s.indexOf('[');
if (a >= 0) {
const b = s.lastIndexOf(']');
if (b > a) s = s.slice(a, b + 1);
else { const last = s.lastIndexOf('}'); s = last > a ? s.slice(a, last + 1) + ']' : ''; }
}
try {
const arr = JSON.parse(s);
if (Array.isArray(arr)) {
questions = arr
.filter(x => x && x.q && Array.isArray(x.options) && x.options.length >= 2)
.slice(0, count + 2)
.map(x => {
const opts = x.options.slice(0, 6).map(o => String(o).slice(0, 300)).filter(Boolean);
let correct = Number(x.correct); if (!Number.isInteger(correct) || correct < 0 || correct >= opts.length) correct = 0;
return { q: String(x.q).slice(0, 1000), options: opts, correct, explanation: String(x.explanation || '').slice(0, 600) };
})
.filter(x => x.options.length >= 2);
}
} catch (e) { /* не-JSON */ }
}
if (!questions.length) return res.status(502).json({ error: 'Не удалось сгенерировать вопросы' });
res.json({ questions });
}
module.exports = { getContext, markSeen, dismiss, setSettings, ask, askStream, flashcardsFromText, questionsFromText, feedback, getMemory, clearMemory, getStudentProfile, llmConfig, pingLLM, clearFailover: _clearFailover, callLLMFailover };
@@ -41,6 +41,15 @@ function createSession(req, res) {
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
emitToSession(sessionId, { type: 'classroom_started', sessionId, title, classId: class_id || null, teacherName: teacher.name });
// Баннер «идёт онлайн-урок» на дашбордах — через SSE-канал (доска работает по WS,
// дашборд по SSE, поэтому нужен отдельный сигнал ученикам класса / приглашённым / учителю).
try {
const sse = require('../../sse');
const payload = { type: 'classroom_live', state: 'started', sessionId, title, classId: class_id || null };
if (class_id) sse.emitToClass(class_id, payload);
else if (user_ids) for (const uid of user_ids) sse.emit(uid, payload);
sse.emit(teacher.id, payload);
} catch { /* SSE недоступен — не критично */ }
res.json(session);
}
@@ -74,6 +83,17 @@ function endSession(req, res) {
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_muted WHERE session_id=?').run(sessionId);
emitToSession(sessionId, { type: 'classroom_ended', sessionId });
// Снять баннер «идёт онлайн-урок» с дашбордов (SSE-канал).
try {
const sse = require('../../sse');
const payload = { type: 'classroom_live', state: 'ended', sessionId };
if (session.class_id) sse.emitToClass(session.class_id, payload);
else {
const invited = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
for (const r of invited) sse.emit(r.user_id, payload);
}
sse.emit(session.teacher_id, payload);
} catch { /* SSE недоступен — не критично */ }
res.json({ ok: true });
}
@@ -0,0 +1,29 @@
'use strict';
/* clientErrorController — приём ошибок из браузера пользователя.
Пишем в общий error_log с level='client', чтобы они появились в админ-вкладке «Ошибки».
Запись не должна ронять запрос — любые сбои глушим. */
const db = require('../db/db');
const MAX_MSG = 1000, MAX_STACK = 4000, MAX_ROUTE = 400;
const clamp = (v, n) => (v == null ? null : String(v).slice(0, n));
function report(req, res) {
const b = req.body || {};
const message = (clamp(b.message, MAX_MSG) || '').trim();
if (!message) return res.status(400).json({ error: 'message required' });
const kind = b.kind === 'unhandledrejection' ? 'rejection' : 'error';
const route = clamp(b.url || b.route, MAX_ROUTE);
let stack = clamp(b.stack, MAX_STACK);
// если стека нет — собираем источник:строка:колонка
if (!stack && (b.source || b.line)) stack = `${b.source || ''}:${b.line || ''}:${b.col || ''}`;
try {
db.prepare(
'INSERT INTO error_log (level, message, stack, route, method, user_id) VALUES (?, ?, ?, ?, ?, ?)'
).run('client', message, stack, route, kind, req.user.id);
} catch { /* лог не должен ломать ответ */ }
res.json({ ok: true });
}
module.exports = { report };
@@ -542,6 +542,7 @@ function onClassJoined(userId) {
}
function onLabExperiment(userId, reactionsDiscovered) {
if (!isGamificationEnabled()) return; // master kill-switch
stmts.incrLabExp.run(userId);
if (reactionsDiscovered > 0) stmts.incrLabReact.run(reactionsDiscovered, userId);
awardXP(userId, 15, 'lab_experiment');
@@ -650,6 +651,7 @@ function ensureChallenges(userId) {
}
function updateChallenges(userId, score, total, subjectSlug, topicId) {
if (!isGamificationEnabled()) return; // master kill-switch
const week = _currentWeek();
const pct = total > 0 ? Math.round(score / total * 100) : 0;
const challenges = stmts.getOpenChallenges.all(userId, week);
+39 -13
View File
@@ -1,5 +1,17 @@
const db = require('../db/db');
/* Вопросы теста без зафиксированного правильного ответа (нет верного варианта И
* нет correct_text). matching исключаем (там ответ — пары match_pair).
* Такой вопрос нельзя оценить → не пускаем тест к ученикам. */
function unanswerableInTest(testId) {
return db.prepare(`
SELECT tq.question_id AS id FROM test_questions tq JOIN questions q ON q.id = tq.question_id
WHERE tq.test_id = ? AND q.type <> 'matching'
AND (q.correct_text IS NULL OR TRIM(q.correct_text) = '')
AND NOT EXISTS (SELECT 1 FROM options o WHERE o.question_id = q.id AND o.is_correct = 1)
`).all(testId).map(r => r.id);
}
/* ── GET /api/tests ─────────────────────────────────────────────────────── */
function list(req, res) {
const { subject } = req.query;
@@ -7,13 +19,16 @@ function list(req, res) {
const args = [];
let where = '1=1';
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
const isStudent = role === 'student' || role === 'free_student';
// Ученик видит каталог тестов, помеченных доступными; учитель — только свои; админ — все.
if (isStudent) { where += ' AND t.available_to_students = 1'; }
else if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
// Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js),
// не показываем их во вкладке «Тесты (шаблоны)» админки.
where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)';
const rows = db.prepare(`
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at,
let rows = db.prepare(`
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, t.available_to_students,
u.name AS creator_name,
COUNT(tq.question_id) AS question_count
FROM tests t
@@ -22,18 +37,19 @@ function list(req, res) {
WHERE ${where}
GROUP BY t.id ORDER BY t.created_at DESC
`).all(...args);
if (isStudent) rows = rows.filter(r => r.question_count > 0); // пустые тесты ученику не показываем
res.json(rows);
}
/* ── POST /api/tests ─────────────────────────────────────────────────────── */
function create(req, res) {
const { title, subject_slug, description, show_answers = 1, time_limit } = req.body;
const { title, subject_slug, description, show_answers = 1, time_limit, available_to_students = 0 } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null;
const r = db.prepare(
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)'
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, req.user.id);
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, available_to_students, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, available_to_students ? 1 : 0, req.user.id);
res.status(201).json({ id: r.lastInsertRowid });
}
@@ -76,13 +92,23 @@ function getOne(req, res) {
/* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */
function update(req, res) {
const { title, subject_slug, description, show_answers, time_limit } = req.body;
const t = req.resource; // ownership verified by requireOwnership middleware
const tl = time_limit !== undefined ? (time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null) : undefined;
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ? WHERE id = ?')
.run(title?.trim(), subject_slug, description?.trim() || null, show_answers === undefined ? 1 : (show_answers ? 1 : 0),
tl !== undefined ? tl : t.time_limit,
t.id);
const b = req.body;
const t = req.resource; // ownership verified by requireOwnership middleware
// Частичный апдейт: НЕ переданные поля сохраняем из текущей строки (иначе toggleTstAvail,
// присылающий только available_to_students, обнулил бы title/subject и т.п.).
const title = b.title !== undefined ? (b.title?.trim() || t.title) : t.title;
const subject_slug = b.subject_slug !== undefined ? b.subject_slug : t.subject_slug;
const description = b.description !== undefined ? (b.description?.trim() || null) : t.description;
const show_answers = b.show_answers !== undefined ? (b.show_answers ? 1 : 0) : t.show_answers;
const time_limit = b.time_limit !== undefined ? (b.time_limit ? Math.max(1, Math.min(600, Number(b.time_limit))) : null) : t.time_limit;
const available = b.available_to_students !== undefined ? (b.available_to_students ? 1 : 0) : t.available_to_students;
// Гард целостности: нельзя публиковать тест ученикам с вопросами без правильного ответа.
if (available === 1) {
const broken = unanswerableInTest(t.id);
if (broken.length) return res.status(400).json({ error: `Нельзя опубликовать: ${broken.length} вопрос(ов) без правильного ответа. Исправьте их в банке.`, brokenQuestions: broken });
}
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ?, available_to_students = ? WHERE id = ?')
.run(title, subject_slug, description, show_answers, time_limit, available, t.id);
res.json({ ok: true });
}
+104
View File
@@ -0,0 +1,104 @@
'use strict';
const db = require('../db/db');
const { stripTags } = require('../utils/sanitize');
const { pushNotif } = require('../utils/notifications');
const CATEGORIES = ['ui', 'content', 'feature', 'bug', 'other'];
const STATUSES = ['new', 'planned', 'in_progress', 'done', 'declined'];
const STATUS_LABEL = {
new: 'Новое', planned: 'Запланировано', in_progress: 'В работе',
done: 'Готово', declined: 'Отклонено',
};
function clampStr(v, max) {
return stripTags(String(v == null ? '' : v)).slice(0, max).trim();
}
/* ── GET /api/wishes ── список: админ видит все (с фильтрами), остальные — свои ── */
function list(req, res) {
const isAdmin = req.user.role === 'admin';
const { status, category } = req.query;
const where = [];
const args = [];
if (!isAdmin) { where.push('w.user_id = ?'); args.push(req.user.id); }
if (status && STATUSES.includes(status)) { where.push('w.status = ?'); args.push(status); }
if (category && CATEGORIES.includes(category)) { where.push('w.category = ?'); args.push(category); }
const sql = `
SELECT w.id, w.user_id, w.title, w.body, w.category, w.status, w.admin_note,
w.created_at, w.updated_at,
${isAdmin ? 'u.name AS author_name, u.email AS author_email,' : ''}
0 AS _pad
FROM wishes w
${isAdmin ? 'JOIN users u ON u.id = w.user_id' : ''}
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY CASE w.status WHEN 'new' THEN 0 WHEN 'planned' THEN 1 WHEN 'in_progress' THEN 2 ELSE 3 END,
w.updated_at DESC`;
const rows = db.prepare(sql).all(...args);
let counts = null;
if (isAdmin) {
counts = {};
for (const r of db.prepare('SELECT status, COUNT(*) c FROM wishes GROUP BY status').all()) counts[r.status] = r.c;
}
res.json({ wishes: rows, counts, isAdmin });
}
/* ── POST /api/wishes ── создать (любой авторизованный) ── */
function create(req, res) {
const title = clampStr(req.body?.title, 200);
if (!title) return res.status(400).json({ error: 'Заголовок обязателен' });
const body = clampStr(req.body?.body, 4000);
let category = String(req.body?.category || 'other');
if (!CATEGORIES.includes(category)) category = 'other';
const info = db.prepare(
`INSERT INTO wishes (user_id, title, body, category) VALUES (?,?,?,?)`
).run(req.user.id, title, body || null, category);
const row = db.prepare('SELECT * FROM wishes WHERE id = ?').get(Number(info.lastInsertRowid));
res.status(201).json(row);
}
/* ── PATCH /api/wishes/:id ── триаж: статус + ответ (только админ) ── */
function update(req, res) {
const wish = db.prepare('SELECT * FROM wishes WHERE id = ?').get(req.params.id);
if (!wish) return res.status(404).json({ error: 'Не найдено' });
const fields = [];
const args = [];
let newStatus = null;
if (req.body?.status !== undefined) {
if (!STATUSES.includes(req.body.status)) return res.status(400).json({ error: 'Неверный статус' });
if (req.body.status !== wish.status) newStatus = req.body.status;
fields.push('status = ?'); args.push(req.body.status);
}
if (req.body?.admin_note !== undefined) {
fields.push('admin_note = ?'); args.push(clampStr(req.body.admin_note, 2000) || null);
}
if (!fields.length) return res.status(400).json({ error: 'Нет изменений' });
fields.push("updated_at = datetime('now')");
db.prepare(`UPDATE wishes SET ${fields.join(', ')} WHERE id = ?`).run(...args, wish.id);
// Уведомить автора при смене статуса (durable + SSE).
if (newStatus && wish.user_id !== req.user.id) {
try {
pushNotif(wish.user_id, 'wish_update',
`Ваше пожелание «${wish.title}»: ${STATUS_LABEL[newStatus] || newStatus}`, '/wishes');
} catch { /* notif не критичен */ }
}
res.json(db.prepare('SELECT * FROM wishes WHERE id = ?').get(wish.id));
}
/* ── DELETE /api/wishes/:id ── автор (пока «новое») или админ ── */
function remove(req, res) {
const wish = db.prepare('SELECT id, user_id, status FROM wishes WHERE id = ?').get(req.params.id);
if (!wish) return res.status(404).json({ error: 'Не найдено' });
const isAdmin = req.user.role === 'admin';
const isOwner = wish.user_id === req.user.id;
if (!isAdmin && !(isOwner && wish.status === 'new'))
return res.status(403).json({ error: 'Удалять можно только своё необработанное пожелание' });
db.prepare('DELETE FROM wishes WHERE id = ?').run(wish.id);
res.json({ ok: true });
}
module.exports = { list, create, update, remove, CATEGORIES, STATUSES };
+1
View File
@@ -48,4 +48,5 @@ db.transaction = function transaction(fn) {
};
};
db._path = dbPath; // абсолютный путь к файлу БД (нужен бэкапу при сбросе системы)
module.exports = db;
@@ -0,0 +1,4 @@
-- Витрина тестов для ученика: флаг «тест доступен ученикам».
-- Учитель/админ помечает свой тест доступным → он появляется в каталоге у учеников
-- (дашборд, виджет «Тесты»). По умолчанию 0 — тест виден только автору в конструкторе.
ALTER TABLE tests ADD COLUMN available_to_students INTEGER NOT NULL DEFAULT 0;
+15
View File
@@ -0,0 +1,15 @@
-- 080_wishes.sql — трекер пожеланий по улучшению системы.
-- Любой пользователь подаёт пожелание; видит только свои. Админ видит все и ведёт по статусам.
CREATE TABLE IF NOT EXISTS wishes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
body TEXT,
category TEXT NOT NULL DEFAULT 'other', -- ui | content | feature | bug | other
status TEXT NOT NULL DEFAULT 'new', -- new | planned | in_progress | done | declined
admin_note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_wishes_user ON wishes(user_id);
CREATE INDEX IF NOT EXISTS idx_wishes_status ON wishes(status);
+17 -1
View File
@@ -119,6 +119,22 @@ function parentAuth(req, res, next) {
}
}
/**
* requirePermissionForStudents(key) — применяет проверку права ТОЛЬКО к ролям
* ученика (student/free_student); учитель и админ проходят всегда.
* Нужно для роутов, которыми пользуются и учителя, и ученики (ассистент,
* материалы, игры, флеш-карты, exam-prep): ученическое право не должно ломать
* доступ учителю (у учителя нет записи по ключу → isEnabled вернул бы false).
*/
function requirePermissionForStudents(key) {
const guard = requirePermission(key);
return (req, res, next) => {
const r = req.user?.role;
if (r === 'student' || r === 'free_student') return guard(req, res, next);
return next();
};
}
/* Alias: requireAuth = authMiddleware */
const requireAuth = authMiddleware;
@@ -151,4 +167,4 @@ function optionalAuth(req, res, next) {
next();
}
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth, effectiveRoles };
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, requirePermissionForStudents, perm, parentAuth, effectiveRoles };
+59
View File
@@ -115,6 +115,28 @@ const PERMISSIONS = {
label: 'Управление геймификацией',
desc: 'Начислять XP/монеты ученикам, управлять достижениями',
},
'classroom.host': {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Вести онлайн-уроки',
desc: 'Запускать синхронные онлайн-уроки (classroom) с доской для класса',
requireConfirmOff: true,
},
'livequiz.host': {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Запускать живые викторины',
desc: 'Создавать и проводить синхронные викторины в реальном времени',
},
'simbuilder.use': {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Конструктор симуляций',
desc: 'Создавать и редактировать собственные интерактивные симуляции',
requireConfirmOff: true,
},
'flashcards.manage': {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Общие колоды флеш-карт',
desc: 'Создавать и раздавать общие колоды флеш-карт классам',
},
/* ── Student (also applies to free_student — same keys, same defaults) ── */
'tests.free': {
@@ -160,6 +182,38 @@ const PERMISSIONS = {
desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)',
requires: ['simulations.access'],
},
'homework.submit': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Сдавать домашние задания',
desc: 'Загружать работы и пересдавать домашние задания',
},
'materials.save': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Сохранять материалы',
desc: 'Сохранять доску/заметки/рисунки в раздел «Мои материалы»',
},
'assistant.use': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'ИИ-ассистент',
desc: 'Задавать вопросы ИИ-ассистенту «Квантик»',
},
'flashcards.access': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Раздел флеш-карт доступен роли',
desc: 'Включает раздел флеш-карт для роли. Какие именно колоды видны — настраивается по классам в «Доступ · контент»',
requireConfirmOff: true,
},
'exam.access': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Подготовка к экзаменам доступна роли',
desc: 'Включает разделы подготовки к экзаменам/ЦТ для роли. Какие именно модули видны — настраивается в «Доступ · контент»',
requireConfirmOff: true,
},
'games.play': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Учебные игры',
desc: 'Играть в учебные мини-игры (Виселица, Кроссворд)',
},
};
/* Группы для секций в админ-UI (один источник; byRole проставляет group). */
@@ -169,15 +223,20 @@ const GROUP = {
'students.invite': 'Класс и ученики', 'sessions.reset': 'Класс и ученики',
'results.export': 'Класс и ученики', 'classes.manage': 'Класс и ученики',
'schedule.manage': 'Класс и ученики', 'announcements.send': 'Класс и ученики',
'classroom.host': 'Класс и ученики', 'livequiz.host': 'Класс и ученики',
'library.upload': 'Библиотека', 'library.folders': 'Библиотека',
'templates.manage': 'Курсы и шаблоны', 'templates.public': 'Курсы и шаблоны',
'courses.manage': 'Курсы и шаблоны', 'courses.interactive': 'Курсы и шаблоны',
'simbuilder.use': 'Курсы и шаблоны', 'flashcards.manage': 'Курсы и шаблоны',
'shop.manage': 'Геймификация', 'gamification.manage': 'Геймификация',
// student
'tests.free': 'Тесты и активность', 'board.post': 'Тесты и активность',
'homework.submit': 'Тесты и активность', 'materials.save': 'Тесты и активность',
'assistant.use': 'Тесты и активность', 'games.play': 'Тесты и активность',
'profile.edit': 'Профиль',
'shop.purchase': 'Геймификация', 'gamification.challenges': 'Геймификация',
'theory.access': 'Контент', 'simulations.access': 'Контент', 'simulations.quiz': 'Контент',
'flashcards.access': 'Контент', 'exam.access': 'Контент',
};
/**
+8
View File
@@ -13,11 +13,19 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF
/* Everything below is admin-only */
router.use(requireRole('admin'));
/* ⚠️ Сброс системы «чистый запуск» — деструктивно, только admin */
router.get('/reset-system/plan', requireRole('admin'), ctrl.getResetPlan);
router.post('/reset-system', requireRole('admin'), ctrl.resetSystem);
router.get('/assistant', ctrl.getAssistant);
router.put('/assistant', ctrl.saveAssistant);
router.post('/assistant/test', ctrl.testAssistant);
router.post('/assistant/reindex', ctrl.reindexTextbooks);
router.get('/assistant/models', ctrl.getProviderModels);
router.post('/assistant/scan', ctrl.scanModels);
router.post('/assistant/probe', ctrl.probeModel);
router.post('/assistant/models/apply', ctrl.applyModels);
router.post('/assistant/health', ctrl.runHealth);
router.get('/imggen', ctrl.getImggen);
router.put('/imggen', ctrl.saveImggen);
router.post('/imggen/test', ctrl.testImggen);
+5 -3
View File
@@ -2,7 +2,7 @@
/* Квантик-ассистент. Все маршруты — под авторизацией (router-level), фича-гейт
* 'pet' навешивается при монтировании в server.js. */
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const ctrl = require('../controllers/assistantController');
@@ -16,8 +16,10 @@ router.get('/context', ctrl.getContext);
router.post('/seen', ctrl.markSeen);
router.post('/dismiss', ctrl.dismiss);
router.patch('/settings', ctrl.setSettings);
router.post('/ask', askLimiter, ctrl.ask);
router.post('/flashcards', fcLimiter, ctrl.flashcardsFromText);
router.post('/ask', requirePermissionForStudents('assistant.use'), askLimiter, ctrl.ask);
router.post('/ask/stream', requirePermissionForStudents('assistant.use'), askLimiter, ctrl.askStream);
router.post('/flashcards', requirePermissionForStudents('assistant.use'), fcLimiter, ctrl.flashcardsFromText);
router.post('/questions', requireRole('teacher', 'admin'), fcLimiter, ctrl.questionsFromText);
router.post('/feedback', ctrl.feedback);
router.get('/memory', ctrl.getMemory);
router.delete('/memory', ctrl.clearMemory);
+1
View File
@@ -37,6 +37,7 @@ router.delete('/:id', requireRole('teacher','admin'), requirePermission('
router.post('/:id/new-code', requireRole('teacher','admin'), requirePermission('classes.manage'), ctrl.regenerateCode);
router.get('/:id/journal', requireRole('teacher','admin'), ctrl.classJournal);
router.get('/:id/journal/csv', requireRole('teacher','admin'), ctrl.classJournalCsv);
router.get('/:id/outstanding', requireRole('teacher','admin'), assignCtrl.classOutstanding);
router.post('/:id/members', requireRole('teacher','admin'), ctrl.addMember);
router.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember);
router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment);
+2 -2
View File
@@ -2,7 +2,7 @@ const router = require('express').Router();
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const c = require('../controllers/classroomController');
@@ -47,7 +47,7 @@ router.get('/my/history', ...auth, c.getMyHistory);
router.get('/class/:classId/history', ...auth, c.getClassHistory);
// Session lifecycle
router.post('/', ...teacher, c.createSession);
router.post('/', ...teacher, requirePermission('classroom.host'), c.createSession);
router.get('/online-students', ...teacher, c.getOnlineStudents);
router.get('/my/session', ...auth, c.getMySession);
router.get('/class/:classId/active', ...auth, c.getActiveSession);
+11
View File
@@ -0,0 +1,11 @@
'use strict';
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const ctrl = require('../controllers/clientErrorController');
router.use(authMiddleware);
// Не больше 20 отчётов в минуту с пользователя — защита от флуда циклящихся ошибок.
router.post('/', rateLimit({ windowMs: 60_000, max: 20, byUser: true, message: 'Слишком много отчётов об ошибках' }), ctrl.report);
module.exports = router;
+3 -3
View File
@@ -5,7 +5,7 @@
* НЕ blanket requireRole на роутере: список/чтение доступны и ученику (published). */
const express = require('express');
const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const { requireFeature } = require('../middleware/features');
const c = require('../controllers/customSimController');
@@ -22,9 +22,9 @@ router.get('/:id', c.get);
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
router.get('/:id/related', c.related);
router.post('/', gate, requireRole('teacher', 'admin'), c.create);
router.post('/', gate, requireRole('teacher', 'admin'), requirePermission('simbuilder.use'), c.create);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.put('/:id', gate, requireRole('teacher', 'admin'), c.update);
router.put('/:id', gate, requireRole('teacher', 'admin'), requirePermission('simbuilder.use'), c.update);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove);
+6 -1
View File
@@ -1,10 +1,13 @@
'use strict';
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
const access = require('../services/contentAccess');
router.use(authMiddleware);
// Ролевой доступ к подготовке к экзаменам: ученик без права exam.access закрыт;
// учитель/админ проходят всегда. Видимость конкретных модулей — в «Доступ · контент».
router.use(requirePermissionForStudents('exam.access'));
/* Гейт доступа: любой маршрут с :examKey проверяется по allowlist.
Админ/учитель проходят всегда; ученик — только при наличии правила. */
@@ -59,6 +62,8 @@ const VARIANT_LABEL = {
117: 'ЦТ-2021',
118: 'ЦТ-2017',
119: 'ЦТ-2013',
120: 'ЦТ-2012',
121: 'ЦТ-2011',
},
};
const examVariantLabel = (examKey, v) => VARIANT_LABEL[examKey]?.[v] || `Вариант ${v}`;
+6 -3
View File
@@ -5,7 +5,7 @@ const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const fc = require('../controllers/flashcardController');
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermission, requirePermissionForStudents } = require('../middleware/auth');
const { requireOwnership } = require('../middleware/ownership');
/* ── multer для картинок карточек ───────────────────────────────────────
@@ -30,6 +30,9 @@ const fcUpload = multer({
});
router.use(authMiddleware);
// Ролевой доступ к разделу флеш-карт: ученик без права flashcards.access закрыт;
// учитель/админ проходят всегда (создают и раздают колоды).
router.use(requirePermissionForStudents('flashcards.access'));
router.post ('/upload', fcUpload.single('file'), fc.uploadImage);
@@ -45,8 +48,8 @@ router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards);
// Шаринг колоды (назначение классу/ученику) — только владелец/админ (проверка в хендлере).
router.get ('/decks/:id/shares', fc.listShares);
router.post ('/decks/:id/share', requireRole('teacher','admin'), fc.addShare);
router.delete('/decks/:id/share', requireRole('teacher','admin'), fc.removeShare);
router.post ('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.addShare);
router.delete('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.removeShare);
router.get ('/decks/:id/study', fc.getStudySession);
router.put ('/cards/:id', fc.updateCard);
router.delete('/cards/:id', fc.deleteCard);
+7 -5
View File
@@ -1,14 +1,16 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const { authMiddleware, requirePermissionForStudents } = require('../middleware/auth');
const { requireFeature } = require('../middleware/features');
const c = require('../controllers/gamesController');
const hangman = requireFeature('hangman');
const crossword = requireFeature('crossword');
// Ролевой доступ к учебным играм: ученик без права games.play закрыт, учитель/админ — нет.
const playable = requirePermissionForStudents('games.play');
router.get('/hangman/word', hangman, authMiddleware, c.hangmanWord);
router.post('/hangman/complete', hangman, authMiddleware, c.hangmanComplete);
router.get('/crossword/generate', crossword, authMiddleware, c.crosswordGenerate);
router.post('/crossword/complete', crossword, authMiddleware, c.crosswordComplete);
router.get('/hangman/word', hangman, authMiddleware, playable, c.hangmanWord);
router.post('/hangman/complete', hangman, authMiddleware, playable, c.hangmanComplete);
router.get('/crossword/generate', crossword, authMiddleware, playable, c.crosswordGenerate);
router.post('/crossword/complete', crossword, authMiddleware, playable, c.crosswordComplete);
module.exports = router;
+2 -2
View File
@@ -1,10 +1,10 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const c = require('../controllers/liveController');
const teacher = [authMiddleware, requireRole('teacher', 'admin')];
router.post('/', ...teacher, c.create);
router.post('/', ...teacher, requirePermission('livequiz.host'), c.create);
router.get('/:id', ...teacher, c.getSession);
router.put('/:id/question', ...teacher, c.setQuestion);
router.get('/:id/results', ...teacher, c.results);
+3 -2
View File
@@ -1,7 +1,7 @@
'use strict';
const express = require('express');
const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
const c = require('../controllers/studentMaterialsController');
router.use(authMiddleware);
@@ -10,7 +10,8 @@ router.use(authMiddleware);
router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
router.get('/', c.list);
router.post('/', c.create);
// Сохранение в «Мои материалы»: ученик без права materials.save закрыт, учитель/админ проходят.
router.post('/', requirePermissionForStudents('materials.save'), c.create);
// Collections (folders) — literal '/collections' prefix before '/:id'
router.post('/collections', c.createCollection);
+3 -1
View File
@@ -11,7 +11,9 @@ router.get('/', (_req, res) => {
router.patch('/:slug', authMiddleware, requireRole('admin'), (req, res) => {
const { default_mode, default_count, default_test_id } = req.body;
const valid_modes = ['exam', 'practice', 'topic', 'random'];
// Старт сессии (POST /api/sessions) поддерживает только exam/practice — раньше тут
// допускались topic/random, но клик по такому предмету на дашборде падал с 400.
const valid_modes = ['exam', 'practice'];
if (default_mode && !valid_modes.includes(default_mode))
return res.status(400).json({ error: 'Invalid mode' });
+3 -3
View File
@@ -1,7 +1,7 @@
const router = require('express').Router();
const multer = require('multer');
const path = require('path');
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const ctrl = require('../controllers/submissionsController');
const { fixUtf8Name } = require('../utils/fixUtf8');
@@ -47,7 +47,7 @@ const upload = multer({
/* ── routes ─────────────────────────────────────────────────────────── */
router.use(authMiddleware);
router.post('/', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.submit);
router.post('/', requireRole('student', 'free_student'), requirePermission('homework.submit'), upload.single('file'), fixUtf8Name, ctrl.submit);
router.get('/my', requireRole('student', 'free_student'), ctrl.getMySubmissions);
router.get('/log', requireRole('admin'), ctrl.getSubmissionLog);
router.delete('/log', requireRole('admin'), ctrl.clearSubmissionLog);
@@ -55,6 +55,6 @@ router.get('/', requireRole('teacher', 'admin'), ctrl.getClassSubm
router.patch('/:id', requireRole('teacher', 'admin'), ctrl.reviewSubmission);
router.get('/:id/download', ctrl.downloadSubmission);
router.delete('/:id', ctrl.deleteSubmission);
router.post('/:id/resubmit', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.resubmit);
router.post('/:id/resubmit', requireRole('student', 'free_student'), requirePermission('homework.submit'), upload.single('file'), fixUtf8Name, ctrl.resubmit);
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
'use strict';
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const ctrl = require('../controllers/wishController');
router.use(authMiddleware);
router.get('/', ctrl.list); // admin → все, остальные → свои (фильтрация в контроллере)
router.post('/', ctrl.create); // любой авторизованный
// @public-by-design: PATCH — только админ; DELETE — автор(своё «новое») или админ (проверка в хендлере)
router.patch('/:id', requireRole('admin'), ctrl.update);
router.delete('/:id', ctrl.remove);
module.exports = router;
+5
View File
@@ -198,6 +198,8 @@ app.use('/api/lab', labRoutes);
app.use('/api/materials', require('./routes/materials'));
app.use('/api/custom-sims', require('./routes/customSims'));
app.use('/api/game', require('./routes/game'));
app.use('/api/wishes', require('./routes/wishes'));
app.use('/api/client-errors', require('./routes/clientErrors'));
app.use('/api/prep', require('./routes/prep'));
app.use('/api/dashboard', require('./routes/dashboard'));
@@ -533,6 +535,9 @@ require('./ws-server').attach(server);
/* ── Ретеншн данных доски: чистка штрихов/картинок старых завершённых сессий ── */
try { require('./classroom-cleanup').schedule(); } catch (e) { logger.error('classroom-cleanup schedule error', { err: e.message }); }
/* ── Авто-здоровье LLM-провайдеров Квантика: пинг + авто-понижение упавшего активного ── */
try { require('./assistant-health').schedule(); } catch (e) { logger.error('assistant-health schedule error', { err: e.message }); }
/* ── Graceful shutdown ── */
function shutdown(signal) {
logger.info(`${signal} received — shutting down gracefully`);
+141
View File
@@ -0,0 +1,141 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
systemReset.js — общая логика «чистого запуска» (используют и CLI
backend/scripts/reset-system.js, и админ-эндпоинт POST /api/admin/reset-system).
⚠️ ДЕСТРУКТИВНО. Перед вызовом runReset ОБЯЗАТЕЛЬНО сделать бэкап БД.
Идея: сохранить ОДНОГО админа, переназначить ему авторский контент, стереть всех
остальных пользователей + всю активность/организацию, сохранить контент/конфиг.
Классифицируем ВСЕ таблицы; неизвестные НЕ трогаем.
─────────────────────────────────────────────────────────────────────────── */
/* Контент-таблицы: владелец переписывается на сохранённого админа (колонка у каждой своя). */
const REASSIGN = {
courses: 'created_by', tests: 'created_by',
flashcard_decks: 'user_id', custom_sims: 'owner_id',
course_templates: 'created_by', lesson_templates: 'created_by',
assignment_templates: 'created_by', lab_sim_links: 'created_by',
classroom_templates: 'teacher_id', folders: 'created_by', files: 'uploaded_by',
};
/* Активность/организация — полностью очищается. */
const WIPE = new Set([
'test_sessions', 'session_questions', 'user_answers',
'exam_attempts', 'exam_mock_sessions', 'exam_user_plan',
'assignments', 'assignment_sessions', 'assignment_completion',
'submissions', 'submission_log',
'classes', 'class_members', 'class_courses',
'classroom_sessions', 'classroom_attendance', 'classroom_chat', 'classroom_chat_reactions',
'classroom_draw_permissions', 'classroom_hands', 'classroom_invites', 'classroom_muted',
'classroom_notes', 'classroom_pages', 'classroom_strokes',
'live_sessions', 'live_answers',
'content_access',
'xp_log', 'coin_log', 'user_achievements', 'daily_goals', 'challenges', 'user_purchases',
'notifications', 'parent_notifications', 'parent_links',
'student_materials', 'material_collections',
'game_progress',
'lesson_progress', 'lesson_comments', 'lesson_notes',
'textbook_progress', 'textbook_bookmarks', 'bookmarks',
'flashcard_reviews', 'flashcard_deck_access',
'bio_user_challenges', 'bio_user_molecules', 'bio_user_pathway',
'rb_user_collection', 'rb_user_quests', 'rb_sightings',
'assistant_seen', 'assistant_memory', 'assistant_feedback', 'assistant_usage', 'assistant_cache',
'imggen_usage',
'folder_access', 'file_access',
'avatar_requests',
'geometry_submissions', 'geometry_tasks',
'security_events', 'error_log', 'admin_audit_log',
'student_prep',
'announcements', 'teacher_students', 'user_permissions', 'user_preferences',
]);
/* Контент/конфиг — НЕ трогаем (явный список, чтобы ловить «неизвестные» таблицы). */
const KEEP = new Set([
'subjects', 'questions', 'options', 'topics',
'textbooks', 'textbook_chunks',
'lessons', 'lesson_blocks', 'course_sections',
'exam_tasks', 'exam_topics', 'exam_tracks', 'exam9_variant_tests',
'test_questions', 'flashcard_cards', 'lab_sims',
'bio_challenges', 'bio_elements', 'bio_molecules', 'bio_pathways', 'bio_reactions',
'rb_food_web', 'rb_groups', 'rb_habitats', 'rb_population_data', 'rb_quests', 'rb_species', 'rb_species_regions',
'shop_items', 'achievements',
'roles', 'role_permissions', 'app_settings', '_migrations',
]);
const ADMIN_RESET_SQL =
`UPDATE users SET xp = 0, level = 1, coins = 0, streak_current = 0, streak_best = 0,
streak_date = NULL, goal_tier = 0, lab_experiments = 0, lab_reactions = 0,
pet_petting_streak = 0 WHERE id = ?`;
function allTables(db) {
return db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
.all().map(r => r.name);
}
function rowCount(db, t) { try { return db.prepare(`SELECT COUNT(*) c FROM "${t}"`).get().c; } catch { return null; } }
/** Кандидат-админ по умолчанию (минимальный id). null если админов нет. */
function pickKeptAdmin(db) {
return db.prepare("SELECT id, email, name FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get() || null;
}
/** План (без изменений): что переназначится / сотрётся / неизвестно. */
function classify(db) {
const tables = allTables(db);
const reassign = Object.entries(REASSIGN).map(([table, col]) => ({ table, col, rows: rowCount(db, table) }));
const wipe = [...WIPE].map(table => ({ table, rows: rowCount(db, table) }));
const unknown = tables.filter(t => t !== 'users' && !REASSIGN[t] && !WIPE.has(t) && !KEEP.has(t));
const totalUsers = rowCount(db, 'users');
const wipeRows = wipe.reduce((a, w) => a + (typeof w.rows === 'number' ? w.rows : 0), 0);
return { reassign, wipe, unknown, keepCount: KEEP.size, totalUsers, wipeRows };
}
/**
* Выполнить сброс. db — экземпляр node:sqlite DatabaseSync. keptAdminId — id админа,
* которого сохраняем (ему переназначается контент). Возвращает сводку.
* ⚠️ Бэкап делает ВЫЗЫВАЮЩИЙ код ДО вызова.
*/
function runReset(db, keptAdminId) {
const admin = db.prepare("SELECT id, role FROM users WHERE id = ?").get(keptAdminId);
if (!admin || admin.role !== 'admin') throw new Error('keptAdminId не является админом');
db.exec('PRAGMA foreign_keys = OFF'); // управляем удалением вручную, детерминированно
db.exec('BEGIN');
try {
for (const [t, col] of Object.entries(REASSIGN)) {
try { db.prepare(`UPDATE "${t}" SET "${col}" = ? WHERE "${col}" IS NOT NULL AND "${col}" != ?`).run(keptAdminId, keptAdminId); } catch { /* нет таблицы/колонки — пропуск */ }
}
for (const t of WIPE) {
try { db.prepare(`DELETE FROM "${t}"`).run(); } catch { /* нет таблицы — пропуск */ }
}
const del = db.prepare('DELETE FROM users WHERE id != ?').run(keptAdminId);
db.prepare(ADMIN_RESET_SQL).run(keptAdminId);
db.exec('COMMIT');
var deletedUsers = del.changes;
} catch (e) {
db.exec('ROLLBACK');
db.exec('PRAGMA foreign_keys = ON');
throw e;
}
db.exec('PRAGMA foreign_keys = ON');
let fkBad = 0;
try { fkBad = db.prepare('PRAGMA foreign_key_check').all().length; } catch {}
try { db.exec('VACUUM'); } catch {}
return {
ok: true,
keptAdminId,
deletedUsers,
remainingUsers: rowCount(db, 'users'),
fkDangling: fkBad,
kept: {
textbooks: rowCount(db, 'textbooks'),
questions: rowCount(db, 'questions'),
tests: rowCount(db, 'tests'),
courses: rowCount(db, 'courses'),
exam_tasks: rowCount(db, 'exam_tasks'),
},
};
}
module.exports = { REASSIGN, WIPE, KEEP, classify, pickKeptAdmin, runReset };
+67
View File
@@ -0,0 +1,67 @@
'use strict';
/**
* Integration: /api/client-errors — приём браузерных ошибок в error_log (level='client').
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, db, cleanup } = require('./setup');
app.use('/api/client-errors', require('../src/routes/clientErrors'));
after(() => cleanup());
describe('/api/client-errors', () => {
let student;
before(async () => { student = await getToken('student'); });
it('требует авторизацию (401)', async () => {
const res = await inject('POST', '/api/client-errors', { message: 'x' }, null);
assert.equal(res.status, 401);
});
it('пустое сообщение → 400', async () => {
const res = await inject('POST', '/api/client-errors', { message: ' ' }, student.token);
assert.equal(res.status, 400);
});
it('пишет ошибку в error_log с level=client', async () => {
const res = await inject('POST', '/api/client-errors', {
kind: 'error', message: 'TypeError: x is null',
stack: 'at foo (app.js:10:5)', source: '/js/app.js', line: 10, col: 5,
url: '/lab?sim=demo#x',
}, student.token);
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
const row = db.prepare(
"SELECT * FROM error_log WHERE level='client' AND user_id=? ORDER BY id DESC LIMIT 1"
).get(student.userId);
assert.ok(row, 'строка должна появиться');
assert.equal(row.message, 'TypeError: x is null');
assert.equal(row.route, '/lab?sim=demo#x');
assert.equal(row.method, 'error');
assert.match(row.stack, /app\.js:10:5/);
});
it('unhandledrejection → method=rejection, stack из source при отсутствии stack', async () => {
const res = await inject('POST', '/api/client-errors', {
kind: 'unhandledrejection', message: 'boom', source: '/js/x.js', line: 3, col: 1, url: '/dashboard',
}, student.token);
assert.equal(res.status, 200);
const row = db.prepare(
"SELECT * FROM error_log WHERE level='client' AND message='boom' ORDER BY id DESC LIMIT 1"
).get();
assert.equal(row.method, 'rejection');
assert.match(row.stack, /x\.js:3:1/);
});
it('длинные поля обрезаются (не падает)', async () => {
const res = await inject('POST', '/api/client-errors', {
message: 'M'.repeat(5000), stack: 'S'.repeat(20000), url: 'U'.repeat(2000),
}, student.token);
assert.equal(res.status, 200);
const row = db.prepare("SELECT * FROM error_log WHERE level='client' ORDER BY id DESC LIMIT 1").get();
assert.ok(row.message.length <= 1000);
assert.ok(row.stack.length <= 4000);
assert.ok(row.route.length <= 400);
});
});
+119
View File
@@ -0,0 +1,119 @@
'use strict';
/**
* Integration tests: /api/wishes — трекер пожеланий по улучшению.
* Covers: auth-only; создание (валидация); приватность (автор видит только свои,
* админ — все + counts); триаж только админом (403 ученику); смена статуса; удаление
* (автор «новое» / админ; чужое нельзя).
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, cleanup } = require('./setup');
app.use('/api/wishes', require('../src/routes/wishes'));
after(() => cleanup());
describe('/api/wishes', () => {
let s1, s2, admin;
before(async () => {
s1 = await getToken('student');
s2 = await getToken('student');
admin = await getToken('admin');
});
it('POST /wishes requires auth (401)', async () => {
const res = await inject('POST', '/api/wishes', { title: 'x' }, null);
assert.equal(res.status, 401);
});
it('создание: пустой заголовок → 400', async () => {
const res = await inject('POST', '/api/wishes', { title: ' ' }, s1.token);
assert.equal(res.status, 400);
});
let wishId;
it('создание пожелания учеником → 201, статус new', async () => {
const res = await inject('POST', '/api/wishes',
{ title: 'Тёмная тема', body: 'Хочу ночной режим', category: 'ui' }, s1.token);
assert.equal(res.status, 201, JSON.stringify(res.body));
assert.equal(res.body.status, 'new');
assert.equal(res.body.category, 'ui');
assert.equal(res.body.user_id, s1.userId);
wishId = res.body.id;
});
it('неизвестная категория → other', async () => {
const res = await inject('POST', '/api/wishes', { title: 'Что-то', category: 'hack' }, s1.token);
assert.equal(res.body.category, 'other');
});
it('приватность: автор видит свои', async () => {
const res = await inject('GET', '/api/wishes', null, s1.token);
assert.equal(res.status, 200);
assert.ok(res.body.wishes.some(w => w.id === wishId));
assert.equal(res.body.isAdmin, false);
});
it('приватность: другой ученик НЕ видит чужое', async () => {
const res = await inject('GET', '/api/wishes', null, s2.token);
assert.ok(!res.body.wishes.some(w => w.id === wishId));
});
it('админ видит все + counts', async () => {
const res = await inject('GET', '/api/wishes', null, admin.token);
assert.equal(res.status, 200);
assert.equal(res.body.isAdmin, true);
assert.ok(res.body.wishes.some(w => w.id === wishId));
assert.ok(res.body.counts && typeof res.body.counts.new === 'number');
// у админа в списке есть имя автора
const w = res.body.wishes.find(x => x.id === wishId);
assert.ok(w.author_name);
});
it('триаж учеником запрещён (403)', async () => {
const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'done' }, s1.token);
assert.equal(res.status, 403);
});
it('админ меняет статус + ответ → 200', async () => {
const res = await inject('PATCH', `/api/wishes/${wishId}`,
{ status: 'planned', admin_note: 'Запланировано на лето' }, admin.token);
assert.equal(res.status, 200, JSON.stringify(res.body));
assert.equal(res.body.status, 'planned');
assert.equal(res.body.admin_note, 'Запланировано на лето');
});
it('неверный статус → 400', async () => {
const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'bogus' }, admin.token);
assert.equal(res.status, 400);
});
it('фильтр по статусу у админа', async () => {
const res = await inject('GET', '/api/wishes?status=planned', null, admin.token);
assert.ok(res.body.wishes.every(w => w.status === 'planned'));
assert.ok(res.body.wishes.some(w => w.id === wishId));
});
it('автор НЕ может удалить уже обработанное (не new) → 403', async () => {
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s1.token);
assert.equal(res.status, 403);
});
it('чужой ученик не может удалить → 403', async () => {
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s2.token);
assert.equal(res.status, 403);
});
it('автор удаляет своё «новое» → 200', async () => {
const c = await inject('POST', '/api/wishes', { title: 'Черновик' }, s1.token);
const res = await inject('DELETE', `/api/wishes/${c.body.id}`, null, s1.token);
assert.equal(res.status, 200);
});
it('админ удаляет любое → 200', async () => {
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, admin.token);
assert.equal(res.status, 200);
const gone = await inject('GET', '/api/wishes', null, admin.token);
assert.ok(!gone.body.wishes.some(w => w.id === wishId));
});
});
+11 -4
View File
@@ -486,6 +486,13 @@
.tst-search { width: 100%; padding: 7px 12px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.83rem; background: #fff; color: var(--text); margin-bottom: 8px; outline: none; }
.tst-search:focus { border-color: var(--violet); }
.tst-empty { text-align: center; padding: 20px; color: var(--text-3); font-size: 0.82rem; }
.tst-pick-filters { display: flex; gap: 8px; margin-bottom: 8px; }
.tst-pick-sel { flex: 1; min-width: 0; padding: 6px 10px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.78rem; background: #fff; color: var(--text); cursor: pointer; outline: none; }
.tst-pick-sel:focus { border-color: var(--violet); }
.tst-pick-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; min-height: 24px; }
.tst-pick-count { font-size: 0.74rem; color: var(--text-3); }
.btn-tst-more { padding: 6px 14px; border: 1.5px solid var(--violet); border-radius: 8px; background: rgba(155,93,229,0.06); color: var(--violet); font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: background var(--tr); }
.btn-tst-more:hover { background: rgba(155,93,229,0.14); }
.src-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
/* formula bar */
/* Formula bar: hidden by default, toggled via #qf-fml-toggle */
@@ -1071,7 +1078,7 @@
<i data-lucide="clipboard-check" style="width:15px;height:15px"></i> Экзамен-модули
</button>
<button class="admin-nav-item" data-tab="games" onclick="switchTab(this)" id="btn-tab-games" style="display:none">
<i data-lucide="gamepad-2" style="width:15px;height:15px"></i> Игры
<i data-lucide="layout-grid" style="width:15px;height:15px"></i> Модули
</button>
<button class="admin-nav-item" data-tab="assistant" onclick="switchTab(this)" id="btn-tab-assistant" style="display:none">
<i data-lucide="sparkles" style="width:15px;height:15px"></i> Помощник Квантик
@@ -1568,10 +1575,10 @@
<div id="imggen-admin"><div style="color:var(--muted);font-size:0.84rem">Загрузка…</div></div>
</div>
<!-- ── Игры ── -->
<!-- ── Модули ── -->
<div class="tab-pane" id="tab-games">
<div class="section-title">Управление играми</div>
<div class="perm-desc" style="margin-bottom:20px">Отключённые игры скрываются из бокового меню и становятся недоступны для всех пользователей.</div>
<div class="section-title">Управление модулями</div>
<div class="perm-desc" style="margin-bottom:20px">Отключённые модули скрываются из бокового меню и становятся недоступны для всех пользователей.</div>
<div class="perm-grid" id="games-features-grid">
<div style="color:var(--muted);font-size:0.84rem">Загрузка…</div>
</div>
+127 -1
View File
@@ -109,6 +109,29 @@
.deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); }
.deadline-over { background: rgba(241,91,181,0.1); color: var(--pink); }
/* ── Долги (что висит у учеников) ── */
.debt-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; font-size: 0.84rem; color: var(--text-2); }
.debt-summary b { color: var(--text); font-family: 'Unbounded', sans-serif; }
.debt-card { border: 1px solid var(--border); border-radius: 14px; padding: 14px 18px; margin-bottom: 12px; }
.debt-card.has-over { border-color: rgba(241,91,181,0.35); }
.debt-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.debt-name { font-size: 0.9rem; font-weight: 700; }
.debt-email { font-size: 0.74rem; color: var(--text-3); }
.debt-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-left: auto; }
.debt-chip { font-size: 0.68rem; font-weight: 700; padding: 2px 9px; border-radius: var(--r-pill); white-space: nowrap; }
.dc-overdue { background: rgba(241,91,181,0.12); color: var(--pink); }
.dc-in_progress { background: rgba(255,179,71,0.14); color: var(--amber); }
.dc-revision { background: rgba(245,158,11,0.14); color: #d97706; }
.dc-not_started { background: rgba(15,23,42,0.06); color: var(--text-3); }
.debt-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--border); }
.debt-item-title { font-size: 0.82rem; font-weight: 600; flex: 1; min-width: 0; }
.debt-item-meta { font-size: 0.72rem; color: var(--text-3); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 2px; }
.debt-del { border: none; background: transparent; color: var(--text-3); cursor: pointer; padding: 5px; border-radius: 8px; flex-shrink: 0; }
.debt-del:hover { background: rgba(241,91,181,0.1); color: var(--pink); }
.debt-allclear { text-align: center; padding: 40px 20px; color: var(--green); font-weight: 600; }
.debt-rest { font-size: 0.78rem; color: var(--text-3); margin-top: 8px; }
.tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--pink); color: #fff; font-size: 0.68rem; font-weight: 800; vertical-align: 1px; }
/* ── Student search ── */
.student-search-wrap { position: relative; flex: 1; max-width: 360px; }
.student-search-wrap .form-input { width: 100%; }
@@ -628,6 +651,7 @@
<button class="tab-btn active" data-tab="dash" onclick="switchDetailTab(this)">Дашборд</button>
<button class="tab-btn" data-tab="members" onclick="switchDetailTab(this)">Ученики</button>
<button class="tab-btn" data-tab="assign" onclick="switchDetailTab(this)">Задания</button>
<button class="tab-btn" data-tab="debts" onclick="switchDetailTab(this)">Долги <span class="tab-badge" id="debts-tab-badge" style="display:none"></span></button>
<button class="tab-btn" data-tab="journal" onclick="switchDetailTab(this)">Журнал</button>
<button class="tab-btn" data-tab="announce" onclick="switchDetailTab(this)">Объявления</button>
<button class="tab-btn" data-tab="works" onclick="switchDetailTab(this)"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Работы</button>
@@ -667,6 +691,11 @@
<div class="assign-list" id="d-assignments"></div>
</div>
<!-- Debts (что висит у учеников) -->
<div class="tab-pane" id="dtab-debts">
<div id="debts-content"><div class="spinner"></div></div>
</div>
<!-- Journal -->
<div class="tab-pane" id="dtab-journal">
<div id="journal-content"><div class="spinner"></div></div>
@@ -794,7 +823,7 @@
<button class="atype-tab active" id="atype-random-btn" onclick="setAssignType('random')"><i data-lucide="shuffle" style="width:13px;height:13px;vertical-align:-2px"></i> Случайные</button>
<button class="atype-tab" id="atype-fixtest-btn" onclick="setAssignType('fixed_test')"><i data-lucide="clipboard-list" style="width:13px;height:13px;vertical-align:-2px"></i> Готовый тест</button>
<button class="atype-tab" id="atype-file-btn" onclick="setAssignType('file')"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Файл</button>
<button class="atype-tab" id="atype-upload-btn" onclick="setAssignType('upload')"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Сдать работу</button>
<button class="atype-tab" id="atype-upload-btn" onclick="setAssignType('upload')"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Загрузка работы</button>
</div>
<div id="a-type-hint" style="font-size:0.76rem;color:var(--text-3);margin:-12px 0 16px;padding:0 4px;line-height:1.5">
Вопросы подбираются случайно из базы по выбранному предмету
@@ -1199,6 +1228,102 @@
}).join('');
}
/* ══ Долги: что висит у учеников (классовые + личные задания) ══ */
const DEBT_STATUS = {
overdue: { label: 'просрочено', cls: 'dc-overdue' },
in_progress: { label: 'в процессе', cls: 'dc-in_progress' },
revision: { label: 'на доработке', cls: 'dc-revision' },
not_started: { label: 'не начато', cls: 'dc-not_started' },
};
const DEBT_TYPE_ICON = { test: 'clipboard-list', upload: 'upload', file: 'paperclip', textbook: 'book-open' };
let _debtData = null;
async function loadDebts() {
if (!currentClass) return;
const el = document.getElementById('debts-content');
el.innerHTML = '<div class="spinner"></div>';
try {
_debtData = await LS.classOutstanding(currentClass.id);
renderDebts();
} catch (e) {
el.innerHTML = `<div class="empty">Не удалось загрузить: ${esc(e.message || '')}</div>`;
}
}
function debtChips(c) {
return ['overdue', 'in_progress', 'revision', 'not_started']
.filter(k => c[k] > 0)
.map(k => `<span class="debt-chip ${DEBT_STATUS[k].cls}">${DEBT_STATUS[k].label}: ${c[k]}</span>`)
.join('');
}
function debtItemHtml(s, p) {
const st = DEBT_STATUS[p.status] || DEBT_STATUS.not_started;
const icon = DEBT_TYPE_ICON[p.type] || 'file-text';
const dl = p.deadline ? `<span>${p.status === 'overdue' ? 'просрочено ' : 'до '}${fmtDate(p.deadline)}</span>` : '';
const scopeTag = p.scope === 'direct' ? '<span style="color:var(--violet)">личное</span>' : '';
return `<div class="debt-item">
<i data-lucide="${icon}" style="width:15px;height:15px;color:var(--text-3);flex-shrink:0"></i>
<div style="flex:1;min-width:0">
<div class="debt-item-title">${esc(p.title)}</div>
<div class="debt-item-meta"><span class="debt-chip ${st.cls}">${st.label}</span>${dl}${scopeTag}</div>
</div>
<button class="debt-del" title="Удалить задание" onclick="deleteDebtAssignment(${p.assignment_id},'${p.scope}',${s.id})"><i data-lucide="trash-2" style="width:15px;height:15px"></i></button>
</div>`;
}
function renderDebts() {
const el = document.getElementById('debts-content');
if (!_debtData) { el.innerHTML = ''; return; }
const { summary, students } = _debtData;
const withDebt = students.filter(s => s.counts.total > 0);
const badge = document.getElementById('debts-tab-badge');
if (badge) {
if (summary.overdue > 0) { badge.textContent = summary.overdue; badge.style.display = ''; }
else badge.style.display = 'none';
}
if (!withDebt.length) {
el.innerHTML = `<div class="debt-allclear"><i data-lucide="check-circle-2" style="width:36px;height:36px"></i><div style="margin-top:8px">Задолженностей нет — все ученики всё сдали</div></div>`;
if (window.lucide) lucide.createIcons();
return;
}
let html = `<div class="debt-summary">
<span>Должников: <b>${summary.debtors}</b> из ${summary.students_total}</span>
<span>Просроченных позиций: <b style="color:var(--pink)">${summary.overdue}</b></span>
</div>`;
html += withDebt.map(s => `
<div class="debt-card${s.counts.overdue > 0 ? ' has-over' : ''}">
<div class="debt-head">
<div><div class="debt-name">${esc(s.name)}</div><div class="debt-email">${esc(s.email || '')}</div></div>
<div class="debt-chips">${debtChips(s.counts)}</div>
</div>
${s.pending.map(p => debtItemHtml(s, p)).join('')}
</div>`).join('');
const clear = summary.students_total - withDebt.length;
if (clear > 0) html += `<div class="debt-rest">Остальные ${clear} ученик(ов) — без задолженностей.</div>`;
el.innerHTML = html;
if (window.lucide) lucide.createIcons();
}
async function deleteDebtAssignment(id, scope, studentId) {
let title = 'задание', studentName = '';
const stu = _debtData && _debtData.students.find(s => s.id === studentId);
if (stu) {
studentName = stu.name;
const it = stu.pending.find(p => p.assignment_id === id);
if (it) title = it.title;
}
const msg = scope === 'direct'
? `Удалить персональное задание «${title}» у ученика ${studentName}?`
: `Удалить задание «${title}» у ВСЕГО класса? Оно исчезнет у всех учеников.`;
if (!await LS.confirm(msg, { title: 'Удалить задание', confirmText: 'Удалить', danger: true })) return;
try {
await LS.deleteAssignment(id);
LS.toast('Задание удалено', 'info');
await loadDebts();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ══ Detail tabs ══ */
function switchDetailTab(btn) {
const name = btn.dataset.tab;
@@ -1208,6 +1333,7 @@
document.getElementById('dtab-' + name).classList.add('active');
if (name === 'announce') loadAnnouncements();
if (name === 'dash') loadClassDashboard();
if (name === 'debts') loadDebts();
if (name === 'journal') loadJournal();
if (name === 'settings') loadSettings();
if (name === 'works') loadClassWorks();
+1 -1
View File
@@ -365,7 +365,7 @@
LS.sidebar?.init();
lucide.createIcons();
const feats = await LS.loadFeatures();
if (feats.collection === false) { window.location.replace('/403'); return; }
if (feats.collection === false && user?.role !== 'admin') { window.location.replace('/403'); return; }
LS.hideDisabledFeatures?.();
await loadCollection();
})();
+27 -16
View File
@@ -1039,7 +1039,7 @@ body {
body.no-class #lb-section { display: none !important; }
/* Gamification kill-switch.
When admin turns off the feature, body.no-gamification is set by
When admin turns off the feature, .no-gamification is set by
api.js/hideDisabledFeatures and EVERY XP / coin / streak / shop /
achievement / frame element must vanish — across the whole app,
not just the dashboard. The rules below cover:
@@ -1050,21 +1050,32 @@ body.no-class #lb-section { display: none !important; }
• a catch-all [data-gamified] hook that wraps any future block —
authors of new pages should wrap XP UI in a <div data-gamified>
instead of inventing new classes. */
body.no-gamification .gam-bar,
body.no-gamification .lb-widget,
body.no-gamification .achievements-section,
body.no-gamification #tab-btn-achievements,
body.no-gamification #tab-btn-shop,
body.no-gamification #tab-achievements,
body.no-gamification #tab-shop,
body.no-gamification #frames-section,
body.no-gamification .hero-xp-badge,
body.no-gamification .po-xp,
body.no-gamification .xp-card,
body.no-gamification .xp-bar,
body.no-gamification .xp-pill,
body.no-gamification .xp-badge,
body.no-gamification [data-gamified] { display: none !important; }
.no-gamification .gam-bar,
.no-gamification .lb-widget,
.no-gamification .achievements-section,
.no-gamification #tab-btn-achievements,
.no-gamification #tab-btn-shop,
.no-gamification #tab-achievements,
.no-gamification #tab-shop,
.no-gamification #frames-section,
.no-gamification .hero-xp-badge,
.no-gamification .po-xp,
.no-gamification .xp-card,
.no-gamification .xp-bar,
.no-gamification .xp-pill,
.no-gamification .xp-badge,
/* challenges / еженедельные испытания (dashboard) */
.no-gamification .ch-widget,
.no-gamification #ch-section,
/* серия/стрик: календарь, стат-кольцо, чипы на карточке питомца */
.no-gamification .streak-cal,
.no-gamification #sr-streak,
.no-gamification .hc-pet .chip-streak,
.no-gamification .hc-pet .chip-goal,
/* монеты (профиль) и xp-прогресс */
.no-gamification #p-coins-row,
.no-gamification .gam-progress,
.no-gamification [data-gamified] { display: none !important; }
/* ══════════════════════════════════════════
RESPONSIVE — SMALL PHONES (≤ 480px)
+148 -24
View File
@@ -81,7 +81,33 @@
}
.ab-btn:hover { background: rgba(255,255,255,0.25); }
/* ── Hero cards row (Reading · Lab of day · Pet) ── */
.hero-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.hero-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
/* ── Live online-lesson banner ── */
.live-lesson {
display: flex; align-items: center; gap: 14px; text-decoration: none;
background: linear-gradient(100deg, #059652, #06D6A0); color: #fff;
border-radius: 16px; padding: 14px 20px; margin-bottom: 18px;
box-shadow: 0 6px 22px rgba(5,150,82,0.28); transition: transform .15s, box-shadow .15s;
}
.live-lesson:hover { transform: translateY(-1px); box-shadow: 0 10px 28px rgba(5,150,82,0.34); }
.ll-dot { width: 12px; height: 12px; border-radius: 50%; background: #fff; flex-shrink: 0;
box-shadow: 0 0 0 0 rgba(255,255,255,0.7); animation: llPulse 1.6s infinite; }
@keyframes llPulse {
0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.6); }
70% { box-shadow: 0 0 0 10px rgba(255,255,255,0); }
100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
}
.ll-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ll-text b { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ll-text span { font-size: 0.78rem; opacity: 0.92; }
.ll-cta { flex-shrink: 0; background: rgba(255,255,255,0.95); color: #059652;
font-weight: 800; font-size: 0.82rem; padding: 8px 16px; border-radius: 10px; white-space: nowrap; }
@media (max-width: 480px) {
.live-lesson { padding: 12px 14px; gap: 10px; }
.ll-cta { padding: 7px 12px; font-size: 0.78rem; }
}
.hero-card {
position: relative; border-radius: 18px; padding: 18px 20px;
display: flex; flex-direction: column; min-height: 196px;
@@ -1532,6 +1558,13 @@
<div class="container">
<!-- Live online-lesson status (student/teacher) -->
<a class="live-lesson" id="live-lesson-banner" href="/classroom" style="display:none">
<span class="ll-dot"></span>
<span class="ll-text"><b id="ll-title">Идёт онлайн-урок</b><span id="ll-sub"></span></span>
<span class="ll-cta" id="ll-cta">Присоединиться</span>
</a>
<!-- Gamification Bar (students only) -->
<div class="gam-bar" id="gam-bar" style="display:none">
<div class="gam-level">
@@ -1750,6 +1783,11 @@
<div class="widget" id="w-tests">
<div class="w-head"><div class="w-title">Тесты</div></div>
<div class="subj-mini-grid" id="subjects-list"><div id="subjects-sk"></div></div>
<!-- Витрина: тесты, открытые учителем/админом ученикам -->
<div id="avail-tests-wrap" style="display:none;margin-top:14px">
<div style="font-size:.74rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin:0 0 8px 2px">Доступные тесты</div>
<div class="subj-mini-grid" id="available-tests-list"></div>
</div>
</div>
<!-- Col 3: Progress -->
@@ -1884,6 +1922,7 @@
<!-- Join modal -->
<!-- Quick-start test modal -->
<script src="/js/api.js"></script>
<script src="/js/assignment-utils.js"></script>
<script src="/js/sound.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
@@ -2222,10 +2261,14 @@
async function loadSubjects() {
const list = document.getElementById('subjects-list');
try {
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест', topic:'По теме', random:'Случайный' };
const subjects = await LS.getSubjects();
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест' };
// Прячем предметы, по которым нечего запустить (нет вопросов в банке и нет фикс-теста).
const subjects = (await LS.getSubjects())
.filter(s => (s.question_count || 0) > 0 || s.default_test_id);
if (!subjects.length) { list.innerHTML = '<div class="empty">Тесты пока недоступны</div>'; return; }
list.innerHTML = subjects.map((s, si) => {
const mode = s.default_mode || 'exam';
let mode = s.default_mode || 'exam';
if (mode !== 'exam' && mode !== 'practice') mode = 'practice'; // старые topic/random → practice (старт сессии их не принимает)
const count = s.default_count || 25;
const testId = s.default_test_id || null;
const modeLabel = SUBJ_MODE_LABELS[mode] || mode;
@@ -2253,6 +2296,32 @@
window.location.href = url;
}
/* Витрина доступных тестов (бэкенд ученику отдаёт только помеченные доступными). */
async function loadAvailableTests() {
const wrap = document.getElementById('avail-tests-wrap');
const list = document.getElementById('available-tests-list');
if (!wrap || !list) return;
try {
const tests = await LS.getTests();
if (!tests || !tests.length) { wrap.style.display = 'none'; return; }
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
list.innerHTML = tests.map((t, i) => {
const color = SUBJ_COLORS[t.subject_slug] || '#9B5DE5';
const iconName = ICONS[t.subject_slug] || 'book-open';
return `<div class="subj-mini-card stagger-item" style="--i:${i}" onclick="startSubjectTest('${t.subject_slug}','exam',25,${t.id})">
<div class="smc-icon" style="background:${color}">${lci(iconName)}</div>
<div class="smc-body">
<div class="smc-name">${esc(t.title)}</div>
<div class="smc-meta">${SUBJ_N[t.subject_slug] || t.subject_slug} · ${t.question_count} вопр.</div>
</div>
<i data-lucide="chevron-right" class="smc-arrow"></i>
</div>`;
}).join('');
wrap.style.display = '';
reIcons();
} catch { wrap.style.display = 'none'; }
}
/* ══ ЗАДАНИЯ ══════════════════════════════════════════════════════════ */
async function loadAssignments() {
try {
@@ -2346,15 +2415,8 @@
body.classList.toggle('collapsed');
}
/* ── Urgency sort score (lower = shown first) ── */
function urgencyScore(a) {
if (a.session_status === 'in_progress') return -4; // in progress <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> top
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
if (dlMs < 0) return -3; // overdue
if (dlMs < 24 * 3600 * 1000) return -2; // urgent <24h
if (dlMs < Infinity) return dlMs; // sorted by deadline
return 1e12; // no deadline <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> last
}
/* ── Urgency sort score (lower = shown first) — общий модуль ── */
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
/* ── Is assignment urgent for teacher (within 48h) ── */
function isTeacherUrgent(a) {
@@ -2422,7 +2484,7 @@
}
/* ── Upload-only homework (no test, no file) ── */
if (a.is_homework && !a.file_id && !a.session_id && a.count <= 1 && (!a.subject_slug || a.subject_slug === 'other')) {
if (AssignmentUtils.type(a) === 'upload') {
const over = a.deadline && new Date(a.deadline) < new Date();
const sub = _mySubmissions.get(a.id);
const metaParts = [classStr, dl ? `до ${dl}` : null,
@@ -2659,18 +2721,22 @@
reIcons(); return;
}
// Classify
// Classify (active/overdue/done) — тип и «сдано» из общего модуля AssignmentUtils.
function classify(a) {
const maxAtt = a.max_attempts || 0;
const usedAtt = a.attempts_used ?? 0;
if (a.textbook_id) {
if (a.completed_at || a.textbook_all_read) return 'done';
const t = AssignmentUtils.type(a);
if (t === 'textbook') {
if (AssignmentUtils.isDone(a)) return 'done';
if (a.deadline && new Date(a.deadline) < now) return 'overdue';
return 'active';
}
if (maxAtt > 0 && usedAtt >= maxAtt) return 'done';
if (a.session_status === 'completed' && a.mode !== 'repeat') return 'done';
if (!a.file_id && a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
if (t === 'test') {
if (AssignmentUtils.isDone(a)) return 'done';
if (a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
return 'active';
}
// upload / file: «сдано» по сабмишену здесь не считаем (как и раньше — статус
// показывает чип сдачи в карточке); upload просрочивается по дедлайну, file — всегда активен.
if (t === 'upload' && a.deadline && new Date(a.deadline) < now) return 'overdue';
return 'active';
}
@@ -3670,12 +3736,36 @@
document.getElementById('act-cal-pane').classList.toggle('visible', tab === 'calendar');
}
/* Колонка прогресса (#w-progress-col) — это один .widget-бокс с тремя секциями
(карточка / по предметам / результаты). Если все секции скрыты (напр. флешкарты
отключены и нет данных) — прячем сам бокс, иначе висит пустая рамка. */
function syncProgressCol() {
const col = document.getElementById('w-progress-col');
if (!col) return;
const any = ['w-flashcard', 'w-subj-progress', 'w-last-results'].some(id => {
const e = document.getElementById(id);
return e && getComputedStyle(e).display !== 'none';
});
col.style.display = any ? '' : 'none';
}
/* Hero-ряд (чтение/лаборатория/питомец): карточки скрываются по фиче (через CSS).
Подгоняем число колонок под видимые карточки и прячем весь ряд, если пусто. */
function syncHeroRow() {
const row = document.getElementById('hero-row');
if (!row) return;
const vis = [...row.querySelectorAll('.hero-card')]
.filter(c => getComputedStyle(c).display !== 'none');
row.style.display = vis.length ? '' : 'none';
// ширину колонок под число карточек делает CSS (auto-fit), мобайл не трогаем.
}
/* ══ WIDGET: Last results (compact, 5 items) ══════════════════════ */
function loadLastResultsWidget(rows) {
const w = document.getElementById('w-last-results');
if (!w) return;
const completed = (rows || []).filter(r => r.score !== null && r.total > 0).slice(0, 5);
if (!completed.length) { w.style.display = 'none'; return; }
if (!completed.length) { w.style.display = 'none'; syncProgressCol(); return; }
w.style.display = '';
document.getElementById('last-results-list').innerHTML = completed.map(h => {
const pct = Math.round(h.score / h.total * 100);
@@ -3689,6 +3779,7 @@
</div>
</div>`;
}).join('');
syncProgressCol();
}
/* ══ WIDGET: Subject progress bars ════════════════════════════════ */
@@ -3702,7 +3793,7 @@
bySubj[r.subject_slug].scores.push(Math.round(r.score / r.total * 100));
});
const entries = Object.entries(bySubj);
if (!entries.length) { w.style.display = 'none'; return; }
if (!entries.length) { w.style.display = 'none'; syncProgressCol(); return; }
w.style.display = '';
document.getElementById('subj-progress-bars').innerHTML = entries.map(([slug, d]) => {
const avg = Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length);
@@ -3713,6 +3804,7 @@
<span class="sp-pct" style="color:${color}">${avg}%</span>
</div>`;
}).join('');
syncProgressCol();
}
/* ══ WIDGET: Theory progress ══════════════════════════════════════ */
@@ -4285,6 +4377,7 @@
loadLabOfDay();
loadPetHero();
loadFlashcardWidget();
syncHeroRow(); // спрятать карточки отключённых модулей и подогнать сетку
}
/* ══ WIDGET: Flashcard review (random card from pool) ════════════════ */
@@ -4298,6 +4391,7 @@
renderFlashcardWidget(r);
w.style.display = '';
} catch { /* фича выключена или ошибка — оставляем скрытым */ }
syncProgressCol(); // если карточка скрыта и нет прогресса/результатов — спрятать бокс
}
function renderFlashcardWidget(r) {
@@ -4473,6 +4567,7 @@
} else {
// Student: full layout
loadSubjects();
loadAvailableTests();
loadAssignments();
loadStats();
loadGamification();
@@ -4481,13 +4576,42 @@
loadDashboardStats();
applyDashboardPrefs();
}
loadLiveLesson();
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadLiveLesson(); });
LS.notif.init();
// Статус онлайн-урока: показываем баннер, если у ученика/учителя идёт активная сессия.
async function loadLiveLesson() {
const el = document.getElementById('live-lesson-banner');
if (!el) return;
let data;
try { data = await LS.crGetMySession(); } catch { el.style.display = 'none'; return; }
const s = data && data.session;
if (!s) { el.style.display = 'none'; return; }
const title = (s.title && s.title.trim()) ? s.title.trim() : 'Онлайн-урок';
document.getElementById('ll-title').textContent = (isTeacher ? 'Ваш урок идёт: ' : 'Идёт урок: ') + title;
let sub;
if (isTeacher) {
const online = Array.isArray(s.attendance) ? s.attendance.filter(a => !a.left_at).length : 0;
sub = online ? (online + ' онлайн') : 'ожидание учеников';
} else {
sub = data.wasJoined ? 'Вы участник — вернуться к доске' : 'Нажмите, чтобы присоединиться';
}
document.getElementById('ll-sub').textContent = sub;
document.getElementById('ll-cta').textContent = isTeacher
? 'Вернуться к доске'
: (data.wasJoined ? 'Вернуться' : 'Присоединиться');
el.style.display = '';
}
// Real-time SSE for page-specific events (notif handled by notifications.js)
LS.connectSSE(ev => {
if (ev.type === 'assignment') {
LS.toast(ev.message, 'info');
isTeacher ? loadAdminAssignments() : loadAssignments();
} else if (ev.type === 'classroom_live') {
loadLiveLesson();
if (ev.state === 'started' && !isTeacher && window.LS && LS.sfx) LS.sfx.play('user_joined');
} else if (ev.type === 'session') {
LS.toast(ev.message, 'info');
if (isTeacher) loadAdminSessions();
+228 -22
View File
@@ -125,6 +125,41 @@
/* student name in teacher view */
.hw-student-name { font-size: 0.78rem; font-weight: 700; color: var(--violet); }
/* ── section titles (multi-block student view) ── */
.hw-sec-title {
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
color: #0F172A; margin: 4px 0 12px; display: flex; align-items: center; gap: 9px;
}
.hw-sec-count {
font-family: 'Manrope', sans-serif; font-size: 0.7rem; font-weight: 700;
color: var(--violet); background: rgba(155,93,229,0.1);
padding: 2px 9px; border-radius: 999px;
}
#hw-active-wrap { margin-bottom: 28px; }
/* ── active assignment cards ── */
.hw-acard {
background: #fff; border-radius: 16px; padding: 16px 18px;
border: 1px solid rgba(15,23,42,0.06); border-left: 3px solid var(--ac, #9B5DE5);
display: flex; align-items: center; gap: 14px; transition: all .15s;
}
.hw-acard:hover { box-shadow: 0 2px 12px rgba(15,23,42,0.06); }
.hw-acard.over { border-left-color: #EF476F; }
.hw-acard.urgent { border-left-color: #F59E0B; }
.hw-acard-icon {
width: 42px; height: 42px; border-radius: 12px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
}
.hw-acard-body { flex: 1; min-width: 0; }
.hw-acard-title { font-size: 0.88rem; font-weight: 700; color: #0F172A; margin-bottom: 4px; }
.hw-acard-meta { font-size: 0.74rem; color: var(--text-3); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.hw-acard-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.hw-dl-chip { font-size: 0.7rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
.hw-dl-soon { background: rgba(245,158,11,0.12); color: #F59E0B; }
.hw-dl-over { background: rgba(239,71,111,0.12); color: #EF476F; }
.hw-dl-ok { background: rgba(15,23,42,0.05); color: var(--text-3); }
.hw-sub-chip { font-size: 0.68rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
@media (max-width: 768px) {
.container { padding: 16px 14px 80px; }
.hw-top { gap: 8px; }
@@ -133,6 +168,8 @@
.hw-card-right { flex-direction: row; align-items: center; justify-content: flex-start; width: 100%; }
.hw-card-actions { flex-wrap: wrap; }
.hw-upload-area { padding: 20px 16px; }
.hw-acard { flex-wrap: wrap; }
.hw-acard-right { width: 100%; justify-content: flex-end; }
}
@media (max-width: 480px) {
.container { padding: 12px 10px 80px; }
@@ -152,8 +189,15 @@
<div class="page-title">Домашние задания</div>
<div class="page-sub" id="hw-sub">Загрузка…</div>
<!-- Student: active assignments (что нужно сделать) -->
<div id="hw-active-wrap" style="display:none">
<div class="hw-sec-title">Актуальные задания <span class="hw-sec-count" id="hw-active-count"></span></div>
<div class="hw-list" id="hw-active-list"></div>
</div>
<!-- Student: upload area -->
<div id="hw-upload-wrap" style="display:none">
<div class="hw-sec-title">Сдать работу</div>
<div class="hw-upload-area" id="hw-upload-area" onclick="document.getElementById('hw-file-input').click()">
<div class="hw-upload-icon"><i data-lucide="upload-cloud" style="width:36px;height:36px"></i></div>
<div class="hw-upload-text">Загрузить работу</div>
@@ -195,6 +239,7 @@
</div>
<!-- Student: status filters -->
<div class="hw-sec-title" id="hw-mysubs-title" style="display:none">Мои сдачи</div>
<div class="hw-top" id="hw-top-student" style="display:none">
<div class="hw-status-filters">
<button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button>
@@ -213,6 +258,7 @@
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/assignment-utils.js"></script>
<script>
const { user, isTeacher, isAdmin } = LS.initPage();
if (!user) throw new Error('Not logged in');
@@ -247,6 +293,14 @@
resubmitted: 'Повторно', accepted: 'Принято'
};
/* subject label/colour/icon maps (как на дашборде) */
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
const SUBJ_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap', other:'file-check' };
const SUBJ_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B', other:'#7c3aed' };
let _assignments = []; // актуальные задания (LS.myAssignments)
let _subByAsgn = new Map(); // assignment_id -> последняя сдача
/* ── filter ── */
function filterStatus(st, btn) {
_statusFilter = st;
@@ -257,33 +311,31 @@
/* ── STUDENT VIEW ── */
async function initStudent() {
document.getElementById('hw-sub').textContent = 'Сдавайте работы и отслеживайте оценки';
document.getElementById('hw-sub').textContent = 'Ваши актуальные задания и сданные работы';
document.getElementById('hw-top-student').style.display = '';
document.getElementById('hw-mysubs-title').style.display = '';
// Find student's class
// Find student's class (нужен для загрузки работ без привязки к заданию)
try {
const classes = await LS.myClasses();
if (classes.length) {
_studentClassId = classes[0].id;
document.getElementById('hw-upload-wrap').style.display = '';
// Load assignments for selector
try {
const feed = await LS.classFeed(classes[0].id);
const sel = document.getElementById('hw-assignment-sel');
(feed.assignments || []).forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.title;
sel.appendChild(opt);
});
} catch {}
}
} catch {}
// Load submissions
// Грузим актуальные задания (все классы) + сдачи параллельно
try {
_submissions = await LS.getMySubmissions();
const [assigns, subs] = await Promise.all([
LS.myAssignments().catch(() => []),
LS.getMySubmissions().catch(() => []),
]);
_assignments = Array.isArray(assigns) ? assigns : [];
_submissions = Array.isArray(subs) ? subs : [];
_subByAsgn.clear();
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
populateAssignmentSelect(_assignments);
renderActiveAssignments();
renderSubmissions();
} catch {
document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>';
@@ -312,14 +364,22 @@
}
async function submitHomework() {
if (!_selectedFile || !_studentClassId) return;
if (!_selectedFile) return;
const sel = document.getElementById('hw-assignment-sel');
const assignId = sel.value;
// Класс берём от выбранного задания (важно для учеников в нескольких классах),
// иначе — первый класс ученика.
let classId = _studentClassId;
if (assignId && sel.selectedOptions[0] && sel.selectedOptions[0].dataset.class) {
classId = sel.selectedOptions[0].dataset.class;
}
if (!classId) { LS.toast('Вы не состоите в классе', 'error'); return; }
const btn = document.getElementById('hw-submit-btn');
btn.disabled = true;
try {
const fd = new FormData();
fd.append('file', _selectedFile);
fd.append('class_id', _studentClassId);
const assignId = document.getElementById('hw-assignment-sel').value;
fd.append('class_id', classId);
if (assignId) fd.append('assignment_id', assignId);
const msg = document.getElementById('hw-message').value.trim();
if (msg) fd.append('message', msg);
@@ -336,12 +396,20 @@
// Reload
_submissions = await LS.getMySubmissions();
renderSubmissions();
syncStudentLists();
} catch (e) {
LS.toast(e.message || 'Ошибка отправки', 'error');
} finally { btn.disabled = !_selectedFile; }
}
/* Пересобрать карту сдач и перерисовать обе студенческие секции. */
function syncStudentLists() {
_subByAsgn.clear();
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
renderActiveAssignments();
renderSubmissions();
}
async function resubmitHomework(subId) {
const input = document.createElement('input');
input.type = 'file';
@@ -355,7 +423,7 @@
await LS.resubmitWork(subId, fd);
LS.toast('Работа отправлена повторно!', 'success');
_submissions = await LS.getMySubmissions();
renderSubmissions();
syncStudentLists();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
};
input.click();
@@ -366,7 +434,7 @@
try {
await LS.deleteSubmission(id);
_submissions = _submissions.filter(s => s.id !== id);
renderSubmissions();
syncStudentLists();
LS.toast('Удалено', 'info');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
@@ -381,6 +449,144 @@
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ══ АКТУАЛЬНЫЕ ЗАДАНИЯ (что нужно сделать) ══════════════════════════ */
// Тип / «сдано» / срочность — из общего модуля AssignmentUtils (тот же, что у дашборда
// и сервера). Вид ученика: upload/file закрыт ТОЛЬКО при принятой сдаче (acceptedOnly).
function asgnType(a) { return AssignmentUtils.type(a); }
function asgnDone(a) { return AssignmentUtils.isDone(a, _subByAsgn.get(a.id), { acceptedOnly: true }); }
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
function deadlineChip(a) {
if (!a.deadline) return '<span class="hw-dl-chip hw-dl-ok">Без срока</span>';
const dlMs = new Date(a.deadline) - Date.now();
const date = new Date(a.deadline).toLocaleDateString('ru', { day: 'numeric', month: 'short' });
if (dlMs < 0) return `<span class="hw-dl-chip hw-dl-over">Просрочено · ${date}</span>`;
if (dlMs < 24 * 3600 * 1000) return `<span class="hw-dl-chip hw-dl-soon">Сегодня · до ${date}</span>`;
const days = Math.ceil(dlMs / 86400000);
const txt = days === 1 ? '1 день' : `${days} дн.`;
return `<span class="hw-dl-chip hw-dl-ok">${txt} · до ${date}</span>`;
}
function actionFor(a) {
const t = asgnType(a);
if (t === 'textbook') {
let hash = '';
if (a.textbook_paragraphs) { const m = String(a.textbook_paragraphs).match(/^\s*(\d+)/); if (m) hash = '#p' + m[1]; }
const href = `/textbook/${a.textbook_slug || ''}${hash}`;
return `<a class="hw-btn hw-btn-primary" href="${href}">${(a.textbook_read_count || 0) > 0 ? 'Продолжить' : 'Открыть'}</a>`;
}
if (t === 'file') {
const sub = _subByAsgn.get(a.id);
const submit = sub && sub.status !== 'revision'
? `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`
: `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
return `<a class="hw-btn" href="${LS.downloadFileUrl(a.file_id)}" target="_blank" download>Скачать</a>${submit}`;
}
if (t === 'upload') {
const sub = _subByAsgn.get(a.id);
if (sub && sub.status !== 'revision') {
return `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`;
}
return `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
}
// test
const inProgress = a.session_status === 'in_progress';
const isDone = a.session_status === 'completed';
const label = inProgress ? 'Продолжить' : (isDone && a.mode === 'repeat') ? 'Повторить' : 'Начать';
return `<button class="hw-btn hw-btn-primary" onclick="startAsgn(event,${a.id},'${a.mode || 'exam'}')">${label}</button>`;
}
function activeCardHtml(a) {
const t = asgnType(a);
const color = SUBJ_COLORS[a.subject_slug] || (t === 'textbook' ? '#7c3aed' : '#9B5DE5');
const icon = t === 'textbook' ? 'book-open-text'
: t === 'file' ? 'paperclip'
: t === 'upload' ? 'upload'
: (SUBJ_ICONS[a.subject_slug] || 'file-text');
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
const over = dlMs < 0;
const urgent = !over && dlMs < 24 * 3600 * 1000;
const cls = over ? ' over' : urgent ? ' urgent' : '';
const classStr = a.class_id ? esc(a.class_name) : 'Личное';
const subjStr = SUBJ[a.subject_slug] || (t === 'textbook' ? 'Чтение' : '');
const meta = [classStr, subjStr].filter(Boolean).join(' · ');
return `<div class="hw-acard${cls}" style="--ac:${color}">
<div class="hw-acard-icon" style="background:${color}1a;color:${color}"><i data-lucide="${icon}" style="width:20px;height:20px"></i></div>
<div class="hw-acard-body">
<div class="hw-acard-title">${esc(a.title)}</div>
<div class="hw-acard-meta">${meta ? `<span>${meta}</span>` : ''}${deadlineChip(a)}</div>
</div>
<div class="hw-acard-right">${actionFor(a)}</div>
</div>`;
}
function renderActiveAssignments() {
const wrap = document.getElementById('hw-active-wrap');
const list = document.getElementById('hw-active-list');
if (!wrap || !list) return;
// Только задания с флагом ДЗ (is_homework) — это страница «Домашние задания»,
// обычные тесты/экзамены сюда не попадают.
const active = _assignments
.filter(a => a.is_homework && !asgnDone(a))
.sort((x, y) => urgencyScore(x) - urgencyScore(y));
if (!active.length) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
document.getElementById('hw-active-count').textContent = active.length;
list.innerHTML = active.map(activeCardHtml).join('');
lucide.createIcons();
}
// Наполнить выпадашку «Задание» при загрузке работы — по ВСЕМ классам ученика.
function populateAssignmentSelect(list) {
const sel = document.getElementById('hw-assignment-sel');
if (!sel) return;
sel.querySelectorAll('option[data-asgn]').forEach(o => o.remove());
// Привязать загрузку можно только к ДЗ, куда ученик сдаёт файл (тип upload/file).
list.filter(a => a.is_homework && (asgnType(a) === 'upload' || asgnType(a) === 'file')).forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.title + (a.class_name && a.class_name !== 'Личное задание' ? ' · ' + a.class_name : '');
opt.dataset.asgn = '1';
if (a.class_id) opt.dataset.class = a.class_id;
sel.appendChild(opt);
});
}
// Начать/продолжить тест-задание (как на дашборде).
async function startAsgn(e, id, mode) {
const btn = e.currentTarget;
const orig = btn.textContent;
btn.disabled = true; btn.textContent = '…';
try {
const r = await LS.startAssignment(id);
if (r.error && r.max_attempts) {
LS.toast(`Исчерпан лимит попыток (${r.attempts_used}/${r.max_attempts})`, 'warn');
btn.disabled = false; btn.textContent = orig; return;
}
const aMode = r.assignment_mode || mode || 'exam';
if (r.status === 'completed' && aMode !== 'repeat') location.href = `/test-result?session=${r.session_id}`;
else location.href = `/test-run?session=${r.session_id}&assignment_mode=${aMode}`;
} catch (err) {
const isLimit = err.message && (err.message.includes('лимит') || err.message.includes('Исчерпан'));
LS.toast(isLimit ? err.message : ('Ошибка: ' + err.message), isLimit ? 'warn' : 'error');
btn.disabled = false; btn.textContent = orig;
}
}
// «Сдать» из карточки → прокрутить к области загрузки и преднабрать задание.
function sdatNow(assignId) {
const wrap = document.getElementById('hw-upload-wrap');
if (!wrap || wrap.style.display === 'none') {
LS.toast('Загрузка работ доступна участникам класса', 'warn'); return;
}
const sel = document.getElementById('hw-assignment-sel');
if (sel && [...sel.options].some(o => o.value == assignId)) sel.value = String(assignId);
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
const area = document.getElementById('hw-upload-area');
if (area) { area.classList.add('dragover'); setTimeout(() => area.classList.remove('dragover'), 1200); }
}
/* ── TEACHER VIEW ── */
async function initTeacher() {
document.getElementById('hw-sub').textContent = 'Проверяйте работы учеников и ставьте оценки';
+8 -2
View File
@@ -284,9 +284,15 @@
el.innerHTML = rows.map(r => {
const dt = new Date(r.created_at);
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid var(--pink)">
const isClient = r.level === 'client';
const accent = isClient ? 'var(--violet)' : 'var(--pink)';
const badge = isClient
? `<span style="font-size:0.64rem;font-weight:800;letter-spacing:.03em;padding:2px 7px;border-radius:999px;background:rgba(155,93,229,0.12);color:var(--violet)">БРАУЗЕР</span>`
: '';
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid ${accent}">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
<span style="font-size:0.78rem;color:var(--pink);font-weight:700">${r.method || ''} ${esc(r.route || '')}</span>
${badge}
<span style="font-size:0.78rem;color:${accent};font-weight:700">${esc(r.method || '')} ${esc(r.route || '')}</span>
<span style="font-size:0.72rem;color:var(--text-3);margin-left:auto">${ds}</span>
${r.user_id ? `<span style="font-size:0.72rem;color:var(--text-3)">user:${r.user_id}</span>` : ''}
</div>
+5 -5
View File
@@ -311,7 +311,7 @@
}
async function bulk(allow) {
if (!allow && !confirm(`Закрыть «${_selContent.title}» у всех классов?`)) return;
if (!allow && !await LS.confirm(`Закрыть доступ к «${_selContent.title}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
const classes = _targets.classes || [];
try {
await Promise.all(classes.map(c =>
@@ -436,7 +436,7 @@
if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; }
const srcId = Number(sel.value);
const srcName = sel.options[sel.selectedIndex].text;
if (!confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`)) return;
if (!await LS.confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`, { title: 'Скопировать доступ', confirmText: 'Скопировать', danger: false })) return;
try {
const src = await LS.accessClassOpen(srcId);
const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref]));
@@ -449,7 +449,7 @@
}
async function classBulk(allow) {
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
if (!allow && !await LS.confirm(`Закрыть весь контент у класса «${_selClass.name}»?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
try {
await Promise.all(all.map(([type, ref]) =>
@@ -543,7 +543,7 @@
const classes = _matrix.classes || [];
const allOpen = classes.length && classes.every(c => ((_matrix.open[c.id] || {})[type] || []).includes(ref));
const open = !allOpen;
if (!open && !confirm(`Закрыть «${contentTitle(type, ref)}» у всех классов?`)) return;
if (!open && !await LS.confirm(`Закрыть доступ к «${contentTitle(type, ref)}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
try {
await Promise.all(classes.map(c => LS.accessSetRule(type, ref, 'class', c.id, open ? 1 : null)));
classes.forEach(c => mxApply(_matrix.open[c.id] || (_matrix.open[c.id] = {}), type, ref, open));
@@ -557,7 +557,7 @@
const allOpen = items.length && items.every(([t, ref]) => (o[t] || []).includes(ref));
const open = !allOpen;
const cls = (_matrix.classes.find(c => c.id === classId) || {}).name || ('#' + classId);
if (!open && !confirm(`Закрыть весь контент у класса «${cls}»?`)) return;
if (!open && !await LS.confirm(`Закрыть весь контент у класса «${cls}»?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
try {
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', classId, open ? 1 : null)));
items.forEach(([t, ref]) => mxApply(o, t, ref, open));
+101 -4
View File
@@ -65,10 +65,11 @@
var cfg = {}; try { cfg = await LS.adminGetAssistant(); } catch (e) {}
var providers = cfg.providers || [], activeId = cfg.activeId, presets = cfg.presets || [], kiloModels = cfg.kiloModels || [];
var health = cfg.health || {};
// ── Баннер failover ──
if (cfg.failover) {
var fo = cfg.failover, rmap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка' };
var fo = cfg.failover, rmap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка', health: 'не прошёл авто-проверку' };
var when = ''; try { when = new Date(fo.at).toLocaleString('ru'); } catch (e) {}
var ban = document.createElement('div');
ban.style.cssText = 'margin-top:14px;padding:11px 14px;border-radius:11px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.4);color:#92400e;font-size:.84rem;line-height:1.5;display:flex;align-items:flex-start;gap:12px';
@@ -108,6 +109,20 @@
'</div>';
host.appendChild(pc);
// ── Сканер бесплатных моделей шлюза Kilo ──
var sk = document.createElement('div');
sk.className = 'perm-card'; sk.style.cssText = 'flex-direction:column;align-items:stretch;gap:10px;margin-top:14px';
sk.innerHTML =
'<div class="perm-label"><i data-lucide="radar" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Каталог бесплатных моделей Kilo</div>' +
'<div class="perm-desc">Сканирует шлюз, находит бесплатные модели и тестирует каждую тест-запросом на русском. Этот список показывается в выпадашке моделей у Kilo-провайдеров. ' +
(cfg.kiloModelsCustom ? '<b>Сейчас: обновлён сканированием.</b>' : 'Сейчас: встроенный список.') + '</div>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<button id="asst-scan" class="asst-ib primary" style="padding:8px 14px;display:inline-flex;align-items:center;gap:6px">' + SPARK + 'Сканировать модели</button>' +
(cfg.kiloModelsCustom ? '<button id="asst-scan-reset" class="asst-ib">Вернуть встроенный список</button>' : '') +
'<span id="asst-scan-st" style="font-size:.78rem;color:#8a94a6"></span></div>' +
'<div id="asst-scan-res"></div>';
host.appendChild(sk);
// ── Настройки/статистика ──
var u = cfg.usage || {}, u30 = cfg.usage30 || {}, f = cfg.feedback || {};
var sc = document.createElement('div');
@@ -117,7 +132,9 @@
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-rag" ' + (cfg.rag !== false ? 'checked' : '') + '> Искать ответы по учебникам (RAG)</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-exambtn" ' + (cfg.examButtons ? 'checked' : '') + '> Кнопки помощника на карточках экзамена</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-memory" ' + (cfg.memory !== false ? 'checked' : '') + '> Персональная память об ученике (слабые темы, заметки)</label>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" class="asst-ib">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span></div>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-socratic" ' + (cfg.socratic ? 'checked' : '') + '> Сократический режим: не решать задачи за ученика (теорию объясняет, задачи — наводит)</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-health" ' + (cfg.healthEnabled !== false ? 'checked' : '') + '> Авто-проверка провайдеров (каждые 15 мин): упавший активный автоматически уступает место здоровому</label>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" class="asst-ib">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span><button id="asst-healthrun" class="asst-ib">Проверить провайдеров сейчас</button></div>' +
'<div style="font-size:.78rem;color:#8a94a6">Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.</div>' +
'<div style="font-size:.78rem;color:#8a94a6">Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '</div>';
host.appendChild(sc);
@@ -143,11 +160,13 @@
var lim = L
? '<div class="asst-pclim" data-lim="' + p.id + '">' + fmtLimits(L) + '</div>'
: '<div class="asst-pclim" data-lim="' + p.id + '" style="opacity:.6">лимиты: загрузка…</div>';
var h = health[p.id];
var hdot = h ? '<span title="' + esc((h.ok ? 'отвечает' : (h.error || 'не отвечает')) + (h.at ? ' · ' + (function () { try { return new Date(h.at).toLocaleString('ru'); } catch (e) { return ''; } })() : '')) + '" style="display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0;background:' + (h.ok ? '#059652' : '#e0335e') + ';margin-left:2px;align-self:center"></span>' : '';
return '<div class="asst-pcard' + (act ? ' active' : '') + '">' +
'<div class="asst-pcic">' + SPARK + '</div>' +
'<div class="asst-pcb"><div class="asst-pcn">' + esc(p.name || 'Провайдер') +
'<div class="asst-pcb"><div class="asst-pcn">' + esc(p.name || 'Провайдер') + hdot +
(act ? '<span class="asst-bdg act">активен</span>' : '') +
'<span class="asst-bdg ' + (p.hasKey ? 'key' : 'nokey') + '">' + (p.hasKey ? 'ключ есть' : 'нет ключа') + '</span></div>' +
(p.hasKey ? '<span class="asst-bdg key">ключ есть</span>' : p.noKey ? '<span class="asst-bdg key">без ключа</span>' : '<span class="asst-bdg nokey">нет ключа</span>') + '</div>' +
'<div class="asst-pcs">' + esc(p.model || '') + '</div>' + ksel + lim + '</div>' +
'<div class="asst-pca">' +
(act ? '' : '<button class="asst-ib primary" data-act="activate" data-id="' + p.id + '">Сделать активным</button>') +
@@ -249,11 +268,89 @@
Q('#asst-rag').addEventListener('change', function () { LS.adminSaveAssistant({ rag: Q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: Q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); });
Q('#asst-memory').addEventListener('change', function () { LS.adminSaveAssistant({ memory: Q('#asst-memory').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-socratic').addEventListener('change', function () { LS.adminSaveAssistant({ socratic: Q('#asst-socratic').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-health').addEventListener('change', function () { LS.adminSaveAssistant({ healthEnabled: Q('#asst-health').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-healthrun').addEventListener('click', async function () {
var btn = Q('#asst-healthrun'); btn.disabled = true; btn.textContent = 'Проверяю…';
try { await LS.adminAssistantHealth(); LS.toast('Проверка завершена', 'success'); render(); }
catch (e) { LS.toast('Ошибка проверки', 'error'); btn.disabled = false; btn.textContent = 'Проверить провайдеров сейчас'; }
});
Q('#asst-reindex').addEventListener('click', async function () {
var btn = Q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…';
try { var r = await LS.adminReindexTextbooks(); Q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); }
catch (e) { LS.toast('Ошибка индексации', 'error'); } finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; }
});
// ── Сканер моделей ──
var scanProvId = null;
function applyScan() {
var trs = Q('#asst-scan-res').querySelectorAll('tbody tr[data-mid]');
var models = [];
trs.forEach(function (tr) {
var cb = tr.querySelector('.asst-scan-cb'); if (!cb || !cb.checked) return;
var id = tr.getAttribute('data-mid');
var ctx = Number(tr.getAttribute('data-ctx')) || null, out = Number(tr.getAttribute('data-out')) || null;
var ex = (cfg.kiloModels || []).find(function (x) { return x.id === id; });
var label = ex ? ex.label : (id.split('/').pop().replace(/:free$/, '') + (ctx ? ' (' + fmtTok(ctx) + ')' : ''));
models.push({ id: id, label: label, ctx: ctx, out: out });
});
if (!models.length) { LS.toast('Отметьте хотя бы одну модель', 'warn'); return; }
LS.adminAssistantApplyModels(models).then(function () { LS.toast('Список моделей обновлён (' + models.length + ')', 'success'); render(); }).catch(function () { LS.toast('Ошибка', 'error'); });
}
async function runScan() {
var st = Q('#asst-scan-st'), res = Q('#asst-scan-res'), btn = Q('#asst-scan');
btn.disabled = true; st.textContent = 'Сканирую шлюз…'; res.innerHTML = '';
var r; try { r = await LS.adminAssistantScan(); } catch (e) { st.textContent = 'Ошибка запроса'; btn.disabled = false; return; }
if (!r || r.error) { st.textContent = 'Ошибка: ' + esc((r && r.error) || '—'); btn.disabled = false; return; }
scanProvId = r.providerId;
st.textContent = 'Найдено бесплатных: ' + r.models.length + ' (провайдер «' + esc(r.providerName) + '»). Тестирую русский…';
var rows = r.models.map(function (m) {
return '<tr data-mid="' + esc(m.id) + '" data-ctx="' + (m.ctx || '') + '" data-out="' + (m.out || '') + '">' +
'<td style="padding:5px 6px"><input type="checkbox" class="asst-scan-cb"' + (m.status === 'current' ? ' checked' : '') + '></td>' +
'<td style="padding:5px 6px;font-family:ui-monospace,monospace;font-size:.74rem">' + esc(m.id) + '</td>' +
'<td style="padding:5px 6px;white-space:nowrap;color:#8a94a6">' + fmtTok(m.ctx) + '/' + fmtTok(m.out) + '</td>' +
'<td style="padding:5px 6px"><span class="asst-bdg ' + (m.status === 'current' ? 'key' : 'act') + '">' + (m.status === 'current' ? 'в списке' : 'новая') + '</span></td>' +
'<td class="asst-ru" style="padding:5px 6px;font-size:.75rem;color:#8a94a6">…</td></tr>';
}).join('');
var goneRows = (r.gone || []).map(function (g) {
return '<tr style="opacity:.55"><td style="padding:5px 6px">—</td>' +
'<td style="padding:5px 6px;font-family:ui-monospace,monospace;font-size:.74rem;text-decoration:line-through">' + esc(g.id) + '</td>' +
'<td style="padding:5px 6px">—</td><td style="padding:5px 6px"><span class="asst-bdg nokey">исчезла</span></td>' +
'<td style="padding:5px 6px;font-size:.75rem;color:#e0335e">будет убрана</td></tr>';
}).join('');
res.innerHTML = '<div style="overflow-x:auto;margin-top:4px"><table style="width:100%;border-collapse:collapse">' +
'<thead><tr style="text-align:left;color:#8a94a6;font-size:.72rem;border-bottom:1px solid var(--border,#e2e8f0)">' +
'<th style="padding:4px 6px"></th><th style="padding:4px 6px">модель</th><th style="padding:4px 6px">ctx/out</th><th style="padding:4px 6px">статус</th><th style="padding:4px 6px">русский</th></tr></thead>' +
'<tbody>' + rows + goneRows + '</tbody></table></div>' +
'<div style="margin-top:10px"><button id="asst-scan-apply" class="asst-ib primary" style="padding:8px 16px">Применить выбранные</button> ' +
'<span style="font-size:.74rem;color:#8a94a6">отмечены: текущие + новые с чистым русским</span></div>';
Q('#asst-scan-apply').addEventListener('click', applyScan);
// последовательный прогон тест-запросов
var trs = res.querySelectorAll('tbody tr[data-mid]');
for (var i = 0; i < trs.length; i++) {
var tr = trs[i], mid = tr.getAttribute('data-mid'), cell = tr.querySelector('.asst-ru');
cell.textContent = 'тест…';
try {
var pr = await LS.adminAssistantProbe(scanProvId, mid);
if (pr && pr.ok) {
var good = pr.cjk === 0 && pr.ratio > 55;
var col = good ? '#059652' : (pr.ratio > 20 && pr.cjk === 0) ? '#b45309' : '#e0335e';
cell.innerHTML = '<span style="color:' + col + '" title="' + esc(pr.sample || '') + '">' + pr.ratio + '% · ' + esc(pr.verdict) + ' · ' + (pr.ms / 1000).toFixed(1) + 'с</span>';
if (!good) { var cb = tr.querySelector('.asst-scan-cb'); if (cb) cb.checked = false; }
} else {
cell.innerHTML = '<span style="color:#e0335e">' + esc(String((pr && (pr.error || ('HTTP ' + pr.status))) || 'ошибка').slice(0, 60)) + '</span>';
var cb2 = tr.querySelector('.asst-scan-cb'); if (cb2) cb2.checked = false;
}
} catch (e) { cell.textContent = 'ошибка'; }
}
st.textContent = 'Готово. Отметьте нужные модели и нажмите «Применить выбранные».';
btn.disabled = false;
}
Q('#asst-scan').addEventListener('click', runScan);
if (Q('#asst-scan-reset')) Q('#asst-scan-reset').addEventListener('click', async function () {
if (!await LS.confirm('Вернуть встроенный список бесплатных моделей?', { title: 'Сброс списка', confirmText: 'Вернуть' })) return;
try { await LS.adminAssistantApplyModels(null, true); LS.toast('Возвращён встроенный список', 'success'); render(); } catch (e) { LS.toast('Ошибка', 'error'); }
});
}
window.AdminSections = window.AdminSections || {};
+5
View File
@@ -5,6 +5,7 @@
let inited = false;
const GAME_FEATURES = [
{ key: 'gamification', label: 'Геймификация (всё)', desc: 'Мастер-выключатель: XP, уровни, достижения, монеты, стрики, магазин, лидерборд, испытания, рамки. Выкл → всё это скрыто и не начисляется у ВСЕХ', icon: 'trophy' },
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически по темам', icon: 'grid-3x3' },
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
@@ -13,12 +14,16 @@
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' },
{ key: 'imggen', label: 'Генерация картинок (ИИ)', desc: 'ИИ-генерация изображений в ассистенте, флэшкартах, уроках, питомце, аватаре, доске', icon: 'image' },
{ key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' },
{ key: 'sitemap', label: 'Путеводитель', desc: 'Пункт «Путеводитель» в меню — обзорная карта разделов системы', icon: 'map' },
{ key: 'lab', label: 'Лаборатория', desc: 'Раздел «Лаборатория»: виртуальные симуляции и интерактивные опыты', icon: 'atom' },
{ key: 'theory', label: 'Теория', desc: 'Раздел «Теория»: учебные курсы и уроки для учеников', icon: 'brain' },
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'},
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
{ key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' },
{ key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка: уровни на движке симуляций, прогресс, скины', icon: 'rocket' },
{ key: 'wishes', label: 'Пожелания', desc: 'Трекер пожеланий по улучшению: пользователи подают идеи, админ ведёт по статусам', icon: 'lightbulb' },
];
const FS_FEATURES = [
+128
View File
@@ -452,6 +452,24 @@
<i data-lucide="file-text"></i> Audit log
</button>
</div>
<div class="ov-section-title" style="margin-top:32px;color:var(--pink)">Опасная зона</div>
<div class="ov-card danger" style="padding-bottom:16px">
<div class="ov-card-icon"><i data-lucide="alert-octagon" style="width:18px;height:18px"></i></div>
<div class="ov-card-label" style="margin-bottom:10px;font-weight:700;color:#0F172A">
Сброс системы «чистый запуск»
</div>
<div style="font-size:.82rem;color:#56687A;line-height:1.5;margin-bottom:14px;max-width:560px">
Удаляет всех пользователей (кроме вас), классы, сессии, задания, прогресс, уведомления и
историю. Учебники, вопросы, тесты, курсы и настройки сохраняются авторский контент
переназначается на ваш аккаунт. Перед сбросом автоматически создаётся резервная копия БД.
Действие необратимо.
</div>
<button class="ov-quick-btn" id="ov-reset-system-btn"
style="border-color:rgba(241,91,181,0.5);color:var(--pink);max-width:280px">
<i data-lucide="trash-2"></i> Сбросить систему
</button>
</div>
`;
/* ── wire quick-links via event delegation ───────────────── */
@@ -459,9 +477,119 @@
btn.addEventListener('click', function () { navigateTo(btn.dataset.go); });
});
const resetBtn = el.querySelector('#ov-reset-system-btn');
if (resetBtn) resetBtn.addEventListener('click', openResetModal);
if (window.lucide) lucide.createIcons({ nodes: [el] });
}
/* ── Сброс системы «чистый запуск» — модалка с предпросмотром + вводом «СБРОС» ── */
async function openResetModal() {
const e = LS.esc;
const m = LS.modal({
title: 'Сброс системы — чистый запуск',
size: 'md',
content: '<div style="padding:8px 0;color:#56687A">Загрузка плана…</div>',
actions: [{ label: 'Отмена' }],
});
let plan;
try {
plan = await LS.api('/api/admin/reset-system/plan');
} catch (err) {
m.setBody('<div style="color:#F94144">Не удалось загрузить план: ' + e(err.message) + '</div>');
return;
}
const kept = plan.keptAdmin || {};
const delUsers = Math.max(0, (plan.totalUsers || 0) - 1);
const wipeRows = plan.wipeRows || 0;
const reassignRows = (plan.reassign || []).reduce(function (a, r) {
return a + (typeof r.rows === 'number' ? r.rows : 0);
}, 0);
const unknownNote = (plan.unknown && plan.unknown.length)
? '<div style="margin-top:10px;padding:8px 11px;border-radius:8px;background:rgba(255,179,71,.12);' +
'border:1px solid rgba(255,179,71,.35);font-size:.8rem;color:#9a6a10">' +
'Неизвестные таблицы (не трогаются): ' + e(plan.unknown.join(', ')) + '</div>'
: '';
m.setBody(
'<div style="font-size:.88rem;line-height:1.6;color:#0F172A">' +
'<div style="padding:10px 13px;border-radius:10px;background:rgba(241,91,68,.08);' +
'border:1px solid rgba(241,91,68,.3);margin-bottom:14px">' +
'<strong>Это действие необратимо.</strong> Перед сбросом будет создан бэкап БД.' +
'</div>' +
'<div style="margin-bottom:6px">Останется один администратор:</div>' +
'<div style="padding:8px 12px;border-radius:8px;background:rgba(15,23,42,.04);margin-bottom:14px">' +
'<strong>' + e(kept.name || '—') + '</strong> · ' + e(kept.email || '') +
' <span style="color:#56687A">(вы)</span></div>' +
'<ul style="margin:0 0 14px;padding-left:18px;color:#334155">' +
'<li>Удалится пользователей: <strong>' + delUsers + '</strong></li>' +
'<li>Очистится записей активности/организации: <strong>~' + wipeRows + '</strong></li>' +
'<li>Контента переназначится на вас: <strong>' + reassignRows + '</strong> записей</li>' +
'<li>Сохранится контент-таблиц: <strong>' + (plan.keepCount || 0) + '</strong></li>' +
'</ul>' +
unknownNote +
'<div style="margin:16px 0 6px">Для подтверждения введите <strong>СБРОС</strong>:</div>' +
'<input id="ov-reset-confirm-inp" type="text" autocomplete="off" ' +
'style="width:100%;padding:9px 12px;border:1.5px solid rgba(15,23,42,.18);border-radius:10px;' +
'font-size:.95rem;font-family:inherit" placeholder="СБРОС">' +
'</div>'
);
const inp = m.body.querySelector('#ov-reset-confirm-inp');
function syncBtn() {
const ok = inp && inp.value.trim() === 'СБРОС';
const btn = document.getElementById('ov-reset-go');
if (btn) btn.disabled = !ok;
}
function setReadyActions() {
m.setActions([
{ label: 'Отмена' },
{
label: 'Сбросить систему', danger: true, id: 'ov-reset-go', close: false,
onClick: doReset,
},
]);
const btn = document.getElementById('ov-reset-go');
if (btn) btn.disabled = true;
}
async function doReset() {
const btn = document.getElementById('ov-reset-go');
if (!inp || inp.value.trim() !== 'СБРОС') return;
if (btn) { btn.disabled = true; btn.textContent = 'Выполняется…'; }
m.setError('');
let res;
try {
res = await LS.api('/api/admin/reset-system', { method: 'POST', body: { confirm: 'СБРОС' } });
} catch (err) {
m.setError('Ошибка: ' + (err.message || 'сброс не выполнен'));
if (btn) { btn.disabled = false; btn.textContent = 'Сбросить систему'; }
return;
}
m.setBody(
'<div style="text-align:center;padding:14px 0">' +
'<div style="font-size:2rem;margin-bottom:6px;color:var(--green)">' +
'<i data-lucide="check-circle-2" style="width:40px;height:40px"></i></div>' +
'<div style="font-size:1rem;font-weight:800;color:#0F172A;margin-bottom:10px">Система сброшена</div>' +
'<div style="font-size:.86rem;color:#56687A;line-height:1.6">' +
'Удалено пользователей: <strong>' + (res.deletedUsers || 0) + '</strong>, осталось: <strong>' +
(res.remainingUsers || 1) + '</strong>.<br>' +
'Бэкап сохранён: <code style="font-size:.8rem">' + LS.esc(res.backup || '—') + '</code>' +
(res.fkDangling ? '<br><span style="color:#F94144">Висячих ссылок: ' + res.fkDangling + '</span>' : '') +
'</div>' +
'</div>'
);
m.setActions([{ label: 'Перезагрузить', primary: true, close: false, onClick: function () { location.reload(); } }]);
if (window.lucide) lucide.createIcons({ nodes: [m.body] });
}
setReadyActions();
if (inp) { inp.addEventListener('input', syncBtn); setTimeout(function () { inp.focus(); }, 60); }
}
async function load() {
const el = document.getElementById('overview-content');
if (!el) return;
+2 -1
View File
@@ -4,7 +4,8 @@
'use strict';
let inited = false;
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест', topic: 'По теме', random: 'Случайный' };
// Старт сессии поддерживает только exam/practice (topic/random убраны — давали 400 на дашборде).
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест' };
const SC_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
const SC_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
+112 -20
View File
@@ -40,10 +40,12 @@
<span class="q-badge q-badge-subj">${SUBJ_N[t.subject_slug]||t.subject_slug}</span>
<span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span>
<span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span>
${t.available_to_students ? `<span class="q-badge" style="background:rgba(6,214,160,.14);color:#059669">Доступен ученикам</span>` : ''}
${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''}
</div>
</div>
<div class="q-card-actions">
<button class="btn-edit-q" onclick="toggleTstAvail(${t.id})" title="Показывать ли тест ученикам в каталоге">${t.available_to_students ? 'Скрыть' : 'Ученикам'}</button>
<button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button>
<button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div>
@@ -77,16 +79,15 @@
if (!inner) return;
inner.innerHTML = '<div class="spinner"></div>';
try {
const [t, subjectQs] = await Promise.all([
LS.getTest(id),
LS.getQuestions(
(_tstPickerCache[id]?.subject_slug) || allTests.find(x => x.id === id)?.subject_slug || '',
null, 'date_asc'
).catch(() => []),
]);
const t = await LS.getTest(id);
const inIds = new Set(t.questions.map(q => q.id));
_tstPickerCache[id] = { subjectQs, inIds, subject_slug: t.subject_slug };
// Сохраняем поиск/фильтры между перерисовками (напр. после добавления вопроса).
const prev = _tstPickerCache[id] || {};
_tstPickerCache[id] = {
subject_slug: t.subject_slug, inIds,
q: prev.q || '', difficulty: prev.difficulty || '', type: prev.type || '',
rows: [], total: 0, page: 1, loading: false,
};
inner.innerHTML = `
<div class="tst-cols">
@@ -96,17 +97,89 @@
</div>
<div>
<div class="tst-panel-title">Добавить вопросы</div>
<input class="tst-search" id="tstps-${id}" placeholder="Поиск вопросов…" oninput="filterTstPicker(${id})" />
<div class="tst-q-list" id="tstpicker-${id}">${renderTstPicker(subjectQs, inIds, id)}</div>
<input class="tst-search" id="tstps-${id}" placeholder="Поиск по всему банку предмета…" oninput="filterTstPicker(${id})" />
<div class="tst-pick-filters">
<select class="tst-pick-sel" id="tstfd-${id}" onchange="pickerFilterChange(${id})">
<option value="">Любая сложность</option>
<option value="1">Лёгкий</option>
<option value="2">Средний</option>
<option value="3">Сложный</option>
</select>
<select class="tst-pick-sel" id="tstft-${id}" onchange="pickerFilterChange(${id})">
<option value="">Любой тип</option>
<option value="single">Один</option>
<option value="multi">Несколько</option>
<option value="true_false">Верно/Нет</option>
<option value="short_answer">Краткий</option>
<option value="matching">Сопоставление</option>
</select>
</div>
<div class="tst-q-list" id="tstpicker-${id}"><div class="spinner"></div></div>
<div class="tst-pick-foot" id="tstfoot-${id}"></div>
</div>
</div>`;
// restore search/filters into controls
const si = document.getElementById('tstps-' + id); if (si) si.value = _tstPickerCache[id].q;
const fd = document.getElementById('tstfd-' + id); if (fd) fd.value = _tstPickerCache[id].difficulty;
const ft = document.getElementById('tstft-' + id); if (ft) ft.value = _tstPickerCache[id].type;
AdminCtx.renderMath(inner);
if (window.lucide) lucide.createIcons();
await pickerLoad(id, true);
} catch (e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
/* Серверная подгрузка вопросов в пикер (весь банк предмета, не первые 100).
reset=true новый поиск/фильтр (страница 1, заменяем); иначе «показать ещё». */
async function pickerLoad(id, reset) {
const cache = _tstPickerCache[id];
if (!cache || cache.loading) return;
cache.loading = true;
if (reset) { cache.page = 1; cache.rows = []; }
const listEl = document.getElementById('tstpicker-' + id);
const footEl = document.getElementById('tstfoot-' + id);
if (reset && listEl) listEl.innerHTML = '<div class="spinner"></div>';
try {
const p = new URLSearchParams();
p.set('subject', cache.subject_slug || '');
p.set('sort', 'date_desc');
p.set('page', cache.page);
p.set('limit', 100);
if (cache.q) p.set('q', cache.q);
if (cache.difficulty) p.set('difficulty', cache.difficulty);
if (cache.type) p.set('type', cache.type);
const data = await LS.get('/api/questions?' + p.toString());
const rows = Array.isArray(data) ? data : (data.rows || []);
cache.total = Array.isArray(data) ? rows.length : (data.total != null ? data.total : rows.length);
cache.rows = reset ? rows : cache.rows.concat(rows);
cache.page += 1;
if (listEl) { listEl.innerHTML = renderTstPicker(cache.rows, cache.inIds, id); AdminCtx.renderMath(listEl); }
if (footEl) footEl.innerHTML = pickerFootHtml(id);
if (window.lucide) lucide.createIcons();
} catch (e) {
if (listEl) listEl.innerHTML = `<div class="tst-empty">Ошибка: ${esc(e.message)}</div>`;
} finally { cache.loading = false; }
}
function pickerFootHtml(id) {
const c = _tstPickerCache[id];
if (!c || !c.total) return '';
const more = c.rows.length < c.total
? `<button class="btn-tst-more" onclick="pickerMore(${id})">Показать ещё</button>` : '';
return `<span class="tst-pick-count">Показано ${c.rows.length} из ${c.total}</span>${more}`;
}
function pickerMore(id) { pickerLoad(id, false); }
function pickerFilterChange(id) {
const c = _tstPickerCache[id];
if (!c) return;
c.difficulty = document.getElementById('tstfd-' + id)?.value || '';
c.type = document.getElementById('tstft-' + id)?.value || '';
pickerLoad(id, true);
}
function renderTstQList(questions, tid) {
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Вопросов нет. Добавьте справа <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></div>';
@@ -127,7 +200,11 @@
function renderTstPicker(questions, inIds, tid) {
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
if (!questions.length) {
const c = _tstPickerCache[tid] || {};
const searching = c.q || c.difficulty || c.type;
return `<div class="tst-empty">${searching ? 'Ничего не найдено — измените запрос или фильтры' : 'Вопросов нет в этом предмете'}</div>`;
}
return questions.map(q => {
const added = inIds.has(q.id);
return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}">
@@ -145,15 +222,13 @@
}).join('');
}
async function filterTstPicker(tid) {
const search = document.getElementById('tstps-'+tid)?.value.toLowerCase() || '';
const cache = _tstPickerCache[tid];
const _pickDebounce = {};
function filterTstPicker(tid) {
const cache = _tstPickerCache[tid];
if (!cache) return;
const filtered = search
? cache.subjectQs.filter(q => q.text.toLowerCase().includes(search))
: cache.subjectQs;
const picker = document.getElementById('tstpicker-'+tid);
if (picker) { picker.innerHTML = renderTstPicker(filtered, cache.inIds, tid); AdminCtx.renderMath(picker); if(window.lucide)lucide.createIcons(); }
cache.q = (document.getElementById('tstps-' + tid)?.value || '').trim();
clearTimeout(_pickDebounce[tid]);
_pickDebounce[tid] = setTimeout(() => pickerLoad(tid, true), 300); // серверный поиск по всему банку
}
async function tstAddQ(tid, qid) {
@@ -261,11 +336,27 @@
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Открыть/скрыть тест для учеников (попадает в каталог на дашборде)
async function toggleTstAvail(id) {
const t = allTests.find(x => x.id === id);
if (!t) return;
if (!t.question_count) { LS.toast('Сначала добавьте вопросы в тест', 'error'); return; }
const next = t.available_to_students ? 0 : 1;
try {
await LS.updateTest(id, { available_to_students: next });
t.available_to_students = next;
renderTests();
LS.toast(next ? 'Тест открыт ученикам' : 'Тест скрыт от учеников', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose handlers
window.loadTests = load;
window.renderTests = renderTests;
window.toggleTstDrawer = toggleTstDrawer;
window.filterTstPicker = filterTstPicker;
window.pickerMore = pickerMore;
window.pickerFilterChange = pickerFilterChange;
window.tstAddQ = tstAddQ;
window.tstRemoveQ = tstRemoveQ;
window.setTstShowAnswers = setTstShowAnswers;
@@ -274,6 +365,7 @@
window.closeTstModal = closeTstModal;
window.saveTst = saveTst;
window.deleteTst = deleteTst;
window.toggleTstAvail = toggleTstAvail;
window.AdminSections = window.AdminSections || {};
window.AdminSections.tests = {
+61
View File
@@ -0,0 +1,61 @@
'use strict';
/* assignment-utils.js единый источник правды для классификации заданий и статуса «сдано».
*
* Раньше эта логика дублировалась в трёх местах (dashboard.html, homework.html,
* assignmentController.js) и начала расходиться. Теперь один модуль, который грузится
* и в браузере (window.AssignmentUtils), и в Node (module.exports) как svg-sanitize.js.
*
* Поля задания (как их отдаёт /assignments/my и assignmentRowsForUser):
* textbook_id, file_id, is_homework, count, subject_slug, mode, session_status,
* max_attempts, attempts_used, deadline, textbook_all_read, completed_at.
*/
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof window !== 'undefined') window.AssignmentUtils = api;
})(this, function () {
/* Тип задания: textbook | file | upload | test.
Порядок проверки: учебник файл загрузка работы тест. */
function type(a) {
if (a.textbook_id) return 'textbook';
if (a.file_id) return 'file';
if (a.is_homework && (a.count == null || a.count <= 1)
&& (!a.subject_slug || a.subject_slug === 'other')) return 'upload';
return 'test';
}
/* «Закрыто» (сдано/выполнено/прочитано) задание уходит из активных/долгов.
sub последняя сдача (объект с .status) для upload/file, иначе null/undefined.
opts.acceptedOnly=true (вид ученика на /homework): upload/file закрыт ТОЛЬКО при
status==='accepted' (пока не приняли у ученика «висит»).
по умолчанию (учитель / обзор долгов): любая сдача не на доработке = закрыто
(ученик свою часть сделал это уже не его долг). */
function isDone(a, sub, opts) {
opts = opts || {};
const t = type(a);
if (t === 'textbook') return !!(a.textbook_all_read || a.completed_at);
if (t === 'upload' || t === 'file') {
const st = sub && sub.status;
if (!st || st === 'revision') return false;
return opts.acceptedOnly ? st === 'accepted' : true;
}
// test
const maxAtt = a.max_attempts || 0;
const used = (a.attempts_used != null) ? a.attempts_used : 0;
if (maxAtt > 0 && used >= maxAtt) return true;
return a.session_status === 'completed' && a.mode !== 'repeat';
}
/* Срочность для сортировки (меньше — выше): идёт → просрочено → <24ч → по дедлайну → без срока. */
function urgencyScore(a) {
if (a.session_status === 'in_progress') return -4;
const dlMs = a.deadline ? (new Date(a.deadline).getTime() - Date.now()) : Infinity;
if (dlMs < 0) return -3;
if (dlMs < 24 * 3600 * 1000) return -2;
if (dlMs < Infinity) return dlMs;
return 1e12;
}
return { type, isDone, urgencyScore };
});
+185 -11
View File
@@ -282,6 +282,9 @@
'.asst-dot{position:absolute;top:0;right:0;width:13px;height:13px;border-radius:50%;background:#F15BB5;border:2px solid #fff;}',
reduceMotion ? '' : '.asst-fab.pulse{animation:asstPulse 2.2s ease-in-out infinite;}',
'@keyframes asstPulse{0%,100%{box-shadow:0 8px 24px rgba(139,92,246,.32);}50%{box-shadow:0 8px 30px rgba(241,91,181,.5);}}',
'.asst-name-face{display:inline-block;transition:transform .2s;}',
reduceMotion ? '' : '.asst-name-face.asst-think{animation:asstThink 1.3s ease-in-out infinite;transform-origin:60% 70%;}',
'@keyframes asstThink{0%,100%{transform:scale(1) rotate(0);}50%{transform:scale(1.08) rotate(-4deg);}}',
'.asst-bubble{position:absolute;left:0;bottom:66px;width:380px;max-width:92vw;background:#fff;border-radius:18px;',
' box-shadow:0 20px 56px rgba(15,23,42,.24);padding:15px 17px;border:1px solid rgba(15,23,42,.07);',
' opacity:0;transform:translateY(8px) scale(.98);pointer-events:none;transition:opacity .18s,transform .18s;transform-origin:bottom left;}',
@@ -322,6 +325,10 @@
'.asst-rich .katex-display::-webkit-scrollbar{height:6px;}',
'.asst-rich .katex-display::-webkit-scrollbar-thumb{background:rgba(15,23,42,.18);border-radius:99px;}',
'.asst-rich .katex{max-width:100%;}',
// мигающий курсор во время стриминга ответа (CSS-каретка, без глифа)
'.asst-streaming{white-space:pre-wrap;}',
'.asst-streaming::after{content:"";display:inline-block;width:2px;height:1em;vertical-align:-2px;margin-left:2px;background:#9B5DE5;animation:asst-blink 1s steps(2) infinite;}',
'@keyframes asst-blink{50%{opacity:0;}}',
'.asst-md-h{font-weight:800;color:#0F172A;margin:6px 0 2px;}',
'.asst-chat{max-height:46vh;overflow:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:8px;}',
'.asst-chat:empty{display:none;}',
@@ -359,6 +366,8 @@
/* ── рендер ──────────────────────────────────────────────────────────── */
function setFace(mood) { var f = root.querySelector('.asst-face'); if (f) f.innerHTML = faceSVG(mood); }
// живость: лицо Квантика в шапке чата реагирует на состояние (думает/радуется/грустит)
function setNameFace(mood) { var f = bubble && bubble.querySelector && bubble.querySelector('.asst-name-face'); if (f) { f.innerHTML = faceSVG(mood === 'thinking' ? 'neutral' : mood); f.classList.toggle('asst-think', mood === 'thinking'); } }
function openBubble(html, opts) {
opts = opts || {};
@@ -476,12 +485,35 @@
var h = sec.querySelector('.sec-h');
var title = (h && h.textContent.trim()) || (document.title || 'Параграф').split('·')[0].trim();
var text = (sec.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 3500);
if (text.length > 40) return { title: title, text: text };
if (text.length > 40) return { title: title, text: text, kind: 'textbook' };
}
}
if (PAGE === 'theory') {
var c = document.querySelector('.lesson-content, .lsn-content, .lesson-body, #lesson-content, article.lesson, .course-lesson, .lesson-view');
if (c) {
var lt = document.querySelector('h1, .lsn-title, .lesson-title');
var ltitle = (lt && lt.textContent.trim()) || (document.title || 'Урок').split('·')[0].trim();
var ltext = (c.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 3500);
if (ltext.length > 60) return { title: ltitle, text: ltext, kind: 'lesson' };
}
}
} catch (e) {}
return null;
}
// Лёгкий ситуативный контекст для ЛЮБОГО вопроса — где сейчас ученик (заголовок+раздел).
var PAGE_LABEL = { textbook: 'учебник', theory: 'урок/курс', exam: 'экзамен или тест', flashcards: 'флешкарты',
lab: 'лаборатория', homework: 'домашние задания', dashboard: 'главная', knowledge: 'карта знаний',
library: 'библиотека', analytics: 'аналитика', gradebook: 'журнал', qbank: 'банк вопросов' };
function pageHint() {
try {
var label = PAGE_LABEL[PAGE] || '';
var hEl = document.querySelector('.sec.active .sec-h, h1, .lsn-title, .lesson-title, .page-title');
var title = (hEl && hEl.textContent.trim()) || (document.title || '').split('·')[0].split('|')[0].trim();
title = title.replace(/\s+/g, ' ').slice(0, 120);
if (!title && !label) return '';
return 'Ученик сейчас на странице платформы' + (label ? ' («' + label + '»)' : '') + (title ? ': «' + title + '»' : '') + '. Учитывай это, если вопрос относится к материалу страницы.';
} catch (e) { return ''; }
}
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */
var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?'];
@@ -501,21 +533,25 @@
}
var FB_UP = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
var FB_DOWN = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(180deg)"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…', draw: 'Опиши картинку: «кот-учёный, плоская иллюстрация»' };
var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…', draw: 'Опиши картинку: «кот-учёный, плоская иллюстрация»', quiz: 'Тема или текст — сгенерирую вопросы для банка' };
function openAsk(prefill) {
var sel = _lastSel, pc = getPageContext();
var noun = pc && pc.kind === 'lesson' ? 'этот урок' : 'этот параграф';
var noun2 = pc && pc.kind === 'lesson' ? 'урока' : 'параграфа';
var ctxBtns = '';
if (sel) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sel" type="button">Объяснить выделенное</button>';
if (pc) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sec" type="button">Объяснить этот параграф</button>' +
'<button class="asst-chip asst-chip-ctx" data-ctx="sum" type="button">Конспект параграфа</button>' +
'<button class="asst-chip asst-chip-ctx" data-ctx="cards" type="button">Флешкарты из параграфа</button>';
if (pc) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sec" type="button">Объяснить ' + noun + '</button>' +
'<button class="asst-chip asst-chip-ctx" data-ctx="sum" type="button">Конспект ' + noun2 + '</button>' +
'<button class="asst-chip asst-chip-ctx" data-ctx="cards" type="button">Флешкарты из ' + noun2 + '</button>';
var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS;
var chips = '<div class="asst-chips">' + ctxBtns +
sug.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') + '</div>';
var isTch = (_role === 'teacher' || _role === 'admin');
var modes = '<div class="asst-modes">' +
'<button class="asst-mode on" data-m="answer">Ответ</button>' +
'<button class="asst-mode" data-m="hint">Подсказка</button>' +
'<button class="asst-mode" data-m="check">Проверить решение</button>' +
(isTch ? '<button class="asst-mode" data-m="quiz">Тест в банк</button>' : '') +
'<button class="asst-mode" data-m="draw">Нарисовать</button></div>';
openBubble(
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Спроси Квантика' +
@@ -530,7 +566,8 @@
var mode = 'answer';
renderChat(chatEl);
if (_chat.length) chipsEl.style.display = 'none';
function go(q, context, m) { if (chipsEl) chipsEl.style.display = 'none'; inp.value = ''; send(q, context, chatEl, m || mode); }
// свободный вопрос (context не задан явно) → подмешиваем лёгкий ситуативный контекст страницы
function go(q, context, m) { if (chipsEl) chipsEl.style.display = 'none'; inp.value = ''; if (context == null) context = pageHint() || undefined; send(q, context, chatEl, m || mode); }
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') go(inp.value); });
bubble.querySelectorAll('.asst-mode').forEach(function (b) {
b.addEventListener('click', function () {
@@ -590,15 +627,15 @@
_chat.push({ role: 'user', content: 'Нарисуй: ' + prompt });
var u = msgEl('user'); u.textContent = 'Нарисуй: ' + prompt; chatEl.appendChild(u);
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = 'Рисую картинку…'; chatEl.appendChild(ph);
chatEl.scrollTop = chatEl.scrollHeight;
chatEl.scrollTop = chatEl.scrollHeight; setNameFace('thinking');
LS.imageGen(prompt).then(function (r) {
ph.remove();
var d = msgEl('assistant');
if (r && r.url) { d.innerHTML = '<img src="' + r.url + '" alt="" style="width:100%;border-radius:10px;display:block">'; _chat.push({ role: 'assistant', content: '[картинка]', img: r.url }); }
else d.textContent = 'Не получилось нарисовать.';
if (r && r.url) { d.innerHTML = '<img src="' + r.url + '" alt="" style="width:100%;border-radius:10px;display:block">'; _chat.push({ role: 'assistant', content: '[картинка]', img: r.url }); setNameFace('ecstatic'); }
else { d.textContent = 'Не получилось нарисовать.'; setNameFace('sad'); }
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
}).catch(function (err) {
ph.remove(); var d = msgEl('assistant');
ph.remove(); var d = msgEl('assistant'); setNameFace('sad');
d.textContent = (err && err.data && err.data.error) || 'Не получилось нарисовать.';
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
});
@@ -607,11 +644,90 @@
q = (q || '').trim();
if (q.length < 2) return;
if (mode === 'draw') return drawInChat(q, chatEl);
if (mode === 'quiz') return makeQuiz(q, chatEl);
// стриминг недоступен (старый кэш api.js / нет ReadableStream) — обычный путь
if (!LS.assistantAskStream || typeof ReadableStream === 'undefined') return sendNonStream(q, context, chatEl, mode);
var history = _chat.slice(-6);
_chat.push({ role: 'user', content: q });
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = mode === 'check' ? 'Проверяю…' : 'Думаю…'; chatEl.appendChild(ph);
chatEl.scrollTop = chatEl.scrollHeight;
setNameFace('thinking');
var searchP = (LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; });
var meta = { answers: [], sources: [] }, full = '', msgD = null, richEl = null, streamed = false, finalized = false;
function ensureMsg() {
if (msgD) return;
if (ph.parentNode) ph.remove();
msgD = msgEl('assistant'); msgD.innerHTML = '<div class="asst-rich asst-streaming"></div>';
richEl = msgD.querySelector('.asst-rich'); chatEl.appendChild(msgD);
}
function finalize(done) {
if (finalized) return; finalized = true;
done = done || {};
var src = done.source;
if ((src === 'limit' || src === 'error') && !full) {
_chat.pop();
if (msgD) msgD.remove(); if (ph.parentNode) ph.remove();
var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = done.answer || 'Сейчас не получилось. Попробуй ещё раз.';
chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('sad'); return;
}
var isModel = src === 'model' && (full || done.answer);
setNameFace(isModel ? 'happy' : 'neutral');
searchP.then(function (sres) {
var found = (sres && sres.results) || [];
var ansArr = (done.answers && done.answers.length ? done.answers : meta.answers) || [];
var sources = done.sources || meta.sources || [];
var content = isModel ? (full || done.answer) : ((ansArr[0] && (ansArr[0].q + '\n' + ansArr[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).');
ensureMsg(); richEl.classList.remove('asst-streaming');
_chat.push({ role: 'assistant', content: content });
renderRich(richEl, content);
if (isModel && sources.length) {
var sc = document.createElement('div'); sc.className = 'asst-src';
sc.innerHTML = 'Источник: ' + sources.map(function (s) { return '<a href="' + esc(safeUrl(srcUrl(s))) + '">' + esc(s.title) + (s.section ? ', ' + esc(s.section) : '') + '</a>'; }).join('; ');
chatEl.appendChild(sc);
}
var links = '';
if (!isModel && ansArr.length) links += ansArr.slice(0, 2).filter(function (a) { return a.url; }).map(function (a) { return '<a class="asst-ans-link" href="' + esc(safeUrl(a.url)) + '">' + esc(a.q) + '</a>'; }).join(' · ');
if (found.length) links += (links ? '<br>' : '') + '<span style="color:#8a94a6">На платформе: </span>' + found.slice(0, 3).map(function (f) { return '<a class="asst-ans-link" href="' + esc(safeUrl(f.url)) + '">' + esc(f.title || '…') + '</a>'; }).join(' · ');
if (links) { var l = document.createElement('div'); l.className = 'asst-msg-links'; l.innerHTML = links; chatEl.appendChild(l); }
if (isModel) {
var fb = document.createElement('div'); fb.className = 'asst-fb';
fb.innerHTML = '<button data-r="1" title="Полезно">' + FB_UP + '</button><button data-r="-1" title="Не помогло">' + FB_DOWN + '</button>';
fb.querySelectorAll('button').forEach(function (b) {
b.addEventListener('click', function () { if (fb.dataset.done) return; fb.dataset.done = '1'; b.classList.add('on'); try { LS.assistantFeedback(Number(b.getAttribute('data-r')), q); } catch (e) {} });
});
chatEl.appendChild(fb);
}
chatEl.scrollTop = chatEl.scrollHeight;
});
}
LS.assistantAskStream(q, context, history, mode, {
onMeta: function (m) { if (m.answers) meta.answers = m.answers; if (m.sources) meta.sources = m.sources; },
onDelta: function (t) { streamed = true; ensureMsg(); full += t; richEl.textContent = full; chatEl.scrollTop = chatEl.scrollHeight; },
onDone: function (o) { finalize(o); },
}).then(function () { if (!finalized) finalize({ source: full ? 'model' : 'faq' }); })
.catch(function () {
if (finalized) return;
if (!streamed) { if (ph.parentNode) ph.remove(); _chat.pop(); sendNonStream(q, context, chatEl, mode); }
else finalize({ source: 'model' });
});
}
function sendNonStream(q, context, chatEl, mode) {
q = (q || '').trim();
if (q.length < 2) return;
if (mode === 'draw') return drawInChat(q, chatEl);
if (mode === 'quiz') return makeQuiz(q, chatEl);
var history = _chat.slice(-6);
_chat.push({ role: 'user', content: q });
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = mode === 'check' ? 'Проверяю…' : 'Думаю…'; chatEl.appendChild(ph);
chatEl.scrollTop = chatEl.scrollHeight;
setNameFace('thinking');
Promise.all([
LS.assistantAsk(q, context, history, mode).catch(function () { return { answers: [] }; }),
(LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
@@ -622,9 +738,10 @@
if (r0.source === 'limit' || r0.source === 'error') {
_chat.pop();
var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = r0.answer || 'Сейчас не получилось. Попробуй ещё раз.';
chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; return;
chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('sad'); return;
}
var model = r0.source === 'model' ? r0.answer : null;
setNameFace(model ? 'happy' : 'neutral');
var ans = r0.answers || [];
var sources = r0.sources || [];
var found = (res[1] && res[1].results) || [];
@@ -680,6 +797,63 @@
}).catch(function () { note.innerHTML = '<div class="asst-rich">Не удалось сделать карточки. Попробуй позже.</div>'; });
}
/* ── «Тест в банк» (учитель): модель → вопросы → банк вопросов ─────────── */
function makeQuiz(topic, chatEl) {
topic = (topic || '').trim();
var note = msgEl('assistant');
note.innerHTML = '<div class="asst-rich">Составляю тестовые вопросы…</div>';
chatEl.appendChild(note); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('thinking');
Promise.all([
LS.assistantQuestions(topic, 5),
(LS.getSubjects ? LS.getSubjects() : Promise.resolve([])).catch(function () { return []; }),
]).then(function (res) {
var qs = (res[0] && res[0].questions) || [];
var subjects = Array.isArray(res[1]) ? res[1] : ((res[1] && res[1].subjects) || []);
if (!qs.length) { note.innerHTML = '<div class="asst-rich">Не получилось сгенерировать вопросы. Уточни тему и попробуй ещё.</div>'; setNameFace('sad'); return; }
note.remove();
var wrap = msgEl('assistant'); wrap.style.maxWidth = '100%';
var box = document.createElement('div'); box.className = 'asst-rich'; wrap.appendChild(box);
var head = document.createElement('div'); head.style.cssText = 'font-weight:800;margin-bottom:6px'; head.textContent = 'Вопросы (' + qs.length + ') — проверь и сохрани:'; box.appendChild(head);
qs.forEach(function (it, i) {
var qd = document.createElement('div'); qd.style.cssText = 'margin:8px 0;padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:10px';
var qt = document.createElement('div'); qt.style.cssText = 'font-weight:700;margin-bottom:4px'; qt.appendChild(document.createTextNode((i + 1) + '. '));
var qr = document.createElement('span'); qt.appendChild(qr); renderRich(qr, it.q); qd.appendChild(qt);
(it.options || []).forEach(function (op, oi) {
var li = document.createElement('div'); li.style.cssText = 'padding:2px 0 2px 14px;font-size:.84rem' + (oi === it.correct ? ';color:#059652;font-weight:700' : '');
var os = document.createElement('span'); renderRich(os, op); li.appendChild(os);
if (oi === it.correct) { var okm = document.createElement('span'); okm.textContent = ' — верно'; okm.style.color = '#059652'; li.appendChild(okm); }
qd.appendChild(li);
});
if (it.explanation) { var ex = document.createElement('div'); ex.style.cssText = 'margin-top:4px;font-size:.8rem;color:#8a94a6'; ex.appendChild(document.createTextNode('Пояснение: ')); var exs = document.createElement('span'); renderRich(exs, it.explanation); ex.appendChild(exs); qd.appendChild(ex); }
box.appendChild(qd);
});
var bar = document.createElement('div'); bar.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:8px';
var sel = document.createElement('select'); sel.style.cssText = 'padding:6px 8px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.82rem';
sel.innerHTML = '<option value="">Предмет…</option>' + subjects.map(function (s) { return '<option value="' + esc(s.slug) + '">' + esc(s.name || s.slug) + '</option>'; }).join('');
var topicIn = document.createElement('input'); topicIn.type = 'text'; topicIn.placeholder = 'Тема (необязательно)'; topicIn.style.cssText = 'padding:6px 8px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.82rem;flex:1;min-width:110px';
var saveB = document.createElement('button'); saveB.className = 'asst-chip'; saveB.type = 'button'; saveB.textContent = 'Сохранить в банк';
var st = document.createElement('span'); st.style.cssText = 'font-size:.8rem;color:#8a94a6';
bar.appendChild(sel); bar.appendChild(topicIn); bar.appendChild(saveB); bar.appendChild(st); box.appendChild(bar);
saveB.addEventListener('click', function () {
var slug = sel.value; if (!slug) { st.textContent = 'Выбери предмет'; return; }
saveB.disabled = true; st.textContent = 'Сохраняю…';
var topicName = topicIn.value.trim() || (topic.length <= 60 ? topic : '');
var done = 0;
qs.reduce(function (p, it) {
return p.then(function () {
return LS.createQuestion({ subject_slug: slug, topic_name: topicName || undefined, type: 'single', text: it.q, explanation: it.explanation || undefined, difficulty: 1, options: (it.options || []).map(function (t, i) { return { text: t, is_correct: i === it.correct }; }) }).then(function () { done++; }).catch(function () {});
});
}, Promise.resolve()).then(function () {
st.innerHTML = 'Сохранено ' + done + ' из ' + qs.length + '. <a class="asst-ans-link" href="/question-bank">Открыть банк вопросов</a>';
saveB.style.display = 'none'; sel.disabled = true; topicIn.disabled = true;
});
});
chatEl.appendChild(wrap); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('ecstatic');
}).catch(function (e) {
note.innerHTML = '<div class="asst-rich">' + ((e && e.data && e.data.error) ? esc(e.data.error) : 'Не удалось сгенерировать вопросы.') + '</div>'; setNameFace('sad');
});
}
/* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */
var TOUR = [
{ sel: '#app-sidebar a[href="/dashboard"]', title: 'Дашборд', text: 'Главная: твой прогресс, активность и питомец.' },
+4 -1
View File
@@ -197,7 +197,10 @@
sev: 'amber', kind: 'stuck', kindLabel: 'Зависла',
title: s.user_name || '—',
meta: `${e(s.subject_name || '—')} · висит <span class="acc-mono">${fmtSince(s.started_at)}</span>`,
act: 'Открыть', actHash: '/admin#sessions', solid: true,
// Глубокая ссылка на ДЕТАЛИ конкретной сессии (открывается при любом статусе):
// список /admin#sessions показывает только completed, поэтому зависшая (in_progress)
// там не находилась. На странице деталей её можно посмотреть и удалить.
act: 'Открыть', actHash: '/admin#sessions/' + s.id, solid: true,
});
});
const ab = d.abandonedSessions24h || 0;
+9 -3
View File
@@ -34,6 +34,12 @@
const pickerOver = document.getElementById('vp-overlay');
const pickerGrid = document.getElementById('vp-grid');
/* Человекочитаемая метка варианта (ЦТ-2015 и т.п.); фолбэк — «Вариант N». */
const labelOf = (n) => {
const v = variants.find(x => x.n === n);
return (v && v.label) || `Вариант ${n}`;
};
/* ── Picker overlay ─────────────────────────────────────────── */
function buildGrid() {
pickerGrid.innerHTML = variants.map(v => {
@@ -45,7 +51,7 @@
const active = v.n === currentN ? ' active' : '';
const title = `${v.label} · решено ${v.solved}/${v.total}` +
(v.viewed_sol ? ` · решений открыто ${v.viewed_sol}` : '');
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.n}</button>`;
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.label || v.n}</button>`;
}).join('');
pickerGrid.querySelectorAll('button[data-n]').forEach(b => {
b.onclick = () => { selectVariant(Number(b.dataset.n)); closePicker(); };
@@ -74,7 +80,7 @@
/* ── Variant rendering ──────────────────────────────────────── */
async function selectVariant(n) {
currentN = n;
pickerLabel.textContent = `Вариант ${n}`;
pickerLabel.textContent = labelOf(n);
try { localStorage.setItem(`exam_prep_${examKey}_last_variant`, String(n)); } catch {}
if (!tasksCache.has(n)) {
@@ -94,7 +100,7 @@
}
function renderVariant(n, tasks) {
main.innerHTML = `<div class="vp-title">Вариант ${n}<small>${tasks.length} заданий</small></div>`;
main.innerHTML = `<div class="vp-title">${labelOf(n)}<small>${tasks.length} заданий</small></div>`;
const variantMeta = variants.find(v => v.n === n);
const solvedTracked = new Set(); // tasks already solved this session
+416 -65
View File
@@ -53,6 +53,8 @@ class TrigCircleSim {
this.graphFn = 'sin';
this.snapToNotable = true;
this.animating = false;
this.eq = null; // режим уравнения: { fn:'sin'|'cos'|'tg', a:Number, sols:[рад] } | null
this.showParity = false; // показать зеркальную точку −α (чётность/нечётность)
this._cx = 0; this._cy = 0; this._r = 0;
this._gx = 0; this._gw = 0; this._gh = 0; this._gy = 0;
@@ -96,11 +98,14 @@ class TrigCircleSim {
this._drawBg(c);
this._drawCircle(c);
if (this.eq) this._drawEquation(c);
if (this.showParity) this._drawParity(c);
if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); }
this._drawParticles(c);
if (window.LabFX) LabFX.particles.draw(c);
c.restore();
this._ovClearUnused();
this._fireUpdate();
}
@@ -116,6 +121,103 @@ class TrigCircleSim {
this._layout(); this.draw();
}
/* Режим уравнения: подсветить на окружности все решения fn(x)=a. */
setEquation(fn, a, sols) {
this.eq = { fn, a, sols: sols || [] };
if (this.eq.sols.length) this.angle = this.eq.sols[0]; // встать на первое решение
this.draw();
}
clearEquation() { this.eq = null; this.draw(); }
_drawEquation(c) {
const cx = this._cx, cy = this._cy, r = this._r;
const { fn, a, sols } = this.eq;
const accent = fn === 'sin' ? _TC.sin : fn === 'cos' ? _TC.cos : _TC.tan;
c.save();
/* направляющая линия значения */
c.strokeStyle = _tcRgba(accent, 0.55); c.lineWidth = 1.5; c.setLineDash([6, 5]);
c.beginPath();
if (fn === 'sin') { const y = cy - r * a; c.moveTo(cx - r - 22, y); c.lineTo(cx + r + 22, y); }
else if (fn === 'cos') { const x = cx + r * a; c.moveTo(x, cy - r - 22); c.lineTo(x, cy + r + 22); }
else { const ang = sols.length ? sols[0] : Math.atan(a); const dx = Math.cos(ang), dy = Math.sin(ang), L = r + 24;
c.moveTo(cx - L * dx, cy + L * dy); c.lineTo(cx + L * dx, cy - L * dy); }
c.stroke(); c.setLineDash([]);
/* точки-решения + подписи градусов */
c.font = 'bold 11px Manrope,sans-serif';
sols.forEach(ang => {
const x = cx + r * Math.cos(ang), y = cy - r * Math.sin(ang);
c.fillStyle = accent; c.shadowColor = accent; c.shadowBlur = 12;
c.beginPath(); c.arc(x, y, 6, 0, Math.PI * 2); c.fill(); c.shadowBlur = 0;
c.fillStyle = 'rgba(255,255,255,0.92)'; c.beginPath(); c.arc(x, y, 2.2, 0, Math.PI * 2); c.fill();
const lr = r + 18, lx = cx + lr * Math.cos(ang), ly = cy - lr * Math.sin(ang);
c.fillStyle = accent; c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText(Math.round(ang * 180 / Math.PI) + '°', lx, ly);
});
c.restore();
}
/* Зеркальная точка −α (отражение через ось Ox): наглядно чётность cos и нечётность sin. */
_drawParity(c) {
const cx = this._cx, cy = this._cy, r = this._r, a = this.angle;
const px = cx + r * Math.cos(a), py = cy - r * Math.sin(a);
const mx = cx + r * Math.cos(-a), my = cy - r * Math.sin(-a);
c.save();
c.strokeStyle = _tcRgba(_TC.violet, 0.4); c.setLineDash([4, 4]); c.lineWidth = 1;
c.beginPath(); c.moveTo(px, py); c.lineTo(mx, my); c.stroke(); c.setLineDash([]);
c.strokeStyle = _TC.violet; c.lineWidth = 2; c.fillStyle = 'rgba(155,93,229,0.15)';
c.beginPath(); c.arc(mx, my, 6, 0, Math.PI * 2); c.fill(); c.stroke();
c.font = 'bold 11px Manrope,sans-serif'; c.fillStyle = _TC.violet;
c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText('-α', mx + (Math.cos(-a) >= 0 ? 14 : -14), my);
c.restore();
}
/* ═══ KaTeX-оверлей: HTML-подписи поверх canvas (на canvas KaTeX не рисуется) ══════ */
_ov() {
if (this._ovEl === undefined) this._ovEl = (typeof document !== 'undefined' && document.getElementById) ? document.getElementById('trig-overlay') : null;
return this._ovEl;
}
/* key стабильный id подписи; latex LaTeX (дробь/корень KaTeX, иначе текст);
x,y CSS-px над canvas; anchor: c|l|r|t|b; boxed тёмная плашка (для координат). */
_ovLabel(key, latex, x, y, color, anchor, boxed) {
const ov = this._ov(); if (!ov) return;
this._ovMap = this._ovMap || {};
this._ovUsed = this._ovUsed || {};
let rec = this._ovMap[key];
if (!rec) {
const el = document.createElement('div');
el.style.position = 'absolute'; el.style.whiteSpace = 'nowrap'; el.style.pointerEvents = 'none';
el.style.willChange = 'transform';
ov.appendChild(el);
rec = this._ovMap[key] = { el, last: null, boxed: null };
}
if (rec.last !== latex) {
// Любая LaTeX-команда (\pi, \tfrac, \sin…) → KaTeX; простой текст/число — быстро текстом.
const useK = /\\/.test(latex) && (typeof window !== 'undefined' && window.katex);
if (useK) rec.el.innerHTML = window.katex.renderToString(latex, { throwOnError: false, strict: false, displayMode: false });
else rec.el.textContent = latex;
rec.last = latex;
}
if (rec.boxed !== !!boxed) {
rec.el.style.cssText += boxed
? ';background:rgba(12,12,22,0.82);border:1px solid rgba(155,93,229,0.3);border-radius:8px;padding:3px 9px'
: ';background:none;border:none;padding:0';
rec.boxed = !!boxed;
}
rec.el.style.color = color || '#fff';
const a = anchor || 'c';
const tr = a === 'r' ? 'translate(-100%,-50%)' : a === 'l' ? 'translate(0,-50%)'
: a === 't' ? 'translate(-50%,0)' : a === 'b' ? 'translate(-50%,-100%)' : 'translate(-50%,-50%)';
rec.el.style.transform = `translate(${x}px,${y}px) ${tr}`;
rec.el.style.display = '';
this._ovUsed[key] = true;
}
_ovClearUnused() {
if (!this._ovMap) return;
for (const k in this._ovMap) if (!(this._ovUsed && this._ovUsed[k])) this._ovMap[k].el.style.display = 'none';
this._ovUsed = {};
}
goToAngle(rad) {
this._animTarget = this._norm(rad);
if (!this.animating) this._startAnim();
@@ -130,7 +232,16 @@ class TrigCircleSim {
const ct = Math.abs(s) > 1e-9 ? co / s : undefined;
const deg = a * 180 / Math.PI;
const q = a < Math.PI/2 ? 1 : a < Math.PI ? 2 : a < 3*Math.PI/2 ? 3 : 4;
return { angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q };
// Опорный (острый) угол — к ближайшей оси Ox: основа формул приведения.
let ref;
if (a <= Math.PI / 2) ref = a;
else if (a <= Math.PI) ref = Math.PI - a;
else if (a <= 3 * Math.PI/2) ref = a - Math.PI;
else ref = 2 * Math.PI - a;
return {
angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q,
refAngle: ref, refDeg: ref * 180 / Math.PI,
};
}
/* ═══ Layout ═══════════════════════════════════════════════════════ */
@@ -290,11 +401,10 @@ class TrigCircleSim {
ag.addColorStop(1, _tcRgba(_TC.violet, 0.0));
c.strokeStyle = ag; c.lineWidth = 2.5;
c.beginPath(); c.arc(cx, cy, ar, 0, -a, true); c.stroke();
/* label */
const mid = a / 2, lr = ar + 18;
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.violet;
c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText(this._radLbl(a), cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid));
/* label (KaTeX overlay: π-доля для табличных, иначе текст) */
const mid = a / 2, lr = ar + 20;
this._ovLabel('angle', _angleLatex(a) || this._radLbl(a),
cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid), _TC.violet, 'c');
}
/* ── radius ── */
@@ -388,9 +498,9 @@ class TrigCircleSim {
/* ── axis value badges ── */
if (this.showSin && Math.abs(sinA) > 0.04)
this._badge(c, cx - 12, py, this._fmt(sinA), _TC.sin, 'right', 'middle');
this._ovLabel('vsin', _latexVal(sinA), cx - 14, py, _TC.sin, 'r');
if (this.showCos && Math.abs(cosA) > 0.04)
this._badge(c, projX, cy + 17, this._fmt(cosA), _TC.cos, 'center', 'top');
this._ovLabel('vcos', _latexVal(cosA), projX, cy + 20, _TC.cos, 't');
/* ── main point ── */
const ps = this._hover || this._drag ? 10 : 8;
@@ -406,8 +516,12 @@ class TrigCircleSim {
c.strokeStyle = 'rgba(255,255,255,0.50)'; c.lineWidth = 2;
c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.stroke();
/* ── coordinate tooltip ── */
this._tooltip(c, px, py, cosA, sinA);
/* coordinate tooltip (KaTeX overlay) выносим РАДИАЛЬНО НАРУЖУ за точку,
чтобы не перекрывать центральную дугу угла и её подпись */
const _odx = Math.cos(a), _ody = -Math.sin(a);
this._ovLabel('coord', `\\left(${_latexVal(cosA)};\\ ${_latexVal(sinA)}\\right)`,
px + _odx * 20 + (cosA >= 0 ? 6 : -6), py + _ody * 20 + (sinA >= 0 ? -8 : 8),
'#fff', cosA >= 0 ? 'l' : 'r', true);
/* ── quadrant roman numeral ── */
const qOff = r * 0.46;
@@ -538,7 +652,6 @@ class TrigCircleSim {
const fn = this.graphFn;
const col = _TC[fn] || _TC.sin;
const lbl = fn==='sin'?'y = sin x':fn==='cos'?'y = cos x':fn==='tan'?'y = tg x':'y = ctg x';
const evFn = fn==='sin'?Math.sin:fn==='cos'?Math.cos:fn==='tan'?Math.tan:(x=>1/Math.tan(x));
const yR = (fn==='tan'||fn==='cot') ? 4 : 1.5;
const xMin = -0.25*Math.PI, xMax = 2.25*Math.PI;
@@ -575,29 +688,30 @@ class TrigCircleSim {
c.beginPath(); c.moveTo(x0, gy); c.lineTo(x0, gy+gh); c.stroke();
}
/* ±1 lines */
if (fn==='sin'||fn==='cos') {
c.strokeStyle = 'rgba(255,255,255,0.05)'; c.setLineDash([4, 4]);
[1,-1].forEach(v => { c.beginPath(); c.moveTo(gx, sy(v)); c.lineTo(gx+gw, sy(v)); c.stroke(); });
c.setLineDash([]);
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.22)';
c.textAlign='right'; c.textBaseline='middle';
c.fillText('1', gx-5, sy(1)); c.fillText('1', gx-5, sy(-1));
}
/* ── шкала значений по оси Y (значения на координатной плоскости) ── */
const yVals = (fn==='tan'||fn==='cot')
? [[3,'3'],[2,'2'],[1,'1'],[0,'0'],[-1,'-1'],[-2,'-2'],[-3,'-3']]
: [[1,'1'],[0.5,'\\tfrac{1}{2}'],[0,'0'],[-0.5,'-\\tfrac{1}{2}'],[-1,'-1']];
yVals.forEach(([v, lx], i) => {
const yy = sy(v);
if (yy < gy + 6 || yy > gy + gh - 6) return;
if (v !== 0) {
c.strokeStyle = 'rgba(255,255,255,0.05)'; c.lineWidth = 1; c.setLineDash([4, 4]);
c.beginPath(); c.moveTo(gx, yy); c.lineTo(gx+gw, yy); c.stroke(); c.setLineDash([]);
}
this._ovLabel('gy' + i, lx, gx - 6, yy, 'rgba(255,255,255,0.55)', 'r');
});
/* x ticks */
const ticks = [[0,'0'],[Math.PI/2,'π/2'],[Math.PI,'π'],[3*Math.PI/2,'3π/2'],[2*Math.PI,'2π']];
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.20)';
c.textAlign='center'; c.textBaseline='top';
for (const [v,l] of ticks) {
/* x ticks — линии на canvas, подписи KaTeX-оверлеем */
const ticks = [[0, '0'], [Math.PI/2, '\\tfrac{\\pi}{2}'], [Math.PI, '\\pi'],
[3*Math.PI/2, '\\tfrac{3\\pi}{2}'], [2*Math.PI, '2\\pi']];
ticks.forEach(([v, lx], i) => {
const xx = sx(v);
if (xx < gx+6 || xx > gx+gw-6) continue;
c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1;
c.setLineDash([3,3]);
c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke();
c.setLineDash([]);
c.fillText(l, xx, gy+gh+6);
}
if (xx < gx+6 || xx > gx+gw-6) return;
c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1; c.setLineDash([3,3]);
c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke(); c.setLineDash([]);
this._ovLabel('gtick' + i, lx, xx, gy + gh + 9, 'rgba(255,255,255,0.55)', 't');
});
/* ── ghost curves (other functions, dimmed) ── */
c.save();
@@ -669,6 +783,21 @@ class TrigCircleSim {
}
c.stroke();
/* ── развёртка: ярче выделяем кривую на [0, α] — как угол «разворачивается» в график ── */
{
const aMax = Math.min(Math.max(this.angle, 0), xMax);
c.strokeStyle = col; c.lineWidth = 4.5; c.lineCap = 'round'; c.lineJoin = 'round';
c.shadowColor = col; c.shadowBlur = 6;
c.beginPath(); let onS = false;
for (let x = 0; x <= aMax + 1e-9; x += step) {
const yv = evFn(x);
if (!isFinite(yv) || Math.abs(yv) > yR * 2) { onS = false; continue; }
const spx = sx(x), spy = sy(yv);
if (!onS) { c.moveTo(spx, spy); onS = true; } else c.lineTo(spx, spy);
}
c.stroke(); c.shadowBlur = 0;
}
/* ── current angle marker ── */
const curY = evFn(this.angle);
if (isFinite(curY) && Math.abs(curY) <= yR*2) {
@@ -687,31 +816,21 @@ class TrigCircleSim {
c.shadowBlur = 0;
c.fillStyle = 'rgba(255,255,255,0.7)';
c.beginPath(); c.arc(mx, my, 2, 0, Math.PI*2); c.fill();
/* value badge */
const txt = this._fmt(curY);
c.font = 'bold 11px Manrope,sans-serif';
const tm = c.measureText(txt);
const bx2 = mx+10, by2 = my-22, bw2 = tm.width+14, bh2 = 20;
c.fillStyle='rgba(12,12,22,0.85)';
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.fill();
c.strokeStyle = _tcRgba(col, 0.4); c.lineWidth = 1;
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.stroke();
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
c.fillText(txt, bx2+7, by2);
/* value badge (KaTeX overlay) */
this._ovLabel('gval', _latexVal(curY), mx + 12, my - 20, col, 'l', true);
/* подпись угла на оси X (развёртка: где текущий угол на графике) */
this._ovLabel('gangle', _angleLatex(this.angle) || this._radLbl(this.angle),
mx, gy + 5, _TC.violet, 't', true);
}
c.restore();
/* fn name badge */
c.font='bold 13px Manrope,sans-serif';
const tm2 = c.measureText(lbl);
const bw3 = tm2.width+18, bh3 = 26;
c.fillStyle='rgba(12,12,22,0.7)';
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.fill();
c.strokeStyle = _tcRgba(col, 0.25); c.lineWidth = 1;
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.stroke();
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
c.fillText(lbl, gx+17, gy+21);
/* fn name badge (KaTeX-оверлей) */
const _glblTex = fn === 'sin' ? 'y = \\sin x'
: fn === 'cos' ? 'y = \\cos x'
: fn === 'tan' ? 'y = \\operatorname{tg} x'
: 'y = \\operatorname{ctg} x';
this._ovLabel('glabel', _glblTex, gx + 16, gy + 21, col, 'l', true);
}
/* ═══ Snap particles ═══════════════════════════════════════════════ */
@@ -1029,6 +1148,144 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
if (window.LabFX) LabFX.sound.play('click');
}
/* Ввод угла в градусах (поле + Enter/кнопка). Принимает любое число (включая <0 и >360),
goToAngle нормализует заодно демонстрирует котерминальность. */
function trigSetAngleDeg(inp) {
if (!trigSim || !inp) return;
const v = parseFloat(String(inp.value || '').replace(',', '.'));
if (!isFinite(v)) return;
trigSim.goToAngle(v * Math.PI / 180);
}
function trigAngleKey(e, inp) { if (e && (e.key === 'Enter' || e.keyCode === 13)) trigSetAngleDeg(inp); }
/* Показать/скрыть график функций (тема «функции» по умолчанию можно убрать,
круг займёт всю ширину). Переиспользует существующий слой 'graph'. */
function trigToggleGraph(rowEl) {
if (!trigSim) return;
const on = rowEl.classList.toggle('active');
trigSim.toggleLayer('graph', on);
const fns = document.getElementById('trig-graph-fns');
if (fns) fns.style.display = on ? '' : 'none';
}
/* ── Уравнения: решения fn(x)=a на [0,2π) ── */
function _trigSolveAngles(fn, a) {
const TAU = 2 * Math.PI, norm = x => ((x % TAU) + TAU) % TAU;
let raw;
if (fn === 'sin') { if (Math.abs(a) > 1) return []; const b = Math.asin(a); raw = [b, Math.PI - b]; }
else if (fn === 'cos') { if (Math.abs(a) > 1) return []; const b = Math.acos(a); raw = [b, -b]; }
else { const b = Math.atan(a); raw = [b, b + Math.PI]; } // tg — всегда есть решения
const out = [];
raw.map(norm).forEach(x => { if (!out.some(y => Math.abs(y - x) < 1e-6 || Math.abs(y - x - TAU) < 1e-6)) out.push(x); });
return out.sort((p, q) => p - q);
}
/* Радиан → LaTeX красивой π-доли (или null). Покрывает главные значения arcsin/arccos/arctg. */
function _radLatex(rad) {
const P = Math.PI;
const T = [[0, '0'], [P/6, '\\tfrac{\\pi}{6}'], [P/4, '\\tfrac{\\pi}{4}'], [P/3, '\\tfrac{\\pi}{3}'],
[P/2, '\\tfrac{\\pi}{2}'], [2*P/3, '\\tfrac{2\\pi}{3}'], [3*P/4, '\\tfrac{3\\pi}{4}'],
[5*P/6, '\\tfrac{5\\pi}{6}'], [P, '\\pi']];
for (const [v, l] of T) {
if (Math.abs(rad - v) < 1e-6) return l;
if (v > 0 && Math.abs(rad + v) < 1e-6) return '-' + l;
}
return null;
}
/* Общая формула решения (LaTeX) или {none:true}. */
function _trigEqFormulaLatex(fn, a) {
if ((fn === 'sin' || fn === 'cos') && Math.abs(a) > 1) return { none: true };
if (fn === 'sin') {
const p = _radLatex(Math.asin(a)) || ('\\arcsin ' + _latexVal(a));
return { latex: `x = (-1)^{n}\\,${p} + \\pi n,\\ n\\in\\mathbb{Z}` };
}
if (fn === 'cos') {
const p = _radLatex(Math.acos(a)) || ('\\arccos ' + _latexVal(a));
return { latex: `x = \\pm ${p} + 2\\pi n,\\ n\\in\\mathbb{Z}` };
}
const p = _radLatex(Math.atan(a)) || ('\\operatorname{arctg} ' + _latexVal(a));
return { latex: `x = ${p} + \\pi n,\\ n\\in\\mathbb{Z}` };
}
var trigEqFn = 'sin';
function trigSetEqFn(fn, btn) {
trigEqFn = fn;
document.querySelectorAll('.trig-eq-fn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
}
function trigSolve() {
if (!trigSim) return;
const inp = document.getElementById('trig-eq-input');
const a = parseFloat(String(inp && inp.value || '').replace(',', '.'));
const fnTex = { sin: '\\sin', cos: '\\cos', tg: '\\operatorname{tg}' }[trigEqFn];
const fEl = document.getElementById('trig-eq-formula');
const sEl = document.getElementById('trig-eq-sols');
if (!isFinite(a)) { if (fEl) fEl.innerHTML = '<span style="color:var(--text-3)">Введите значение a</span>'; if (sEl) sEl.textContent = ''; return; }
const sols = _trigSolveAngles(trigEqFn, a);
trigSim.setEquation(trigEqFn, a, sols);
const K = window.katex;
const tex = l => (K ? K.renderToString(l, { throwOnError: false, strict: false, displayMode: false }) : l);
const eqHead = tex(`${fnTex} x = ${_latexVal(a)}`);
const f = _trigEqFormulaLatex(trigEqFn, a);
if (fEl) {
fEl.innerHTML = `<div style="margin-bottom:5px;color:var(--violet)">${eqHead}</div>` +
(f.none ? '<div style="color:#EF476F">Нет решений (|a| > 1)</div>' : `<div>${tex(f.latex)}</div>`);
}
if (sEl) sEl.textContent = sols.length
? 'На [0, 2π): ' + sols.map(x => Math.round(x * 180 / Math.PI) + '°').join(', ')
: '';
}
function trigClearEq() {
if (!trigSim) return;
trigSim.clearEquation();
const fEl = document.getElementById('trig-eq-formula'); if (fEl) fEl.innerHTML = '';
const sEl = document.getElementById('trig-eq-sols'); if (sEl) sEl.textContent = '';
}
function trigEqKey(e) { if (e && (e.key === 'Enter' || e.keyCode === 13)) trigSolve(); }
/* ── Таблица значений (первая четверть) — строится один раз, KaTeX ── */
function _trigBuildValueTable() {
const el = document.getElementById('trig-table');
if (!el || el.dataset.built) return;
const cols = [['sin', '#EF476F'], ['cos', '#06D6E0'], ['tg', '#FFD166'], ['ctg', '#7BF5A4']];
const head = '<tr><th style="text-align:left;padding:2px 4px;color:var(--text-3);font-weight:700">α</th>' +
cols.map(([n, c]) => `<th style="padding:2px 4px;color:${c};font-weight:700">${n}</th>`).join('') + '</tr>';
const body = [0, 30, 45, 60, 90].map(deg => {
const a = deg * Math.PI / 180, sn = Math.sin(a), cs = Math.cos(a);
const tn = Math.abs(cs) > 1e-9 ? sn / cs : undefined;
const ct = Math.abs(sn) > 1e-9 ? cs / sn : undefined;
const cell = v => `<td style="padding:3px 4px;text-align:center">${_tex(_latexVal(v))}</td>`;
return `<tr data-deg="${deg}"><td style="padding:3px 4px;font-weight:700">${deg}°</td>${cell(sn)}${cell(cs)}${cell(tn)}${cell(ct)}</tr>`;
}).join('');
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:0.74rem">${head}${body}</table>`;
el.dataset.built = '1';
}
function trigToggleTable(rowEl) {
const on = rowEl.classList.toggle('active');
const el = document.getElementById('trig-table');
if (!el) return;
if (on) { _trigBuildValueTable(); el.style.display = ''; if (trigSim) _trigUpdateUI(trigSim.stats()); }
else el.style.display = 'none';
}
/* ── Чётность/нечётность + периоды (статический KaTeX-блок, строится один раз) ── */
function trigToggleParity(rowEl) {
if (!trigSim) return;
const on = rowEl.classList.toggle('active');
trigSim.showParity = on;
trigSim.draw();
const pEl = document.getElementById('trig-parity');
if (!pEl) return;
pEl.style.display = on ? '' : 'none';
if (on && !pEl.dataset.built) {
pEl.innerHTML =
`<div>${_tex('\\sin(-\\alpha) = -\\sin\\alpha')}</div>` +
`<div>${_tex('\\cos(-\\alpha) = \\cos\\alpha')}</div>` +
`<div>${_tex('\\operatorname{tg}(-\\alpha) = -\\operatorname{tg}\\alpha')}</div>` +
`<div style="margin-top:6px;color:var(--text-3);font-size:0.7rem">${_tex('T_{\\sin}=T_{\\cos}=2\\pi,\\quad T_{\\operatorname{tg}}=T_{\\operatorname{ctg}}=\\pi')}</div>`;
pEl.dataset.built = '1';
}
}
function _trigUpdateUI(s) {
const _f = v => {
if (v === undefined) return '—';
@@ -1044,25 +1301,119 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
};
const degStr = s.deg.toFixed(1) + '°';
// Panel values (nice fractions)
document.getElementById('trig-v-sin').textContent = _f(s.sin);
document.getElementById('trig-v-cos').textContent = _f(s.cos);
document.getElementById('trig-v-tan').textContent = _f(s.tan);
document.getElementById('trig-v-cot').textContent = _f(s.cot);
// Значения — KaTeX для дробей/корней, текст для простых чисел (быстро при перетаскивании).
const setMathVal = (id, v) => {
const el = document.getElementById(id); if (!el) return;
const lx = _latexVal(v);
if (/\\tfrac|\\sqrt|\\text/.test(lx)) el.innerHTML = _tex(lx);
else el.textContent = lx;
};
setMathVal('trig-v-sin', s.sin);
setMathVal('trig-v-cos', s.cos);
setMathVal('trig-v-tan', s.tan);
setMathVal('trig-v-cot', s.cot);
// Angle badge
// Угол: KaTeX (град = π-доля) + радианы + котерминальные (+360°·k)
const al = _angleLatex(s.angle);
const head = al ? `${Math.round(s.deg)}^\\circ = ${al}` : `${degStr}`;
document.getElementById('trig-angle-badge').innerHTML =
`${degStr} = ${s.radLabel}<br><span style="font-size:0.72rem;opacity:0.6">${s.angle.toFixed(4)} рад</span>`;
`<div>${_tex(head)}</div>` +
`<span style="font-size:0.72rem;opacity:0.6">${s.angle.toFixed(4)} рад</span>` +
`<br><span style="font-size:0.68rem;opacity:0.5">+ 360°·k (котерминальные)</span>`;
// Stats bar (nice fractions)
// Опорный (острый) угол — guarded (панель может не иметь элемента)
const refEl = document.getElementById('trig-ref');
if (refEl) refEl.textContent = (Math.round(s.refDeg * 10) / 10) + '°';
// Знаки функций в текущей четверти
const signsEl = document.getElementById('trig-signs');
if (signsEl) {
const sg = v => (v > 1e-9 ? '+' : v < -1e-9 ? '' : '0');
signsEl.innerHTML =
`<b style="color:#EF476F">sin ${sg(s.sin)}</b> · <b style="color:#06D6E0">cos ${sg(s.cos)}</b> · ` +
`<b style="color:#FFD166">tg ${s.tan === undefined ? '—' : sg(s.tan)}</b>`;
}
// Точные значения + формула приведения (только для табличных углов)
const fEl = document.getElementById('trig-formula');
if (fEl) {
const beta = Math.round(s.refDeg);
const degR = Math.round(s.deg);
const isTable = [0, 30, 45, 60, 90].some(b => Math.abs(s.refDeg - b) < 0.5);
if (!isTable) {
fEl.innerHTML = '<span style="color:var(--text-3);font-size:0.72rem;line-height:1.5">Нетабличный угол — точных значений нет, см. приближённые выше.</span>';
} else {
const reduce = (s.quadrant !== 1) && (beta === 30 || beta === 45 || beta === 60);
const K = window.katex;
const tex = latex => (K ? K.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }) : latex);
const FN = { sin: '\\sin', cos: '\\cos', tg: '\\operatorname{tg}', ctg: '\\operatorname{ctg}' };
let html = '';
if (reduce) {
const wrap = s.quadrant === 2 ? `180^\\circ - ${beta}^\\circ`
: s.quadrant === 3 ? `180^\\circ + ${beta}^\\circ`
: `360^\\circ - ${beta}^\\circ`;
html += `<div style="color:var(--violet);margin-bottom:6px">${tex(`${degR}^\\circ = ${wrap}`)}</div>`;
}
const line = (nm, color, val) => {
const sgn = (val !== undefined && val < -1e-9) ? '-' : '';
const mid = reduce ? ` = ${sgn}${FN[nm]}\\,${beta}^\\circ` : '';
// KaTeX наследует CSS-цвет родителя → красим div, формулу не трогаем.
return `<div style="color:${color};line-height:1.95">${tex(`${FN[nm]}\\,${degR}^\\circ${mid} = ${_latexVal(val)}`)}</div>`;
};
fEl.innerHTML = html + line('sin', '#EF476F', s.sin) + line('cos', '#06D6E0', s.cos) +
line('tg', '#FFD166', s.tan) + line('ctg', '#7BF5A4', s.cot);
}
}
// Подсветка строки таблицы значений (по опорному острому углу)
const tbl = document.getElementById('trig-table');
if (tbl && tbl.dataset.built && typeof tbl.querySelectorAll === 'function') {
const beta = Math.round(s.refDeg);
tbl.querySelectorAll('tr[data-deg]').forEach(tr => {
tr.style.background = (Number(tr.dataset.deg) === beta) ? 'rgba(155,93,229,0.18)' : '';
});
}
// Stats bar — значения тоже KaTeX (дроби/корни)
document.getElementById('trigbar-angle').textContent = degStr;
document.getElementById('trigbar-sin').textContent = _f(s.sin);
document.getElementById('trigbar-cos').textContent = _f(s.cos);
document.getElementById('trigbar-tan').textContent = _f(s.tan);
document.getElementById('trigbar-cot').textContent = _f(s.cot);
setMathVal('trigbar-sin', s.sin);
setMathVal('trigbar-cos', s.cos);
setMathVal('trigbar-tan', s.tan);
setMathVal('trigbar-cot', s.cot);
document.getElementById('trigbar-quad').textContent = ['I', 'II', 'III', 'IV'][s.quadrant - 1];
}
/* Точное значение → LaTeX (зеркалит _f, но для KaTeX). undefined → «—». */
function _latexVal(v) {
if (v === undefined) return '\\text{не опр.}';
const a = Math.abs(v), sg = v < -1e-9 ? '-' : '';
if (a < 5e-4) return '0';
if (Math.abs(a - 0.5) < 1e-3) return sg + '\\tfrac{1}{2}';
if (Math.abs(a - Math.SQRT2 / 2) < 1e-3) return sg + '\\tfrac{\\sqrt{2}}{2}';
if (Math.abs(a - Math.sqrt(3) / 2) < 1e-3) return sg + '\\tfrac{\\sqrt{3}}{2}';
if (Math.abs(a - Math.sqrt(3) / 3) < 1e-3) return sg + '\\tfrac{\\sqrt{3}}{3}';
if (Math.abs(a - 1) < 1e-3) return sg + '1';
if (Math.abs(a - Math.sqrt(3)) < 1e-3) return sg + '\\sqrt{3}';
return v.toFixed(3);
}
/* Рендер LaTeX → HTML через KaTeX (с фолбэком на сырой LaTeX, если katex ещё не готов). */
function _tex(latex) {
const K = window.katex;
return K ? K.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }) : latex;
}
/* Юникод-метка π-доли ('7π/6','π/4','π','0') → LaTeX. */
function _piLabelToLatex(l) {
if (l === '0') return '0';
const conv = s => s.replace('π', '\\pi');
if (l.indexOf('/') >= 0) { const p = l.split('/'); return `\\tfrac{${conv(p[0])}}{${p[1]}}`; }
return conv(l);
}
/* Радиан текущего угла → LaTeX красивой π-доли по таблице 16 углов (или null). */
function _angleLatex(rad) {
for (const n of _TC_NOTABLE) if (Math.abs(rad - n.a) < 1e-6) return _piLabelToLatex(n.l);
return null;
}
/* ── KaTeX live preview ── */
/** Convert user ascii expression <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> LaTeX string for KaTeX preview */
+1 -1
View File
@@ -585,7 +585,7 @@ let _dashOffset = 0; // animated dash offset for link flow
LS.notif.init();
lucide.createIcons();
const feats = await LS.loadFeatures();
if (feats.knowledge_map === false) { window.location.replace('/403'); return; }
if (feats.knowledge_map === false && user?.role !== 'admin') { window.location.replace('/403'); return; }
LS.hideDisabledFeatures?.();
document.querySelector('.sb-toggle')?.addEventListener('click', () => {
+78 -4
View File
@@ -506,6 +506,16 @@
<!-- left panel -->
<div class="proj-panel" style="width:240px;gap:0">
<!-- Angle input -->
<div class="gp-section-title" style="margin-bottom:8px">Угол, °</div>
<div style="display:flex;gap:6px;margin-bottom:14px">
<input id="trig-angle-input" type="number" step="1" placeholder="напр. 150"
onkeydown="trigAngleKey(event,this)"
style="flex:1;min-width:0;padding:7px 10px;border:1.5px solid var(--border-h);border-radius:8px;background:#fff;color:var(--text);font-family:'Manrope',sans-serif;font-size:0.82rem;outline:none" />
<button class="preset-btn" style="flex-shrink:0;padding:7px 12px" title="Перейти к углу"
onclick="trigSetAngleDeg(document.getElementById('trig-angle-input'))"><svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
</div>
<!-- Function toggles -->
<div class="gp-section-title" style="margin-bottom:10px">Отрезки</div>
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:14px">
@@ -535,9 +545,14 @@
</label>
</div>
<!-- Graph function selector -->
<div class="gp-section-title" style="margin-bottom:8px">График</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px">
<!-- Graph (functions) — optional, can be hidden to focus on the circle -->
<label class="tri-layer-row active" style="margin-bottom:8px" onclick="trigToggleGraph(this)">
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
<span class="tri-layer-name">График</span>
<span class="tri-layer-hint" style="color:var(--text-3)">функции</span>
<span class="tri-toggle"></span>
</label>
<div id="trig-graph-fns" style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px">
<button class="trig-fn-btn active" onclick="trigSetGraphFn('sin',this)" style="--fc:#EF476F">sin</button>
<button class="trig-fn-btn" onclick="trigSetGraphFn('cos',this)" style="--fc:#06D6E0">cos</button>
<button class="trig-fn-btn" onclick="trigSetGraphFn('tan',this)" style="--fc:#FFD166">tg</button>
@@ -553,6 +568,55 @@
<span class="tri-stat-k" style="color:#7BF5A4">ctg</span><span class="tri-stat-v" id="trig-v-cot"></span>
</div>
<!-- Reference (acute) angle + signs by quadrant -->
<div class="gp-section-title" style="margin-bottom:8px">Опорный угол · знаки</div>
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:0.78rem">
<span style="color:var(--text-3)">острый угол к оси</span>
<span id="trig-ref" style="font-weight:800;color:var(--violet)"></span>
</div>
<div id="trig-signs" style="text-align:center;font-size:0.72rem;color:var(--text-2)"></div>
</div>
<!-- Exact values + reduction formula (table angles) -->
<div class="gp-section-title" style="margin-bottom:8px">Точные значения · приведение</div>
<div id="trig-formula" style="margin-bottom:14px;font-size:0.78rem;color:var(--text);background:rgba(155,93,229,0.06);border:1px solid rgba(155,93,229,0.15);border-radius:10px;padding:9px 11px"></div>
<!-- Equation solver: fn(x) = a -->
<div class="gp-section-title" style="margin-bottom:8px">Уравнение</div>
<div style="display:flex;align-items:center;gap:5px;margin-bottom:6px;flex-wrap:wrap">
<button class="trig-eq-fn trig-fn-btn active" onclick="trigSetEqFn('sin',this)" style="--fc:#EF476F">sin</button>
<button class="trig-eq-fn trig-fn-btn" onclick="trigSetEqFn('cos',this)" style="--fc:#06D6E0">cos</button>
<button class="trig-eq-fn trig-fn-btn" onclick="trigSetEqFn('tg',this)" style="--fc:#FFD166">tg</button>
<span style="color:var(--text-3);font-size:0.82rem;font-weight:700">x =</span>
<input id="trig-eq-input" type="number" step="0.1" placeholder="a" onkeydown="trigEqKey(event)"
style="width:58px;padding:6px 8px;border:1.5px solid var(--border-h);border-radius:8px;background:#fff;color:var(--text);font-family:'Manrope',sans-serif;font-size:0.82rem;outline:none" />
</div>
<div style="display:flex;gap:6px;margin-bottom:8px">
<button class="preset-btn" style="flex:1" onclick="trigSolve()">Решить</button>
<button class="preset-btn" style="flex:1" onclick="trigClearEq()">Сброс</button>
</div>
<div id="trig-eq-formula" style="font-size:0.82rem;color:var(--text);margin-bottom:4px;line-height:1.7"></div>
<div id="trig-eq-sols" style="font-size:0.72rem;color:var(--text-3);margin-bottom:14px"></div>
<!-- Values table (first quadrant), toggle -->
<label class="tri-layer-row" style="margin-bottom:8px" onclick="trigToggleTable(this)">
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
<span class="tri-layer-name">Таблица значений</span>
<span class="tri-layer-hint" style="color:var(--text-3)">090°</span>
<span class="tri-toggle"></span>
</label>
<div id="trig-table" style="display:none;margin-bottom:14px;background:rgba(155,93,229,0.05);border:1px solid rgba(155,93,229,0.13);border-radius:10px;padding:6px 8px;overflow-x:auto"></div>
<!-- Parity (−α) + periods toggle -->
<label class="tri-layer-row" style="margin-bottom:8px" onclick="trigToggleParity(this)">
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
<span class="tri-layer-name">Чётность (−α)</span>
<span class="tri-layer-hint" style="color:var(--text-3)">симметрия</span>
<span class="tri-toggle"></span>
</label>
<div id="trig-parity" style="display:none;margin-bottom:14px;font-size:0.82rem;color:var(--text);line-height:1.7;background:rgba(155,93,229,0.05);border:1px solid rgba(155,93,229,0.13);border-radius:10px;padding:8px 11px"></div>
<!-- Notable angles -->
<div class="gp-section-title" style="margin-bottom:8px">Табличные углы</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:14px">
@@ -562,8 +626,16 @@
<button class="preset-btn" onclick="trigGoTo(Math.PI/3)">60°</button>
<button class="preset-btn" onclick="trigGoTo(Math.PI/2)">90°</button>
<button class="preset-btn" onclick="trigGoTo(2*Math.PI/3)">120°</button>
<button class="preset-btn" onclick="trigGoTo(3*Math.PI/4)">135°</button>
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/6)">150°</button>
<button class="preset-btn" onclick="trigGoTo(Math.PI)">180°</button>
<button class="preset-btn" onclick="trigGoTo(7*Math.PI/6)">210°</button>
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/4)">225°</button>
<button class="preset-btn" onclick="trigGoTo(4*Math.PI/3)">240°</button>
<button class="preset-btn" onclick="trigGoTo(3*Math.PI/2)">270°</button>
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/3)">300°</button>
<button class="preset-btn" onclick="trigGoTo(7*Math.PI/4)">315°</button>
<button class="preset-btn" onclick="trigGoTo(11*Math.PI/6)">330°</button>
</div>
<!-- Angle info -->
@@ -583,8 +655,10 @@
</div><!-- /.proj-panel -->
<!-- canvas -->
<div class="proj-canvas-outer">
<div class="proj-canvas-outer" style="position:relative">
<canvas id="trigcircle-canvas"></canvas>
<!-- KaTeX overlay: подписи значений/координат/угла над canvas -->
<div id="trig-overlay" style="position:absolute;inset:0;pointer-events:none;overflow:hidden;font-size:0.82rem"></div>
</div>
</div><!-- /.sim-body-wrap -->
+44 -3
View File
@@ -48,6 +48,14 @@
.mm-card-body { padding: 12px 14px; border-top: 1px solid var(--border); }
.mm-card-title { font-weight: 700; font-size: 0.86rem; color: var(--text); margin-bottom: 3px; }
.mm-card-meta { font-size: 0.74rem; color: var(--text-3); }
.mm-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.mm-tag { font-size: 0.7rem; font-weight: 600; color: var(--violet); background: rgba(155,93,229,0.10); border: 1px solid rgba(155,93,229,0.22); border-radius: 99px; padding: 2px 9px; cursor: pointer; }
.mm-tag:hover { background: rgba(155,93,229,0.18); }
.mm-tagbar { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; align-items: center; }
.mm-tagbar:empty { display: none; }
.mm-tagf { font-size: 0.76rem; font-weight: 600; color: var(--text-2); background: var(--surface); border: 1px solid var(--border); border-radius: 99px; padding: 4px 11px; cursor: pointer; }
.mm-tagf:hover { border-color: rgba(155,93,229,0.4); }
.mm-tagf.active { background: var(--violet); color: #fff; border-color: var(--violet); }
.mm-card-actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; }
.mm-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; font-size: 0.76rem; font-weight: 600; color: var(--text-2); text-decoration: none; transition: border-color .12s, color .12s; }
.mm-btn:hover { border-color: var(--violet); color: var(--violet); }
@@ -110,6 +118,7 @@
<option value="link">Ссылки</option>
</select>
</div>
<div class="mm-tagbar" id="mm-tags"></div>
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
</div>
</div>
@@ -172,7 +181,30 @@
}
let _mats = [];
let _cols = [];
const _filter = { col: 'all', kind: 'all', q: '' };
const _filter = { col: 'all', kind: 'all', q: '', tag: null };
/* ── Теги (хранятся строкой через запятую в m.tags) ── */
function tagsOf(m) { return String((m && m.tags) || '').split(',').map(t => t.trim()).filter(Boolean); }
function normTags(str) { const seen = new Set(), out = []; String(str || '').split(',').forEach(t => { t = t.trim().slice(0, 40); const k = t.toLowerCase(); if (t && !seen.has(k)) { seen.add(k); out.push(t); } }); return out.slice(0, 12).join(', '); }
function tagsHtml(m) {
const ts = tagsOf(m);
if (!ts.length) return '';
return `<div class="mm-tags">${ts.map(t => `<span class="mm-tag" onclick="event.stopPropagation();setTag('${esc(t).replace(/'/g, "&#39;")}')">#${esc(t)}</span>`).join('')}</div>`;
}
function allTags() {
const set = new Map();
_mats.forEach(m => tagsOf(m).forEach(t => set.set(t.toLowerCase(), t)));
return Array.from(set.values()).sort((a, b) => a.localeCompare(b, 'ru'));
}
function renderTags() {
const bar = document.getElementById('mm-tags');
if (!bar) return;
const tags = allTags();
if (!tags.length) { bar.innerHTML = ''; return; }
let html = `<span class="mm-tagf${_filter.tag ? '' : ' active'}" onclick="setTag(null)">Все теги</span>`;
html += tags.map(t => `<span class="mm-tagf${_filter.tag === t.toLowerCase() ? ' active' : ''}" onclick="setTag('${esc(t).replace(/'/g, "&#39;")}')">#${esc(t)}</span>`).join('');
bar.innerHTML = html;
}
/* ── Move-to-collection select ── */
function moveSelect(m) {
@@ -202,6 +234,7 @@
${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div>
${tagsHtml(m)}
<div class="mm-card-actions">
${mv}
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
@@ -225,6 +258,7 @@
${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div>
${tagsHtml(m)}
<div class="mm-card-actions">
${mv}
<a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
@@ -304,6 +338,7 @@
if (_filter.col === 'none' && m.collection_id) return false;
if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false;
if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false;
if (_filter.tag && !tagsOf(m).some(t => t.toLowerCase() === _filter.tag)) return false;
if (_filter.q) {
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
if (!hay.includes(_filter.q)) return false;
@@ -326,6 +361,7 @@
grid.innerHTML = rows.length
? rows.map(card).join('')
: `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
renderTags();
lucide.createIcons();
}
@@ -345,7 +381,8 @@
function setCol(key) { _filter.col = key; renderCols(); renderGrid(); }
function onKind(v) { _filter.kind = v; renderGrid(); }
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); }
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch;
function setTag(t) { const lt = t ? String(t).toLowerCase() : null; _filter.tag = (_filter.tag === lt) ? null : lt; renderGrid(); }
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch; window.setTag = setTag;
/* ── Material actions ── */
async function moveMaterial(id, cid) {
@@ -366,6 +403,7 @@
const content = `<div style="display:flex;flex-direction:column;gap:10px">
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки…" style="${FLD};resize:vertical"></textarea>
<input id="mm-nt-tags" placeholder="Теги через запятую (необязательно)" style="${FLD}" />
</div>`;
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
@@ -374,7 +412,8 @@
const text = m.body.querySelector('#mm-nt-body').value.trim();
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null;
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col }); m.close(); load(); }
const tags = normTags(m.body.querySelector('#mm-nt-tags').value);
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col, tags }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
@@ -388,12 +427,14 @@
const content = `<div style="display:flex;flex-direction:column;gap:10px">
<input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" />
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical">${esc(mt.body || '')}</textarea>` : ''}
<input id="mm-ed-tags" value="${esc(tagsOf(mt).join(', '))}" placeholder="Теги через запятую (напр. алгебра, формулы)" style="${FLD}" />
</div>`;
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Сохранить', primary: true, onClick: async () => {
const data = { title: m.body.querySelector('#mm-ed-title').value.trim() };
if (isNote) data.body = m.body.querySelector('#mm-ed-body').value;
data.tags = normTags(m.body.querySelector('#mm-ed-tags').value);
try { await LS.updateMaterial(id, data); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
+1 -1
View File
@@ -792,7 +792,7 @@ const XP_MAP = { CR: 50, EN: 40, VU: 30, NT: 20, LC: 10 };
async function init() {
lucide.createIcons();
const feats = await LS.loadFeatures().catch(() => ({}));
if (feats.red_book === false) { window.location.replace('/403'); return; }
if (feats.red_book === false && LS.getUser()?.role !== 'admin') { window.location.replace('/403'); return; }
LS.hideDisabledFeatures?.();
// Auth (sidebar)
+2 -1
View File
@@ -196,7 +196,8 @@
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
// Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
if (LS.loadFeatures) {
// Админ имеет доступ всегда (он управляет модулями) — для него гейт не срабатывает.
if (LS.loadFeatures && !ip.isAdmin) {
LS.loadFeatures().then(function (feats) {
if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; }
}).catch(function () {});
+9
View File
@@ -479,6 +479,15 @@
/* Фаза 5: открыть связанную симуляцию из карточки учебника (не уходя в учебник). */
function openLabSim(simId, ev) {
if (ev) ev.stopPropagation();
// Страховка: если «Лаборатория» отключена — не открываем (кнопка и так скрыта
// kill-switch'ем). Админ имеет доступ всегда (admin-override).
try {
const u = LS.getUser && LS.getUser();
if (!(u && u.role === 'admin')) {
const f = JSON.parse(localStorage.getItem('ls_feat_cache') || 'null');
if (f && f.lab === false) { if (LS.toast) LS.toast('Лаборатория отключена', 'warn'); return; }
}
} catch (e) { /* нет кэша — открываем как раньше */ }
location.href = '/lab?sim=' + encodeURIComponent(simId);
}
window.openLabSim = openLabSim;
+386
View File
@@ -0,0 +1,386 @@
<!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="/css/ls.css" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.sb-content { background: #f4f5f8; }
.container { max-width: 860px; margin: 0 auto; padding: 26px 32px 100px; }
/* ── hero ── */
.wq-hero { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.wq-hero-icon { width: 46px; height: 46px; border-radius: 14px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #9B5DE5, #06B6D4); color: #fff; box-shadow: 0 6px 18px rgba(155,93,229,0.3); }
.wq-hero-txt { flex: 1; min-width: 200px; }
.page-title { font-family: 'Unbounded', sans-serif; font-size: 1.18rem; font-weight: 800; color: #0F172A; margin-bottom: 4px; }
.page-sub { font-size: 0.82rem; color: var(--text-3); line-height: 1.5; }
.wq-new-btn { display: inline-flex; align-items: center; gap: 7px; padding: 10px 18px; border-radius: 12px; border: none;
background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.86rem; font-weight: 700; cursor: pointer;
transition: transform .15s, box-shadow .15s; box-shadow: 0 4px 14px rgba(155,93,229,0.28); white-space: nowrap; }
.wq-new-btn:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(155,93,229,0.34); }
.wq-new-btn.open { background: #fff; color: var(--text-2); border: 1.5px solid rgba(15,23,42,0.12); box-shadow: none; }
/* ── submit form (collapsible) ── */
.wq-form { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 18px; padding: 18px 20px; margin-bottom: 22px;
overflow: hidden; max-height: 600px; transition: max-height .3s ease, opacity .25s, padding .25s, margin .25s; }
.wq-form.collapsed { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; margin-bottom: 0; border-width: 0; }
.wq-flabel { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: .03em; margin-bottom: 7px; }
.wq-cat-pick { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 14px; }
.wq-cat-opt { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 999px; cursor: pointer;
border: 1.5px solid rgba(15,23,42,0.1); background: #fff; font-size: 0.78rem; font-weight: 600; color: var(--text-2); transition: all .15s; }
.wq-cat-opt:hover { border-color: var(--cc); }
.wq-cat-opt.sel { border-color: var(--cc); background: color-mix(in srgb, var(--cc) 10%, #fff); color: var(--cc); }
.wq-cat-opt i { width: 14px; height: 14px; }
.wq-inp, .wq-area { width: 100%; padding: 11px 13px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 12px;
font-family: 'Manrope', sans-serif; font-size: 0.9rem; color: #0F172A; outline: none; transition: border-color .15s; }
.wq-inp:focus, .wq-area:focus { border-color: var(--violet); }
.wq-area { min-height: 74px; resize: vertical; margin-top: 10px; }
.wq-form-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; gap: 10px; }
.wq-counter { font-size: 0.72rem; color: var(--text-3); }
/* ── stat / status filter pills ── */
.wq-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
.wq-stat { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; border-radius: 13px; cursor: pointer;
background: #fff; border: 1.5px solid rgba(15,23,42,0.07); transition: all .15s; }
.wq-stat:hover { border-color: var(--sc, #9B5DE5); }
.wq-stat.active { border-color: var(--sc, #9B5DE5); background: color-mix(in srgb, var(--sc, #9B5DE5) 9%, #fff); }
.wq-stat-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--sc, #9B5DE5); flex-shrink: 0; }
.wq-stat-lbl { font-size: 0.78rem; font-weight: 600; color: var(--text-2); }
.wq-stat-num { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: #0F172A; }
/* ── sub-bar: category filter + search ── */
.wq-subbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.wq-cats { display: flex; gap: 6px; flex-wrap: wrap; }
.wq-cchip { display: inline-flex; align-items: center; gap: 5px; padding: 5px 11px; border-radius: 999px; cursor: pointer;
border: 1.5px solid rgba(15,23,42,0.1); background: transparent; font-size: 0.73rem; font-weight: 600; color: var(--text-3); transition: all .15s; }
.wq-cchip:hover { border-color: var(--cc); color: var(--cc); }
.wq-cchip.active { border-color: var(--cc); background: color-mix(in srgb, var(--cc) 10%, #fff); color: var(--cc); }
.wq-cchip i { width: 12px; height: 12px; }
.wq-search { margin-left: auto; min-width: 180px; flex: 1; max-width: 280px; padding: 8px 13px; border: 1.5px solid rgba(15,23,42,0.1);
border-radius: 11px; font-family: 'Manrope', sans-serif; font-size: 0.82rem; outline: none; transition: border-color .15s; }
.wq-search:focus { border-color: var(--violet); }
/* ── wish cards ── */
.w-list { display: flex; flex-direction: column; gap: 12px; }
.w-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 15px 17px;
display: flex; gap: 13px; transition: box-shadow .15s, transform .15s; animation: wqIn .25s ease both; }
.w-card:hover { box-shadow: 0 4px 16px rgba(15,23,42,0.07); }
@keyframes wqIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.w-cat-ic { width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
background: color-mix(in srgb, var(--cc) 13%, #fff); color: var(--cc); }
.w-cat-ic i { width: 19px; height: 19px; }
.w-main { flex: 1; min-width: 0; }
.w-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin-bottom: 3px; }
.w-title { font-size: 0.93rem; font-weight: 700; color: #0F172A; flex: 1; min-width: 0; }
.w-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 0.68rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
.w-badge i { width: 11px; height: 11px; }
.wb-new { background: rgba(6,182,212,0.12); color: #06aab3; }
.wb-planned { background: rgba(155,93,229,0.12); color: #9B5DE5; }
.wb-in_progress{ background: rgba(245,158,11,0.15); color: #d97706; }
.wb-done { background: rgba(5,150,82,0.13); color: #059652; }
.wb-declined { background: rgba(15,23,42,0.07); color: #64748B; }
.w-meta { font-size: 0.72rem; color: var(--text-3); display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
.w-author { font-weight: 700; color: var(--violet); }
.w-body { font-size: 0.84rem; color: #3D4F6B; line-height: 1.55; margin-top: 6px; white-space: pre-wrap; word-break: break-word; }
.w-note { font-size: 0.8rem; color: #0F172A; background: rgba(155,93,229,0.06); border: 1px solid rgba(155,93,229,0.18);
border-radius: 11px; padding: 9px 12px; margin-top: 10px; line-height: 1.5; display: flex; gap: 8px; }
.w-note i { width: 14px; height: 14px; color: var(--violet); flex-shrink: 0; margin-top: 2px; }
/* admin manage */
.w-manage { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px dashed rgba(15,23,42,0.1); }
.w-sel { padding: 8px 11px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px; font-family: 'Manrope', sans-serif;
font-size: 0.8rem; color: #0F172A; cursor: pointer; outline: none; min-width: 150px; }
.w-sel:focus { border-color: var(--violet); }
.w-note-inp { flex: 1; min-width: 200px; padding: 8px 11px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.8rem; outline: none; resize: vertical; min-height: 38px; }
.w-note-inp:focus { border-color: var(--violet); }
.w-btn { display: inline-flex; align-items: center; gap: 5px; padding: 8px 14px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.12);
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; color: var(--text-2); cursor: pointer; transition: all .15s; }
.w-btn:hover { border-color: var(--violet); color: var(--violet); }
.w-btn-primary { background: var(--grad-1); color: #fff; border-color: transparent; }
.w-btn-primary:hover { opacity: .9; color: #fff; }
.w-btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.w-btn-icon { padding: 8px; color: var(--text-3); }
.w-btn-icon:hover { background: rgba(239,71,111,0.08); color: #EF476F; border-color: rgba(239,71,111,0.25); }
/* empty / skeleton */
.w-empty { text-align: center; padding: 54px 20px; color: var(--text-3); }
.w-empty-art { width: 80px; height: 80px; margin: 0 auto 14px; border-radius: 22px; display: flex; align-items: center; justify-content: center;
background: rgba(155,93,229,0.08); color: var(--violet); }
.w-empty-art i { width: 38px; height: 38px; }
.w-empty-t { font-size: 0.92rem; font-weight: 700; color: var(--text-2); margin-bottom: 4px; }
.w-empty-s { font-size: 0.8rem; }
.w-skel { height: 78px; border-radius: 16px; background: linear-gradient(90deg,#eef0f4 25%,#f6f7f9 50%,#eef0f4 75%); background-size: 200% 100%; animation: wqShim 1.3s infinite; }
@keyframes wqShim { to { background-position: -200% 0; } }
@media (max-width: 600px) {
.container { padding: 16px 14px 80px; }
.wq-new-btn { width: 100%; justify-content: center; }
.wq-search { margin-left: 0; max-width: none; }
.w-card { padding: 13px; gap: 10px; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<div class="container">
<div class="wq-hero">
<div class="wq-hero-icon"><i data-lucide="lightbulb" style="width:24px;height:24px"></i></div>
<div class="wq-hero-txt">
<div class="page-title">Пожелания по улучшению</div>
<div class="page-sub" id="w-sub">Есть идея, как сделать систему лучше? Расскажите — мы прочитаем и ответим.</div>
</div>
<button class="wq-new-btn" id="wq-new-btn" onclick="toggleForm()">
<span id="wq-new-ic"><i data-lucide="plus" style="width:15px;height:15px"></i></span> <span id="wq-new-lbl">Поделиться идеей</span>
</button>
</div>
<!-- Submit form -->
<div class="wq-form collapsed" id="wq-form">
<div class="wq-flabel">Категория</div>
<div class="wq-cat-pick" id="wq-cat-pick"></div>
<input class="wq-inp" id="wf-title" maxlength="200" placeholder="Кратко: что улучшить?" oninput="updCounter()" />
<textarea class="wq-area" id="wf-body" maxlength="4000" placeholder="Подробнее (необязательно): как должно работать, зачем это нужно…"></textarea>
<div class="wq-form-foot">
<span class="wq-counter" id="wf-counter">0 / 200</span>
<button class="w-btn w-btn-primary" id="wf-submit" onclick="submitWish()">
<i data-lucide="send" style="width:14px;height:14px"></i> Отправить
</button>
</div>
</div>
<div class="wq-stats" id="wq-stats"></div>
<div class="wq-subbar" id="wq-subbar" style="display:none">
<div class="wq-cats" id="wq-cats"></div>
<input class="wq-search" id="wq-search" placeholder="Поиск по пожеланиям…" oninput="onSearch(this.value)" />
</div>
<div class="w-list" id="w-list">
<div class="w-skel"></div><div class="w-skel"></div><div class="w-skel"></div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script>
const { user, isAdmin } = LS.initPage();
if (!user) throw new Error('Not logged in');
LS.showBoardIfAllowed();
LS.notif.init();
const CAT = {
feature: { label: 'Новая функция', icon: 'sparkles', color: '#9B5DE5' },
ui: { label: 'Интерфейс', icon: 'layout-panel-top', color: '#06B6D4' },
content: { label: 'Контент', icon: 'book-open', color: '#2563EB' },
bug: { label: 'Баг / ошибка', icon: 'bug', color: '#EF476F' },
other: { label: 'Другое', icon: 'message-circle', color: '#64748B' },
};
const CAT_ORDER = ['feature', 'ui', 'content', 'bug', 'other'];
const ST = {
new: { label: 'Новое', icon: 'sparkle', color: '#06aab3' },
planned: { label: 'Запланировано', icon: 'calendar-clock', color: '#9B5DE5' },
in_progress: { label: 'В работе', icon: 'loader', color: '#d97706' },
done: { label: 'Готово', icon: 'check-circle-2', color: '#059652' },
declined: { label: 'Отклонено', icon: 'x-circle', color: '#64748B' },
};
const ST_ORDER = ['new', 'planned', 'in_progress', 'done', 'declined'];
let _wishes = [], _statusFilter = null, _catFilter = null, _q = '', _formCat = 'feature', _formOpen = false;
function fmtDate(s) {
if (!s) return '';
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z');
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' });
}
function icons() { if (window.lucide) lucide.createIcons(); }
/* ── form ── */
function renderCatPick() {
document.getElementById('wq-cat-pick').innerHTML = CAT_ORDER.map(k =>
`<button type="button" class="wq-cat-opt${_formCat === k ? ' sel' : ''}" style="--cc:${CAT[k].color}" onclick="pickCat('${k}')">
<i data-lucide="${CAT[k].icon}"></i> ${CAT[k].label}</button>`).join('');
icons();
}
function pickCat(k) { _formCat = k; renderCatPick(); }
function updCounter() {
const n = document.getElementById('wf-title').value.length;
document.getElementById('wf-counter').textContent = n + ' / 200';
}
function toggleForm(forceOpen) {
_formOpen = forceOpen === undefined ? !_formOpen : forceOpen;
document.getElementById('wq-form').classList.toggle('collapsed', !_formOpen);
const btn = document.getElementById('wq-new-btn');
btn.classList.toggle('open', _formOpen);
document.getElementById('wq-new-lbl').textContent = _formOpen ? 'Свернуть' : 'Поделиться идеей';
// lucide заменяет <i> на <svg> при рендере, поэтому пере-вставляем свежий <i> в контейнер.
const ic = document.getElementById('wq-new-ic');
if (ic) ic.innerHTML = `<i data-lucide="${_formOpen ? 'chevron-up' : 'plus'}" style="width:15px;height:15px"></i>`;
icons();
if (_formOpen) setTimeout(() => document.getElementById('wf-title').focus(), 80);
}
async function submitWish() {
const title = document.getElementById('wf-title').value.trim();
if (!title) { LS.toast('Введите заголовок', 'warn'); return; }
const btn = document.getElementById('wf-submit');
btn.disabled = true;
try {
const row = await LS.wishCreate({ title, category: _formCat, body: document.getElementById('wf-body').value.trim() });
if (isAdmin && user) { row.author_name = user.name; }
_wishes.unshift(row);
document.getElementById('wf-title').value = '';
document.getElementById('wf-body').value = '';
_formCat = 'feature'; renderCatPick(); updCounter();
toggleForm(false);
LS.toast('Пожелание отправлено — спасибо!', 'success');
_statusFilter = null; _catFilter = null;
renderAll();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
finally { btn.disabled = false; }
}
/* ── load + render ── */
async function load() {
try {
const data = await LS.wishesList();
_wishes = data.wishes || [];
renderAll();
} catch (e) {
document.getElementById('w-list').innerHTML = `<div class="w-empty"><div class="w-empty-t">Не удалось загрузить</div><div class="w-empty-s">${esc(e.message || '')}</div></div>`;
}
}
function counts() {
const c = {}; ST_ORDER.forEach(s => c[s] = 0);
_wishes.forEach(w => { c[w.status] = (c[w.status] || 0) + 1; });
return c;
}
function renderAll() { renderStats(); renderSubbar(); renderList(); }
function renderStats() {
const c = counts();
const total = _wishes.length;
let html = `<button class="wq-stat${!_statusFilter ? ' active' : ''}" style="--sc:#9B5DE5" onclick="setStatus(null)">
<span class="wq-stat-lbl">Все</span><span class="wq-stat-num">${total}</span></button>`;
html += ST_ORDER.filter(s => c[s] > 0).map(s =>
`<button class="wq-stat${_statusFilter === s ? ' active' : ''}" style="--sc:${ST[s].color}" onclick="setStatus('${s}')">
<span class="wq-stat-dot"></span><span class="wq-stat-lbl">${ST[s].label}</span><span class="wq-stat-num">${c[s]}</span></button>`).join('');
document.getElementById('wq-stats').innerHTML = html;
}
function renderSubbar() {
const cats = [...new Set(_wishes.map(w => w.category))];
const bar = document.getElementById('wq-subbar');
// показываем подбар только если есть смысл (несколько категорий или много пожеланий)
if (cats.length < 2 && _wishes.length < 4) { bar.style.display = 'none'; return; }
bar.style.display = '';
document.getElementById('wq-cats').innerHTML = CAT_ORDER.filter(k => cats.includes(k)).map(k =>
`<button class="wq-cchip${_catFilter === k ? ' active' : ''}" style="--cc:${CAT[k].color}" onclick="setCat('${k}')">
<i data-lucide="${CAT[k].icon}"></i> ${CAT[k].label}</button>`).join('');
document.getElementById('wq-search').style.display = _wishes.length >= 4 ? '' : 'none';
icons();
}
function setStatus(s) { _statusFilter = (_statusFilter === s) ? null : s; renderAll(); }
function setCat(k) { _catFilter = (_catFilter === k) ? null : k; renderAll(); }
function onSearch(v) { _q = v.trim().toLowerCase(); renderList(); }
function renderList() {
const el = document.getElementById('w-list');
let list = _wishes;
if (_statusFilter) list = list.filter(w => w.status === _statusFilter);
if (_catFilter) list = list.filter(w => w.category === _catFilter);
if (_q) list = list.filter(w =>
(w.title || '').toLowerCase().includes(_q) ||
(w.body || '').toLowerCase().includes(_q) ||
(w.author_name || '').toLowerCase().includes(_q));
if (!list.length) {
const fresh = !_wishes.length;
el.innerHTML = `<div class="w-empty">
<div class="w-empty-art"><i data-lucide="${fresh ? 'lightbulb' : 'search-x'}"></i></div>
<div class="w-empty-t">${fresh ? (isAdmin ? 'Пожеланий пока нет' : 'У вас пока нет пожеланий') : 'Ничего не найдено'}</div>
<div class="w-empty-s">${fresh ? (isAdmin ? 'Они появятся здесь, когда пользователи их оставят.' : 'Поделитесь идеей — нажмите «Поделиться идеей» выше.') : 'Попробуйте изменить фильтр или запрос.'}</div>
</div>`;
icons();
return;
}
el.innerHTML = list.map(cardHtml).join('');
icons();
}
function cardHtml(w) {
const cat = CAT[w.category] || CAT.other;
const st = ST[w.status] || ST.new;
const author = (isAdmin && w.author_name) ? `<span class="w-author">${esc(w.author_name)}</span><span>·</span>` : '';
const note = w.admin_note ? `<div class="w-note"><i data-lucide="message-square-reply"></i><div><b>Ответ:</b> ${esc(w.admin_note)}</div></div>` : '';
let manage = '';
if (isAdmin) {
const opts = ST_ORDER.map(s => `<option value="${s}"${w.status === s ? ' selected' : ''}>${ST[s].label}</option>`).join('');
manage = `<div class="w-manage">
<select class="w-sel" id="st-${w.id}">${opts}</select>
<textarea class="w-note-inp" id="note-${w.id}" placeholder="Ответ автору (необязательно)…">${esc(w.admin_note || '')}</textarea>
<button class="w-btn w-btn-primary" onclick="saveWish(${w.id})"><i data-lucide="check" style="width:13px;height:13px"></i> Сохранить</button>
<button class="w-btn w-btn-icon" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>
</div>`;
} else if (w.status === 'new') {
manage = `<div class="w-manage"><button class="w-btn w-btn-icon" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px"></i> Удалить</button></div>`;
}
return `<div class="w-card" style="--cc:${cat.color}">
<div class="w-cat-ic"><i data-lucide="${cat.icon}"></i></div>
<div class="w-main">
<div class="w-head">
<span class="w-title">${esc(w.title)}</span>
<span class="w-badge wb-${w.status}"><i data-lucide="${st.icon}"></i>${st.label}</span>
</div>
<div class="w-meta">${author}<span>${cat.label}</span><span>·</span><span>${fmtDate(w.created_at)}</span></div>
${w.body ? `<div class="w-body">${esc(w.body)}</div>` : ''}
${note}
${manage}
</div>
</div>`;
}
async function saveWish(id) {
try {
const upd = await LS.wishUpdate(id, {
status: document.getElementById('st-' + id).value,
admin_note: document.getElementById('note-' + id).value.trim(),
});
const i = _wishes.findIndex(w => w.id === id);
if (i >= 0) { _wishes[i] = { ..._wishes[i], ...upd }; }
LS.toast('Сохранено', 'success');
renderAll();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function delWish(id) {
if (!await LS.confirm('Удалить это пожелание?', { title: 'Удаление', confirmText: 'Удалить', danger: true })) return;
try {
await LS.wishDelete(id);
_wishes = _wishes.filter(w => w.id !== id);
renderAll();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
renderCatPick();
load();
icons();
</script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>
+247 -31
View File
@@ -190,6 +190,12 @@ async function deleteClass(id) { return req('DELETE', `/classes/
async function kickMember(classId, userId) { return req('DELETE', `/classes/${classId}/members/${userId}`); }
async function regenerateInviteCode(classId) { return req('POST', `/classes/${classId}/new-code`); }
async function classJournal(classId) { return req('GET', `/classes/${classId}/journal`); }
async function classOutstanding(classId) { return req('GET', `/classes/${classId}/outstanding`); }
/* ── Пожелания по улучшению ── */
async function wishesList(params = {}) { const q = new URLSearchParams(params).toString(); return req('GET', '/wishes' + (q ? '?' + q : '')); }
async function wishCreate(data) { return req('POST', '/wishes', data); }
async function wishUpdate(id, data) { return req('PATCH', `/wishes/${id}`, data); }
async function wishDelete(id) { return req('DELETE', `/wishes/${id}`); }
async function createAssignment(classId, data) { return req('POST', `/classes/${classId}/assignments`, data); }
async function createDirectAssignment(data) { return req('POST', '/assignments', data); }
async function updateAssignment(id, data) { return req('PUT', `/assignments/${id}`, data); }
@@ -826,10 +832,129 @@ async function loadFeatures() {
_featuresCache = await apiFetch('/api/features');
} catch { _featuresCache = {}; }
_gamificationEnabled = _featuresCache.gamification !== false;
try { localStorage.setItem('ls_feat_cache', JSON.stringify(_featuresCache)); } catch {}
_applyFeatureCss(_featuresCache); // авторитетное скрытие по свежим данным
return _featuresCache;
}
function clearFeaturesCache() { _featuresCache = null; _gamificationEnabled = null; }
/* Карта «фича → href пунктов меню» (скрытие из сайдбара + редирект со страницы). */
const FEATURE_HREFS = {
hangman: ['/hangman'],
crossword: ['/crossword'],
pet: ['/pet'],
red_book: ['/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html'],
collection: ['/collection.html', '/collection'],
lab: ['/lab'],
knowledge_map: ['/knowledge-map'],
flashcards: ['/flashcards'],
board: ['/board'],
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
live_quiz: ['/live-quiz'],
classroom: ['/classroom'],
sim_builder: ['/sim-builder', '/sim-builder.html'],
exam9: ['/exam9', '/exam9.html'],
textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
quantik: ['/quantik', '/quantik.html'],
theory: ['/theory', '/theory.html'],
sitemap: ['/sitemap', '/sitemap.html'],
wishes: ['/wishes', '/wishes.html'],
};
/* Контейнеры виджетов-модулей (дашборд и т.п.) прячем блок целиком, а не только
ссылку, иначе остаётся пустой блок (напр. виджет флеш-карт #w-flashcard).
Hero-карточки дашборда: у lab JS меняет href на /lab?sim= [href="/lab"] не
матчит, поэтому прячем по СТАБИЛЬНОМУ id #hc-lab (аналогично pet/чтение). */
const FEATURE_WIDGETS = {
flashcards: ['#w-flashcard'],
// #hc-lab — hero-карточка дашборда; .tb-lab-btn — кнопка «открыть связанную
// симуляцию» на карточках каталога учебников (openLabSim → /lab?sim=…). Это
// <button onclick>, а не <a href="/lab">, поэтому [href="/lab"] её не ловит.
lab: ['#hc-lab', '.tb-lab-btn'],
pet: ['#hc-pet'],
textbooks: ['#hc-read'],
};
/* Админ видит и имеет доступ ко ВСЕМУ, даже к отключённым модулям (он ими управляет).
Поэтому для админа никакие скрытия/редиректы фич не применяются. getUser() читает
localStorage синхронно (определён в начале файла) работает и на ранней sync-инъекции. */
function _isAdminUser() {
try { return getUser()?.role === 'admin'; } catch { return false; }
}
/* Инъекция CSS, прячущего отключённые фичи. Ставится синхронно из localStorage-кэша
на ранней загрузке (ДО построения сайдбара/виджетов) против мигания (FOUC),
затем обновляется по свежему /api/features. */
function _applyFeatureCss(feats) {
// Админ — без скрытий: чистим <style> и снимаем kill-switch геймификации.
if (_isAdminUser()) {
const elA = document.getElementById('ls-feat-hide');
if (elA) elA.textContent = '';
document.documentElement.classList.remove('no-gamification');
return;
}
const sels = [];
if (feats) {
for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) {
if (feats[key] === false) {
hrefs.forEach(h => sels.push(`[href="${h}"]`));
(FEATURE_WIDGETS[key] || []).forEach(s => sels.push(s));
}
}
}
// Скрытые exam-prep треки (подготовка): кэш хрефов с прошлой загрузки — против мигания.
// /api/exam-prep/tracks асинхронен, поэтому держим точный список скрытых ссылок в кэше.
try {
JSON.parse(localStorage.getItem('ls_examhide') || '[]')
.forEach(h => sels.push(`[href="${h}"]`));
} catch { /* пусто */ }
let css = sels.length ? sels.join(',') + '{display:none !important}' : '';
// Геймификация: дублируем kill-switch в инъекцию — для страниц БЕЗ ls.css.
// Учебники (frontend/textbooks/*.html) грузят api.js, но НЕ ls.css, поэтому правила
// .no-gamification из ls.css туда не доходят, и встроенная XP-механика (data-gamified,
// #ach-popup) оставалась видимой. Инъекция работает на любой странице с api.js.
if (feats && feats.gamification === false) {
css += '.no-gamification [data-gamified],.no-gamification #ach-popup{display:none!important}';
}
let el = document.getElementById('ls-feat-hide');
if (!el) {
el = document.createElement('style');
el.id = 'ls-feat-hide';
(document.head || document.documentElement).appendChild(el);
}
el.textContent = css;
// Геймификация: класс на <html> (доступен раньше body) → kill-switch без мигания.
if (feats) document.documentElement.classList.toggle('no-gamification', feats.gamification === false);
}
/* Ранняя синхронная попытка из кэша прошлой загрузки нет мигания на повторных заходах.
(FEATURE_HREFS const, поэтому этот вызов идёт ПОСЛЕ его объявления.) */
try {
const _cachedFeats = JSON.parse(localStorage.getItem('ls_feat_cache') || 'null');
_applyFeatureCss(_cachedFeats); // применит и кэш фич, и кэш скрытых exam-prep ссылок
} catch { /* нет кэша / приватный режим — просто ждём async */ }
/* Авторитетно подтянуть фичи на страницах БЕЗ сайдбара (учебники, embed): там
sidebar.js/hideDisabledFeatures не вызывают loadFeatures, и кэш мог устареть.
loadFeatures() кэширует in-memory (дубль-вызов = один fetch) и сам зовёт _applyFeatureCss.
Только для залогиненных иначе на /login apiFetch поймает 401 и зациклит редирект. */
try {
if (isLoggedIn()) { loadFeatures().catch(() => {}); }
} catch { /* defensive */ }
/* Прячет группы сайдбара (.sb-group), у которых не осталось ни одного видимого пункта,
чтобы не висел пустой заголовок-аккордеон (напр. «Практика и игры», когда все
модули отключены). Зовётся после построения сайдбара и после hideDisabledFeatures. */
function hideEmptySidebarGroups() {
document.querySelectorAll('.sb-group').forEach(g => {
const body = g.querySelector('.sb-group-body');
if (!body) return;
let anyVisible = false;
body.querySelectorAll('.sb-link').forEach(it => {
const cs = getComputedStyle(it);
if (cs.display !== 'none' && cs.visibility !== 'hidden') anyVisible = true;
});
g.style.display = anyVisible ? '' : 'none';
});
}
/**
* Show board sidebar link only for teachers/admins and students in a class.
* Call after LS.initPage(). Uses features cache (_no_class flag).
@@ -839,38 +964,25 @@ async function showBoardIfAllowed() {
if (!el) return;
const user = getUser();
if (!user) return;
if (user.role === 'teacher' || user.role === 'admin') { el.style.display = ''; return; }
// Student: check if in a class
// Админ видит доску всегда (даже если фича отключена) — он ею управляет.
if (user.role === 'admin') { el.style.display = ''; return; }
const feats = await loadFeatures();
// Фича выключена (глобально или для класса) → доску не показываем, даже учителю.
// Эта функция зовётся напрямую на многих страницах, поэтому проверка ОБЯЗАТЕЛЬНА,
// иначе она перекрывает скрытие из hideDisabledFeatures().
if (feats.board === false) { el.style.display = 'none'; return; }
if (user.role === 'teacher') { el.style.display = ''; return; }
// Student: check if in a class
if (!feats._no_class) el.style.display = '';
}
async function hideDisabledFeatures() {
const feats = await loadFeatures();
const map = {
hangman: ['/hangman'],
crossword: ['/crossword'],
pet: ['/pet'],
red_book: ['/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html'],
collection: ['/collection.html', '/collection'],
lab: ['/lab'],
knowledge_map: ['/knowledge-map'],
flashcards: ['/flashcards'],
board: ['/board'],
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
live_quiz: ['/live-quiz'],
classroom: ['/classroom'],
sim_builder: ['/sim-builder', '/sim-builder.html'],
exam9: ['/exam9', '/exam9.html'],
textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
quantik: ['/quantik', '/quantik.html'],
};
for (const [key, hrefs] of Object.entries(map)) {
const feats = await loadFeatures(); // loadFeatures уже вызвал _applyFeatureCss (визуальное скрытие)
// Админ видит и открывает всё — никаких скрытий, редиректов и схлопывания групп.
if (_isAdminUser()) return;
// Редирект со страницы отключённой фичи (CSS прячет ссылки, а тут уводим со страницы).
for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) {
if (feats[key] === false) {
hrefs.forEach(href => {
document.querySelectorAll(`[href="${href}"]`).forEach(el => el.style.display = 'none');
});
// Redirect away if currently on a disabled page
const cur = window.location.pathname;
if (hrefs.some(h => cur === h || cur === h.replace('.html', ''))) {
window.location.href = '/dashboard.html';
@@ -878,7 +990,7 @@ async function hideDisabledFeatures() {
}
}
if (feats.gamification === false) {
document.body.classList.add('no-gamification');
document.body.classList.add('no-gamification'); // дубль на body (html-класс ставит _applyFeatureCss)
// If student is already viewing achievements or shop tab, redirect to account tab
const active = document.querySelector('#tab-achievements.active, #tab-shop.active');
if (active) {
@@ -894,10 +1006,16 @@ async function hideDisabledFeatures() {
try {
const data = await apiFetch('/api/exam-prep/tracks');
const allowed = new Set((data.tracks || []).map(t => t.exam_key));
// Собираем точные хрефы скрытых треков и кэшируем — чтобы на СЛЕДУЮЩЕЙ загрузке
// _applyFeatureCss спрятал их синхронно из кэша ещё до сборки сайдбара (без мигания).
const hide = [];
examLinks.forEach(el => {
const m = (el.getAttribute('href') || '').match(/^\/exam-prep\/([^/?#]+)/);
if (m && !allowed.has(m[1])) el.style.display = 'none';
const href = el.getAttribute('href') || '';
const m = href.match(/^\/exam-prep\/([^/?#]+)/);
if (m && !allowed.has(m[1])) hide.push(href);
});
try { localStorage.setItem('ls_examhide', JSON.stringify(hide)); } catch {}
_applyFeatureCss(_featuresCache); // обновить <style> (скрыть запрещённые, ПОКАЗАТЬ снова разрешённые)
const cur = window.location.pathname.match(/^\/exam-prep\/([^/?#]+)/);
if (cur && !allowed.has(cur[1])) window.location.href = '/dashboard.html';
} catch { /* сеть/доступ недоступны — ссылки оставляем как есть */ }
@@ -928,6 +1046,9 @@ async function hideDisabledFeatures() {
document.body.classList.add('no-class');
document.body.classList.add('no-gamification'); // no class <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> no gamification
}
// В самом конце — после всех скрытий (фичи, exam-prep, no_class) — схлопнуть пустые группы.
hideEmptySidebarGroups();
}
/* ── generic authenticated fetch (full path like /api/courses) ─────── */
@@ -1029,7 +1150,8 @@ window.LS = {
adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminDeleteSession, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions,
getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment,
regenerateInviteCode, classJournal,
regenerateInviteCode, classJournal, classOutstanding,
wishesList, wishCreate, wishUpdate, wishDelete,
joinClass, myClasses, getStudents, classFeed,
getAnnouncements, createAnnouncement, deleteAnnouncement,
getNotifications, markNotifRead, markAllNotifsRead, connectSSE,
@@ -1061,9 +1183,10 @@ window.LS = {
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
gameProgressList, gameProgressSubmit,
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
adminAssistantScan, adminAssistantProbe, adminAssistantApplyModels, adminAssistantHealth,
fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview,
prepListTracks, prepMyTracks, prepStudentTracks, prepSetStudent, prepUnsetStudent, prepClassStatus, prepSetClass,
escapeHtml, esc,
@@ -1089,6 +1212,7 @@ window.LS = {
loadFeatures,
clearFeaturesCache,
hideDisabledFeatures,
hideEmptySidebarGroups,
showBoardIfAllowed,
biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate, biochemAnalyze,
biochemGetReactions, biochemGetChallenges, biochemSolveChallenge,
@@ -1299,7 +1423,43 @@ async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', {
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
async function assistantSettings(d) { return req('PATCH', '/assistant/settings', d); }
async function assistantAsk(q, context, history, mode) { return req('POST', '/assistant/ask', { q, context: context || undefined, history: history || undefined, mode: mode || undefined }); }
// Стриминговый ask: SSE поверх POST (fetch-stream). cbs: { onMeta, onDelta, onDone }.
async function assistantAskStream(q, context, history, mode, cbs) {
cbs = cbs || {};
const token = getToken();
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(API + '/assistant/ask/stream', {
method: 'POST', headers,
body: JSON.stringify({ q, context: context || undefined, history: history || undefined, mode: mode || undefined }),
});
if (!res.ok || !res.body) throw Object.assign(new Error('stream failed'), { status: res.status });
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
const handle = (block) => {
let ev = 'message', data = '';
block.split('\n').forEach((ln) => {
if (ln.indexOf('event:') === 0) ev = ln.slice(6).trim();
else if (ln.indexOf('data:') === 0) data += ln.slice(5).trim();
});
if (!data) return;
let obj; try { obj = JSON.parse(data); } catch (e) { return; }
if (ev === 'delta' && cbs.onDelta) cbs.onDelta(obj.t || '');
else if (ev === 'meta' && cbs.onMeta) cbs.onMeta(obj);
else if (ev === 'done' && cbs.onDone) cbs.onDone(obj);
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n\n')) >= 0) { const block = buf.slice(0, idx); buf = buf.slice(idx + 2); if (block.trim()) handle(block); }
}
if (buf.trim()) handle(buf);
}
async function assistantFlashcards(text, title, count) { return req('POST', '/assistant/flashcards', { text, title, count }); }
async function assistantQuestions(text, count) { return req('POST', '/assistant/questions', { text, count }); }
async function assistantFeedback(rating, q) { return req('POST', '/assistant/feedback', { rating, q: q || undefined }); }
async function assistantMemory() { return req('GET', '/assistant/memory'); }
async function assistantMemoryClear(id) { return req('DELETE', '/assistant/memory' + (id ? '/' + id : '')); }
@@ -1313,6 +1473,10 @@ async function adminSaveProvider(d) { return req('POST', '/admin/assistant
async function adminDeleteProvider(id) { return req('DELETE', `/admin/assistant/provider/${id}`); }
async function adminSetActiveProvider(id) { return req('POST', '/admin/assistant/active', { id }); }
async function adminAssistantModels(params) { const q = new URLSearchParams(params || {}).toString(); return req('GET', '/admin/assistant/models' + (q ? '?' + q : '')); }
async function adminAssistantScan(id) { return req('POST', '/admin/assistant/scan', id ? { id } : {}); }
async function adminAssistantProbe(id, model) { return req('POST', '/admin/assistant/probe', { id, model }); }
async function adminAssistantApplyModels(models, reset) { return req('POST', '/admin/assistant/models/apply', reset ? { reset: true } : { models }); }
async function adminAssistantHealth() { return req('POST', '/admin/assistant/health', {}); }
async function fcListDecks() { return req('GET', '/flashcards/decks'); }
async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); }
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
@@ -1854,3 +2018,55 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
});
});
})();
/* Глобальный репортер клиентских ошибок
Ловит необработанные JS-ошибки и rejected-промисы в браузере пользователя
и шлёт в /api/client-errors они появляются в админ-вкладке «Ошибки».
Дедуп + лимит на загрузку страницы (не флудим), только для залогиненных. */
(function initClientErrorReporter() {
const seen = new Set();
let sent = 0; const MAX_PER_PAGE = 15;
let inFlight = false;
function send(payload) {
try {
if (!isLoggedIn()) return; // отчёты только от залогиненных
if (sent >= MAX_PER_PAGE) return; // не флудим повторами
const sig = (payload.message || '') + '|' + (payload.source || '') + ':' + (payload.line || '');
if (seen.has(sig)) return;
seen.add(sig); sent++;
if (inFlight) return;
inFlight = true;
const token = getToken();
fetch(API + '/client-errors', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, token ? { Authorization: 'Bearer ' + token } : {}),
body: JSON.stringify(payload),
keepalive: true, // долетит даже при закрытии вкладки
}).catch(function () {}).finally(function () { inFlight = false; });
} catch (e) { inFlight = false; /* репортер не должен сам падать */ }
}
window.addEventListener('error', function (e) {
// Пропускаем ошибки загрузки ресурсов (img/script) — у них нет message/error.
if (!e || (!e.message && !e.error)) return;
send({
kind: 'error',
message: e.message || (e.error && (e.error.message || String(e.error))) || 'Script error',
stack: e.error && e.error.stack ? String(e.error.stack) : null,
source: e.filename || null, line: e.lineno || null, col: e.colno || null,
url: location.pathname + location.search + location.hash,
});
});
window.addEventListener('unhandledrejection', function (e) {
const r = e && e.reason;
let msg = 'Unhandled promise rejection';
let stack = null;
if (r) {
if (typeof r === 'string') msg = r;
else { msg = r.message || (r.toString && r.toString()) || msg; stack = r.stack ? String(r.stack) : null; }
}
send({ kind: 'unhandledrejection', message: msg, stack: stack, url: location.pathname + location.search + location.hash });
});
})();
+4
View File
@@ -62,6 +62,7 @@
<button class="sb-link" onclick="typeof lsSearchOpen!=='undefined'&&lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
${L('/dashboard', 'home', 'Дашборд')}
${L('/sitemap', 'map', 'Путеводитель')}
${L('/wishes', 'lightbulb', 'Пожелания')}
${L('/teacher-guide', 'book-marked', 'Руководство', { cls: 'sb-teacher-only', hidden: !isTch })}
${G('learning', 'Учебный процесс', `
@@ -228,6 +229,9 @@
LS.showBoardIfAllowed?.();
LS.hideDisabledFeatures?.();
LS.notif?.init?.();
// Синхронно по кэш-состоянию (CSS уже инъектнут до сборки) — прячем пустые
// группы сразу, без мигания; hideDisabledFeatures повторит после свежих данных.
LS.hideEmptySidebarGroups?.();
}
// Глобальная плавающая кнопка «создать карточку» (на всех страницах с шапкой)
+49
View File
@@ -0,0 +1,49 @@
# Тригонометрическая окружность — план улучшения (тренажёр темы, без функций)
Цель: симуляция `frontend/js/labs/trigcircle.js` + панель `frontend/labs-bodies.html` (#sim-trigcircle)
покрывает всю школьную тригонометрию НА ОКРУЖНОСТИ. Графики y=f(x) («функции») — вне темы:
существующий showGraph оставляем опциональным/скрываемым.
Архитектура: рукописный canvas-sim (класс TrigCircleSim) + HTML-панель в labs-bodies.html +
glue-функции (`_openTrigCircle`, `trigToggle`, `trigGoTo`, `trigReset`, `_trigUpdateUI`) внизу
trigcircle.js; регистрация в `_register-all.js` (`trigcircle`). KaTeX, LabFX, _tasks.js доступны.
⛔ без eval, без эмодзи (inline SVG .ic), всё аддитивно (не ломать текущий режим).
## Уже есть
Окружность, перетаскиваемая точка, угол °/рад (метки π/6…), sin/cos/tan/cot отрезками (слои),
треугольник sin-cos, касательная/котангенс, 16 табличных углов + snap, подсветка четверти,
значения дробями (½,√2/2,√3/2,√3/3,√3), stat-bar, опциональный график (= «функции»).
## Статус (на 2026-06-24) — ВСЕ ОСНОВНЫЕ ФАЗЫ ГОТОВЫ
- ✅ Ф1 (углы: ввод, котерминальность, 16 углов, опорный угол, знаки) — d395e10
- ✅ Ф2 (точные значения + формулы приведения для текущего угла) — 5eed248
- ✅ KaTeX: формулы — cefb5e0; ВСЯ панель (значения, угол, таблица) — 244df71
- ✅ Ф3 (знаки по четвертям): _quadSigns на canvas (текущая четверть подсвечена) +
панельная строка знаков (Ф1). Доп. работы не потребовалось.
- ✅ Ф4 (таблица значений 0–90° на KaTeX, подсветка опорного угла) — fe6df8f
- ✅ Ф5 (чётность −α: зеркальная точка + sin/cos/tg(−α) + периоды) — 48158ea;
формулы приведения — Ф2.
- ✅ Ф6 (простейшие уравнения fn(x)=a: все решения на круге + общая формула KaTeX) — dfa0535
- Уже было на canvas до плана: Пифагор (_pythBar), отрезки sin/cos/tg/ctg, координаты
точки, табличные точки+snap, опц. график функций.
- Осталось (опционально): режимы-вкладки + задания (_tasks.js); sec/csc; два угла (Ф7).
## Фазы
- **Ф1 — Углы и обзор**: тумблер скрыть график (фокус на круге); ввод угла (° и π-доли);
полная сетка табличных кнопок (16); опорный (острый) угол; знаки по четвертям в выводе;
подсказка котерминальности (+360°k / +2πk).
- **Ф2 — Определения / 6 функций**: подписи sin=y, cos=x на осях; слой sec/csc; Пифагор
sin²+cos²=1 (гипотенуза=1) с формулой; тумблер «формула значения» (KaTeX).
- **Ф3 — Знаки**: режим со знаками +/− sin/cos/tg по четвертям, мнемоника, таблица.
- **Ф4 — Особые углы / таблица значений**: оверлей-таблица 0/30/45/60/90… с подсветкой текущего.
- **Ф5 — Симметрии и формулы приведения**: чётность (α→−α), приведение (π±α, π/2±α, 2π−α)
с анимацией отражения/поворота + KaTeX; период tg/ctg = π.
- **Ф6 — Простейшие уравнения**: задаёшь значение → все решения на круге + общая формула
(sin α=½ → π/6+2πk, 5π/6+2πk); для tg — шаг π; связка с arcsin/arccos геометрически.
- **Ф7 — Два угла (опц.)**: вторая точка β → α±β, формулы сложения.
- **Сквозное**: режимы-вкладки (Углы·Определения·Знаки·Особые·Приведение·Уравнения) с краткой
теорией и кнопкой «Задание» через _tasks.js; шпаргалка (значения+знаки+тождества+приведение).
## Проверка каждой фазы
node --check; headless-смоук математики (опорный угол, знаки, решения уравнений, приведение)
в vm с стабом canvas; коммит+push, без эмодзи, lint.
+26
View File
@@ -183,6 +183,30 @@ function Backup-Db {
Prune-Backups
}
function Reset-System {
Write-Host ''
Write-Host ' ============================================================' -ForegroundColor Red
Write-Host ' ВНИМАНИЕ: ЧИСТЫЙ ЗАПУСК - НЕОБРАТИМАЯ ОЧИСТКА' -ForegroundColor Red
Write-Host ' ============================================================' -ForegroundColor Red
Write-Host ' УДАЛЯТСЯ: все пользователи (кроме одного админа), классы,' -ForegroundColor Yellow
Write-Host ' задания, сессии, геймификация, уведомления, прогресс, история.' -ForegroundColor Yellow
Write-Host ' СОХРАНЯТСЯ: учебники, вопросы, тесты, курсы, уроки, exam-prep,' -ForegroundColor Gray
Write-Host ' симуляции, настройки/права и один админ (контент переходит ему).' -ForegroundColor Gray
Write-Host ''
Write-Host ' План (предпросмотр, без изменений):' -ForegroundColor Cyan
try { & node scripts/reset-system.js | Out-Host } catch { Write-Host (' Ошибка плана: ' + $_.Exception.Message) -ForegroundColor Red; return }
Write-Host ''
$ans = (Read-Host ' Для подтверждения введите СБРОС (иначе отмена)').Trim().ToUpper()
if ($ans -ne 'СБРОС' -and $ans -ne 'RESET') { Write-Host ' Отменено.' -ForegroundColor Yellow; return }
if (Server-Proc) { Write-Host ' Сервер работает - остановите его ([2]) перед сбросом для надёжности.' -ForegroundColor Yellow }
Write-Host ' Шаг 1/2: бэкап БД...' -ForegroundColor Cyan
Backup-Db
Write-Host ' Шаг 2/2: очистка...' -ForegroundColor Cyan
try { & node scripts/reset-system.js --apply --confirm=RESET | Out-Host }
catch { Write-Host (' Ошибка сброса: ' + $_.Exception.Message) -ForegroundColor Red; return }
Write-Host ' Готово. Перезапустите сервер ([3]). Бэкап сохранён в data\backups.' -ForegroundColor Green
}
function Restore-Db {
if (-not (Test-Path $script:BackupDir)) { Write-Host ' Папки бэкапов нет — сначала сделайте бэкап ([B]).' -ForegroundColor Yellow; return }
$files = @(Get-ChildItem $script:BackupDir -Filter 'learnspace-*.db' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
@@ -389,6 +413,7 @@ while ($run) {
Menu-Row '[5]' 'Применить миграции' '[A]' 'Создать админа'
Menu-Row ' ' '' '[W]' 'Сторож (авто-рестарт)'
Menu-Row ' ' '' '[E]' 'Ошибки в логах'
Menu-Row ' ' '' '[Z]' 'Сброс системы (чистый запуск)'
Write-Host ''
Menu-Head 'ДИАГНОСТИКА И ПРОЧЕЕ' ''
Write-Host ' ' -NoNewline
@@ -417,6 +442,7 @@ while ($run) {
'^(U|Г)$' { Run-Cmd 'Обновление из репозитория' { Update-FromRepo }; Refresh-Status }
'^(W|Ц)$' { Watchdog; Refresh-Status }
'^(E|У)$' { Run-Cmd 'Ошибки в логах' { Show-Errors } }
'^(Z|Я)$' { Reset-System; Refresh-Status; Start-Sleep 1 }
'^0$' { $run = $false }
default { }
}