dc073e2114
Админка (Управление → игры/фичи): карточка «Помощник Квантик — модель» — пресеты провайдеров, URL/модель, поле ключа, кнопки Сохранить/Проверить/ Очистить ключ, индикатор статуса. Конфиг в app_settings (без рестарта), откат на ENV/дефолты; нет ключа → автоматически FAQ-режим. Эндпоинты GET/PUT/POST /api/admin/assistant(/test), admin-only. «Спроси Квантика» теперь многоходовой чат: история диалога (последние 6 реплик) уходит модели, ответы рендерятся как чат-лента, кнопка «Очистить». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
14 KiB
JavaScript
205 lines
14 KiB
JavaScript
'use strict';
|
|
/* admin → games (game features + free-student features) section */
|
|
(function () {
|
|
'use strict';
|
|
let inited = false;
|
|
|
|
const GAME_FEATURES = [
|
|
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
|
|
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически по темам', icon: 'grid-3x3' },
|
|
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
|
|
{ key: 'red_book', label: 'Красная книга', desc: 'Интерактивная Красная книга РБ: виды, биомы, пищевые сети, квесты', icon: 'leaf' },
|
|
{ key: 'collection', label: 'Коллекция', desc: 'Коллекция карточек и достижений — игровой прогресс ученика', icon: 'layers' },
|
|
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' },
|
|
{ key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' },
|
|
{ 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: 'assistant', label: 'Помощник «Квантик»', desc: 'Плавающий помощник: подсказки по разделам, напоминания и «Спроси Квантика»', icon: 'sparkles' },
|
|
];
|
|
|
|
const FS_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' },
|
|
{ key: 'red_book', label: 'Красная книга', desc: 'Интерактивная Красная книга РБ: виды, биомы, квесты', icon: 'leaf' },
|
|
{ key: 'collection', label: 'Коллекция', desc: 'Коллекция карточек и игровой прогресс ученика', icon: 'layers' },
|
|
{ key: 'lab', label: 'Лаборатория', desc: 'Виртуальные симуляции и интерактивные опыты', icon: 'flask-conical' },
|
|
{ key: 'knowledge_map',label: 'Карта знаний', desc: 'Визуальная карта тем и связей между понятиями', icon: 'map' },
|
|
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для повторения терминов и понятий', icon: 'square-stack' },
|
|
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями и постами', icon: 'layout-dashboard' },
|
|
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
|
|
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
|
|
];
|
|
|
|
/* ── Конфиг LLM для помощника «Квантик» ── */
|
|
var IN_STYLE = 'padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.85rem;width:100%;box-sizing:border-box;background:var(--surface,#fff);color:var(--text,#0f172a)';
|
|
var BTN_STYLE = 'padding:8px 14px;border-radius:9px;border:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);font:inherit;font-size:.82rem;font-weight:700;cursor:pointer;color:var(--text-2,#475569)';
|
|
async function renderAssistantLlmCard(grid) {
|
|
if (!grid || document.getElementById('asst-llm-card')) return;
|
|
var wrap = document.createElement('div');
|
|
wrap.id = 'asst-llm-card';
|
|
wrap.className = 'perm-card';
|
|
wrap.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-bottom:14px';
|
|
wrap.innerHTML =
|
|
'<div class="perm-label"><i data-lucide="sparkles" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Помощник «Квантик» — модель (ИИ)</div>' +
|
|
'<div class="perm-desc">Подключение LLM к «Спроси Квантика». OpenAI-совместимый эндпоинт. Без ключа — режим обычного FAQ.</div>' +
|
|
'<div id="asst-llm-status" style="font-size:.82rem;font-weight:600"></div>' +
|
|
'<select id="asst-preset" style="' + IN_STYLE + '"><option value="">— провайдер (пресет) —</option></select>' +
|
|
'<input id="asst-url" placeholder="URL chat/completions" style="' + IN_STYLE + '" />' +
|
|
'<input id="asst-model" placeholder="Модель" style="' + IN_STYLE + '" />' +
|
|
'<input id="asst-key" type="password" autocomplete="off" placeholder="API-ключ" style="' + IN_STYLE + '" />' +
|
|
'<div style="display:flex;gap:8px;flex-wrap:wrap">' +
|
|
'<button id="asst-save" style="' + BTN_STYLE + ';background:#9B5DE5;border-color:#9B5DE5;color:#fff">Сохранить</button>' +
|
|
'<button id="asst-test" style="' + BTN_STYLE + '">Проверить</button>' +
|
|
'<button id="asst-clearkey" style="' + BTN_STYLE + ';color:#e0335e">Очистить ключ</button>' +
|
|
'</div>' +
|
|
'<div id="asst-test-res" style="font-size:.82rem;line-height:1.5"></div>';
|
|
grid.parentNode.insertBefore(wrap, grid);
|
|
if (window.lucide) lucide.createIcons();
|
|
|
|
var q = function (s) { return wrap.querySelector(s); };
|
|
var cfg = {};
|
|
try { cfg = await LS.adminGetAssistant(); } catch (e) {}
|
|
var presetSel = q('#asst-preset');
|
|
(cfg.presets || []).forEach(function (p, i) { var o = document.createElement('option'); o.value = String(i); o.textContent = p.name; presetSel.appendChild(o); });
|
|
q('#asst-url').value = cfg.url || '';
|
|
q('#asst-model').value = cfg.model || '';
|
|
q('#asst-key').placeholder = cfg.hasKey ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ';
|
|
function setStatus() {
|
|
q('#asst-llm-status').innerHTML = cfg.active
|
|
? '<span style="color:#059652">● Подключено — «Спроси» отвечает через ИИ</span>'
|
|
: '<span style="color:#94a3b8">○ Ключ не задан — работает обычный FAQ-режим</span>';
|
|
}
|
|
setStatus();
|
|
presetSel.addEventListener('change', function () {
|
|
var p = (cfg.presets || [])[Number(presetSel.value)];
|
|
if (p) { q('#asst-url').value = p.url; q('#asst-model').value = p.model; }
|
|
});
|
|
q('#asst-save').addEventListener('click', async function () {
|
|
var body = { url: q('#asst-url').value, model: q('#asst-model').value };
|
|
var k = q('#asst-key').value.trim(); if (k) body.key = k;
|
|
try { await LS.adminSaveAssistant(body); q('#asst-key').value = ''; LS.toast('Сохранено', 'success');
|
|
cfg = await LS.adminGetAssistant(); cfg.hasKey && (q('#asst-key').placeholder = 'Ключ сохранён — введите новый, чтобы заменить'); setStatus();
|
|
} catch (e) { LS.toast('Ошибка: ' + (e.message || ''), 'error'); }
|
|
});
|
|
q('#asst-test').addEventListener('click', async function () {
|
|
var res = q('#asst-test-res'); res.innerHTML = 'Проверяю…';
|
|
var body = { url: q('#asst-url').value, model: q('#asst-model').value };
|
|
var k = q('#asst-key').value.trim(); if (k) body.key = k;
|
|
try {
|
|
var r = await LS.adminTestAssistant(body);
|
|
res.innerHTML = r && r.ok
|
|
? '<span style="color:#059652">✓ Работает (' + (r.model || '') + '): ' + (r.sample || 'ответ получен').replace(/</g, '<') + '</span>'
|
|
: '<span style="color:#e0335e">✗ ' + ((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').toString().slice(0, 200).replace(/</g, '<') + '</span>';
|
|
} catch (e) { res.innerHTML = '<span style="color:#e0335e">✗ ' + (e.message || 'ошибка') + '</span>'; }
|
|
});
|
|
q('#asst-clearkey').addEventListener('click', async function () {
|
|
if (!await LS.confirm('Очистить сохранённый ключ? «Спроси» вернётся в FAQ-режим.', { title: 'Очистить ключ?', confirmText: 'Очистить' })) return;
|
|
try { await LS.adminSaveAssistant({ clearKey: true }); LS.toast('Ключ очищен', 'success'); cfg = await LS.adminGetAssistant(); q('#asst-key').placeholder = 'API-ключ'; setStatus(); }
|
|
catch (e) { LS.toast('Ошибка', 'error'); }
|
|
});
|
|
}
|
|
|
|
async function loadGamesAdmin() {
|
|
const grid = document.getElementById('games-features-grid');
|
|
renderAssistantLlmCard(grid);
|
|
try {
|
|
const features = await LS.api('/api/admin/features');
|
|
grid.innerHTML = '';
|
|
for (const f of GAME_FEATURES) {
|
|
const enabled = features[f.key] !== false;
|
|
const card = document.createElement('div');
|
|
card.className = 'perm-card' + (enabled ? ' enabled' : '');
|
|
card.innerHTML = `
|
|
<div class="perm-info">
|
|
<div class="perm-label"><i data-lucide="${f.icon}" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>${f.label}</div>
|
|
<div class="perm-desc">${f.desc}</div>
|
|
</div>
|
|
<label class="perm-toggle">
|
|
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleGameFeature('${f.key}', this.checked, this)" />
|
|
<span class="perm-track"></span>
|
|
<span class="perm-thumb"></span>
|
|
</label>`;
|
|
grid.appendChild(card);
|
|
}
|
|
if (window.lucide) lucide.createIcons();
|
|
} catch(e) {
|
|
grid.innerHTML = '<div class="error">Ошибка загрузки</div>';
|
|
}
|
|
}
|
|
|
|
async function toggleGameFeature(key, enabled, checkbox) {
|
|
try {
|
|
await LS.api('/api/admin/features', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ [key]: enabled }),
|
|
});
|
|
const card = checkbox.closest('.perm-card');
|
|
if (card) card.classList.toggle('enabled', enabled);
|
|
LS.toast(enabled ? 'Функция включена' : 'Функция отключена', 'success');
|
|
} catch(e) {
|
|
checkbox.checked = !enabled;
|
|
LS.toast('Ошибка: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadFsFeatures() {
|
|
const grid = document.getElementById('fs-features-grid');
|
|
try {
|
|
const features = await LS.api('/api/admin/free-student-features');
|
|
grid.innerHTML = '';
|
|
for (const f of FS_FEATURES) {
|
|
const enabled = features[f.key] !== false;
|
|
const card = document.createElement('div');
|
|
card.className = 'perm-card' + (enabled ? ' enabled' : '');
|
|
card.innerHTML = `
|
|
<div class="perm-info">
|
|
<div class="perm-label"><i data-lucide="${f.icon}" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>${f.label}</div>
|
|
<div class="perm-desc">${f.desc}</div>
|
|
</div>
|
|
<label class="perm-toggle">
|
|
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleFsFeature('${f.key}', this.checked, this)" />
|
|
<span class="perm-track"></span>
|
|
<span class="perm-thumb"></span>
|
|
</label>`;
|
|
grid.appendChild(card);
|
|
}
|
|
if (window.lucide) lucide.createIcons();
|
|
} catch(e) {
|
|
grid.innerHTML = '<div class="error">Ошибка загрузки</div>';
|
|
}
|
|
}
|
|
|
|
async function toggleFsFeature(key, enabled, checkbox) {
|
|
try {
|
|
await LS.api('/api/admin/free-student-features', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ [key]: enabled }),
|
|
});
|
|
const card = checkbox.closest('.perm-card');
|
|
if (card) card.classList.toggle('enabled', enabled);
|
|
LS.toast(enabled ? 'Модуль включён' : 'Модуль отключён', 'success');
|
|
} catch(e) {
|
|
checkbox.checked = !enabled;
|
|
LS.toast('Ошибка: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
await loadGamesAdmin();
|
|
await loadFsFeatures();
|
|
}
|
|
|
|
window.toggleGameFeature = toggleGameFeature;
|
|
window.toggleFsFeature = toggleFsFeature;
|
|
|
|
window.AdminSections = window.AdminSections || {};
|
|
window.AdminSections.games = {
|
|
init: async () => { if (inited) return; inited = true; await load(); },
|
|
reload: load,
|
|
};
|
|
})();
|