5f481f5d11
renderMath в _shared.js распознавал только \(…\) и \[…\], из-за чего 873 вопроса с долларовыми разделителями не рендерили формулы в админке. $$ ставится раньше $, чтобы auto-render не принял его за два пустых $. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
6.3 KiB
JavaScript
134 lines
6.3 KiB
JavaScript
'use strict';
|
||
/* Admin shared helpers — referenced by admin.js orchestrator + every section module.
|
||
* Exposed on window.AdminCtx (filled by admin.js after LS.initPage()) and
|
||
* on window directly for utility functions used by HTML onclicks.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
/* ─── Constants ─── */
|
||
const MODES = { exam:'Экзамен', practice:'Тренировка', repeat:'Обычный', ct:'ЦТ/ЦЭ', topic:'По теме', random:'Случайный' };
|
||
const DIFFS = { 1:'Лёгкий', 2:'Средний', 3:'Сложный' };
|
||
const DIFF_LABELS = DIFFS;
|
||
const TYPE_LABELS = { single:'Один', multi:'Несколько', true_false:'Верно/Нет', short_answer:'Краткий', matching:'Сопоставление' };
|
||
|
||
/* ─── Generic formatters ─── */
|
||
function pctClass(p) { return p === null ? '' : p >= 75 ? 'pct-hi' : p >= 50 ? 'pct-mid' : 'pct-lo'; }
|
||
function fmtDate(d) { return new Date(d).toLocaleDateString('ru', { day:'numeric', month:'short', year:'numeric' }); }
|
||
function fmtTime(sec) {
|
||
if (!sec || sec < 0) return '—';
|
||
const m = Math.floor(sec / 60), s = sec % 60;
|
||
return m ? `${m} мин ${s} сек` : `${s} сек`;
|
||
}
|
||
function fmtDuration(sec) {
|
||
if (!sec || sec < 0) return '—';
|
||
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
||
if (h) return `${h}ч ${m}м`;
|
||
if (m) return `${m} мин ${s} сек`;
|
||
return `${s} сек`;
|
||
}
|
||
|
||
/* ─── KaTeX rendering ─── */
|
||
const KATEX_OPTS = {
|
||
// Порядок важен: многосимвольные/display-разделители ($$, \[) идут раньше
|
||
// одиночного $, иначе auto-render распознает $$ как два пустых $.
|
||
delimiters: [
|
||
{ left: '$$', right: '$$', display: true },
|
||
{ left: '\\[', right: '\\]', display: true },
|
||
{ left: '\\(', right: '\\)', display: false },
|
||
{ left: '$', right: '$', display: false },
|
||
],
|
||
throwOnError: false,
|
||
};
|
||
function renderMath(el) {
|
||
if (!el) return;
|
||
const run = () => { if (window.renderMathInElement) renderMathInElement(el, KATEX_OPTS); };
|
||
if (window._katexReady) run(); else window._katexCb = run;
|
||
}
|
||
|
||
/* ─── Question type badges (used by tests + subjects sections) ─── */
|
||
function qTypeBadge(type) {
|
||
const MAP = { single:'Один', multi:'Несколько', true_false:'Верно/Нет', short_answer:'Ответ', matching:'Сопост.' };
|
||
const CLR = { single:'rgba(155,93,229,0.12)', multi:'rgba(6,214,224,0.12)', true_false:'rgba(255,179,71,0.14)', short_answer:'rgba(6,214,100,0.12)', matching:'rgba(241,91,181,0.10)' };
|
||
const TXT = { single:'var(--violet)', multi:'#05aab3', true_false:'var(--amber)', short_answer:'var(--green)', matching:'var(--pink)' };
|
||
return `<span class="tst-q-badge" style="background:${CLR[type]||'rgba(15,23,42,0.06)'};color:${TXT[type]||'var(--text-3)'}">${MAP[type]||type}</span>`;
|
||
}
|
||
|
||
function qOptsPreview(q) {
|
||
if (q.type === 'short_answer') return q.correct_text ? `<span class="tst-q-opts">Ответ: ${esc(q.correct_text)}</span>` : '';
|
||
if (!q.options?.length) return '';
|
||
const correct = q.options.filter(o => o.is_correct).map(o => esc(o.text)).join(', ');
|
||
return `<span class="tst-q-opts"><i data-lucide="check" style="width:12px;height:12px;vertical-align:-2px"></i> ${correct}</span>`;
|
||
}
|
||
|
||
/* ─── Pagination controls (users + future tables) ─── */
|
||
function ensurePgnStyles() {
|
||
if (document.getElementById('pgn-bar-style')) return;
|
||
const s = document.createElement('style');
|
||
s.id = 'pgn-bar-style';
|
||
s.textContent = `
|
||
.pgn-bar { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:14px 4px 4px; font-size:0.85rem; color:var(--text-3); }
|
||
.pgn-info { font-weight:600; }
|
||
.pgn-ctrls { display:flex; align-items:center; gap:4px; }
|
||
.pgn-btn { min-width:32px; height:32px; padding:0 10px; border:1px solid var(--border); background:var(--surface); border-radius:8px; cursor:pointer; font-weight:600; font-family:inherit; font-size:0.85rem; color:var(--text-2); transition:background .12s, color .12s, border-color .12s; }
|
||
.pgn-btn:hover:not(:disabled) { background:rgba(155,93,229,.08); color:var(--violet); border-color:rgba(155,93,229,.3); }
|
||
.pgn-btn.active { background:var(--violet); color:#fff; border-color:var(--violet); }
|
||
.pgn-btn:disabled { opacity:.4; cursor:not-allowed; }
|
||
.pgn-ellip { padding:0 6px; color:var(--text-3); }
|
||
`;
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
function renderPgnControls(elId, page, total, perPage, gotoFn) {
|
||
const bar = document.getElementById(elId);
|
||
if (!bar) return;
|
||
const pages = Math.max(1, Math.ceil(total / perPage));
|
||
if (pages <= 1) { bar.style.display = 'none'; return; }
|
||
ensurePgnStyles();
|
||
const from = (page - 1) * perPage + 1;
|
||
const to = Math.min(page * perPage, total);
|
||
const nums = new Set([1, pages, page, page - 1, page + 1, page - 2, page + 2]);
|
||
const sorted = [...nums].filter(n => n >= 1 && n <= pages).sort((a, b) => a - b);
|
||
const numHtml = sorted.map((n, i) => {
|
||
const prev = sorted[i - 1];
|
||
const gap = prev && n - prev > 1 ? '<span class="pgn-ellip">…</span>' : '';
|
||
return `${gap}<button class="pgn-btn${n === page ? ' active' : ''}" onclick="${gotoFn}(${n})">${n}</button>`;
|
||
}).join('');
|
||
bar.innerHTML = `
|
||
<div class="pgn-info">${from}–${to} из ${total}</div>
|
||
<div class="pgn-ctrls">
|
||
<button class="pgn-btn" onclick="${gotoFn}(${page - 1})" ${page <= 1 ? 'disabled' : ''}>←</button>
|
||
${numHtml}
|
||
<button class="pgn-btn" onclick="${gotoFn}(${page + 1})" ${page >= pages ? 'disabled' : ''}>→</button>
|
||
</div>`;
|
||
bar.style.display = '';
|
||
}
|
||
|
||
/* ─── Export ─── */
|
||
window.AdminCtx = window.AdminCtx || {
|
||
// filled by admin.js after LS.initPage():
|
||
user: null,
|
||
isTeacher: false,
|
||
isAdmin: false,
|
||
// constants:
|
||
MODES,
|
||
DIFFS,
|
||
DIFF_LABELS,
|
||
TYPE_LABELS,
|
||
// formatters:
|
||
pctClass,
|
||
fmtDate,
|
||
fmtTime,
|
||
fmtDuration,
|
||
// rendering:
|
||
renderMath,
|
||
qTypeBadge,
|
||
qOptsPreview,
|
||
// pagination:
|
||
renderPgnControls,
|
||
ensurePgnStyles,
|
||
};
|
||
|
||
window.AdminSections = window.AdminSections || {};
|
||
})();
|