fd29acbbdd
Classroom performance: - WebSocket server (ws-server.js) for low-latency cursor & stroke preview Replaces HTTP POST per event → eliminates per-message auth overhead Session member cache (30s TTL) avoids SQLite query per WS message Fallback to HTTP POST when WS not connected - Cursor throttle reduced 100ms → 33ms (~30fps) - Stroke preview throttle reduced 50ms → 20ms - whiteboard.js: render() is now rAF-gated (_doRender/_rafPending) Multiple render() calls within one frame collapse into one repaint document.hidden check — zero CPU when tab is in background visibilitychange listener restores canvas on tab focus Guest board: - guestClassroom.js route: public token-based read-only access - guest-board.html: name entry + read-only whiteboard + SSE - SSE: addGuestClient/removeGuestClient/emitToGuests Screen share picker: - Discord-style modal with tab switching (screen/window/tab) - Live video preview before confirming share - useExistingScreenStream() in ClassroomRTC Fullscreen exit overlay: - #cr-fs-exit-overlay button inside cr-board-wrap - Visible only via CSS :fullscreen selector (touchpad users) File sharing from library: - Teacher picks file from library, sends as styled card in chat - crDownloadLibraryFile() fetches with Bearer auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5264 lines
301 KiB
HTML
5264 lines
301 KiB
HTML
<!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 href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||
<link rel="stylesheet" href="/css/ls.css" />
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous" />
|
||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
|
||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" crossorigin="anonymous"
|
||
onload="window._katexReady=true; if(window._katexCb){window._katexCb(); window._katexCb=null;}"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||
<style>
|
||
.container { padding: 32px 32px 80px; }
|
||
.page-title { font-family: 'Unbounded', sans-serif; font-size: 1.65rem; font-weight: 800; margin-bottom: 6px; }
|
||
.page-sub { font-size: 1rem; color: var(--text-2); margin-bottom: 40px; }
|
||
.section-title { font-family: 'Unbounded', sans-serif; font-size: 0.9rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 20px; }
|
||
|
||
/* stats */
|
||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 20px; margin-bottom: 48px; }
|
||
.stat-card { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 28px 24px; }
|
||
.stat-val { font-family: 'Unbounded', sans-serif; font-size: 2.4rem; font-weight: 800; margin-bottom: 6px; }
|
||
.stat-label { font-size: 0.9rem; color: var(--text-3); font-weight: 600; }
|
||
.stat-val.violet { color: var(--violet); }
|
||
.stat-val.cyan { color: var(--cyan); }
|
||
.stat-val.green { color: var(--green); }
|
||
.subj-stats { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 48px; }
|
||
.subj-stat { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: 16px; padding: 18px 22px; display: flex; align-items: center; gap: 14px; }
|
||
.subj-stat-name { font-size: 0.95rem; font-weight: 700; }
|
||
.subj-stat-info { font-size: 0.84rem; color: var(--text-3); }
|
||
.subj-stat-pct { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; color: var(--violet); min-width: 48px; text-align: right; }
|
||
|
||
/* subject config cards */
|
||
.sc-list { display: flex; flex-direction: column; gap: 8px; }
|
||
.sc-card {
|
||
background: #fff; border: 1.5px solid var(--border); border-radius: 16px;
|
||
overflow: hidden; transition: border-color 0.18s, box-shadow 0.18s;
|
||
}
|
||
.sc-card.open { border-color: rgba(155,93,229,0.2); box-shadow: 0 6px 24px rgba(15,23,42,0.07); }
|
||
|
||
/* Collapsed row — always visible */
|
||
.sc-row-top {
|
||
display: flex; align-items: center; gap: 14px; padding: 14px 20px;
|
||
cursor: pointer; user-select: none; transition: background 0.12s;
|
||
}
|
||
.sc-row-top:hover { background: rgba(155,93,229,0.02); }
|
||
.sc-icon {
|
||
width: 42px; height: 42px; border-radius: 12px;
|
||
display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: #fff;
|
||
}
|
||
.sc-icon svg { width: 20px; height: 20px; }
|
||
.sc-info { flex: 1; min-width: 0; }
|
||
.sc-name { font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 800; color: #0F172A; }
|
||
.sc-summary {
|
||
display: flex; gap: 6px; margin-top: 4px; flex-wrap: wrap; align-items: center;
|
||
}
|
||
.sc-tag {
|
||
font-size: 0.68rem; font-weight: 700; padding: 2px 8px; border-radius: 6px;
|
||
background: rgba(15,23,42,0.05); color: #64748B;
|
||
}
|
||
.sc-tag-mode { background: rgba(155,93,229,0.08); color: var(--violet); }
|
||
.sc-qcount { font-size: 0.72rem; color: #8898AA; font-weight: 600; }
|
||
.sc-chevron {
|
||
width: 20px; height: 20px; color: #cbd5e1; transition: transform 0.2s; flex-shrink: 0;
|
||
}
|
||
.sc-card.open .sc-chevron { transform: rotate(180deg); color: var(--violet); }
|
||
|
||
/* Expanded body */
|
||
.sc-body {
|
||
display: none; padding: 0 20px 20px; flex-direction: column; gap: 14px;
|
||
border-top: 1px solid rgba(15,23,42,0.06);
|
||
}
|
||
.sc-card.open .sc-body { display: flex; }
|
||
|
||
/* Presets row */
|
||
.sc-presets {
|
||
display: flex; gap: 6px; flex-wrap: wrap; padding-top: 14px;
|
||
}
|
||
.sc-preset {
|
||
padding: 6px 14px; border: 1.5px solid var(--border); border-radius: 99px;
|
||
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.76rem;
|
||
font-weight: 600; color: #64748B; cursor: pointer; transition: all 0.15s;
|
||
}
|
||
.sc-preset:hover { border-color: rgba(155,93,229,0.35); color: var(--violet); background: rgba(155,93,229,0.04); }
|
||
.sc-preset.active { border-color: var(--violet); background: rgba(155,93,229,0.08); color: var(--violet); }
|
||
|
||
/* Fields */
|
||
.sc-fields { display: flex; flex-direction: column; gap: 12px; }
|
||
.sc-field { display: flex; align-items: center; gap: 10px; }
|
||
.sc-label {
|
||
font-size: 0.72rem; color: #8898AA; font-weight: 700; white-space: nowrap;
|
||
text-transform: uppercase; letter-spacing: 0.04em; min-width: 68px;
|
||
}
|
||
.sc-select {
|
||
flex: 1; padding: 8px 12px; border: 1.5px solid var(--border); border-radius: 10px;
|
||
background: #f8f9fc; font-family: 'Manrope', sans-serif; font-size: 0.82rem;
|
||
font-weight: 600; color: var(--text); cursor: pointer; transition: border-color 0.15s;
|
||
}
|
||
.sc-select:focus { border-color: var(--violet); outline: none; }
|
||
.sc-input {
|
||
width: 68px; padding: 8px 10px; border: 1.5px solid var(--border); border-radius: 10px;
|
||
background: #f8f9fc; font-family: 'Manrope', sans-serif; font-size: 0.82rem;
|
||
font-weight: 600; color: var(--text); text-align: center; transition: border-color 0.15s;
|
||
}
|
||
.sc-input:focus { border-color: var(--violet); outline: none; }
|
||
.sc-src-toggle {
|
||
display: flex; gap: 0; background: rgba(15,23,42,0.04); border-radius: 10px; padding: 3px; flex: 1;
|
||
}
|
||
.sc-src-btn {
|
||
flex: 1; padding: 6px 12px; border: none; border-radius: 8px; background: transparent;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 600;
|
||
color: #8898AA; cursor: pointer; transition: all 0.15s; text-align: center;
|
||
}
|
||
.sc-src-btn.active { background: #fff; color: var(--violet); box-shadow: 0 1px 4px rgba(15,23,42,0.08); }
|
||
.sc-test-pick { display: none; flex-direction: column; gap: 10px; }
|
||
.sc-test-pick.open { display: flex; }
|
||
|
||
/* Footer */
|
||
.sc-footer {
|
||
display: flex; gap: 8px; align-items: center; padding-top: 14px;
|
||
border-top: 1px solid rgba(15,23,42,0.05);
|
||
}
|
||
.sc-save {
|
||
padding: 8px 22px; border: none; border-radius: 999px; background: #0F172A; color: #fff;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
|
||
cursor: pointer; transition: all 0.15s;
|
||
}
|
||
.sc-save:hover { background: #1E293B; }
|
||
.sc-save.saved { background: #059652; pointer-events: none; }
|
||
.sc-save-add {
|
||
padding: 8px 16px; border: 1.5px solid rgba(155,93,229,0.25); border-radius: 999px;
|
||
background: rgba(155,93,229,0.06); color: var(--violet);
|
||
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
|
||
cursor: pointer; transition: all 0.15s;
|
||
}
|
||
.sc-save-add:hover { background: rgba(155,93,229,0.12); border-color: var(--violet); }
|
||
|
||
/* tables */
|
||
.table-wrap { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; margin-bottom: 48px; box-shadow: var(--shadow); }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { padding: 14px 20px; text-align: left; font-size: 0.82rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; border-bottom: 1px solid var(--border); background: rgba(238,242,255,0.5); }
|
||
td { padding: 15px 20px; font-size: 0.94rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||
tr:last-child td { border-bottom: none; }
|
||
tr.clickable { cursor: pointer; transition: background var(--tr); }
|
||
tr.clickable:hover td { background: rgba(155,93,229,0.04); }
|
||
tr.selected td { background: rgba(155,93,229,0.07); }
|
||
.role-badge { display: inline-block; padding: 4px 12px; border-radius: var(--r-pill); font-size: 0.78rem; font-weight: 700; }
|
||
.role-badge.student { background: rgba(15,23,42,0.07); color: var(--text-3); }
|
||
.role-badge.free_student { background: rgba(16,185,129,0.12); color: #059669; }
|
||
.role-badge.teacher { background: rgba(6,214,224,0.12); color: #05aab3; }
|
||
.role-badge.admin { background: rgba(155,93,229,0.12); color: var(--violet); }
|
||
.role-select { padding: 6px 10px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; background: transparent; color: var(--text); cursor: pointer; }
|
||
.role-select:focus { outline: none; border-color: var(--violet); }
|
||
.role-select:disabled { opacity: 0.4; cursor: default; }
|
||
.pct-cell { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 700; }
|
||
.pct-hi { color: var(--green); }
|
||
.pct-mid { color: var(--amber); }
|
||
.pct-lo { color: var(--pink); }
|
||
|
||
/* user panel */
|
||
.user-panel { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 32px; box-shadow: var(--shadow); display: none; }
|
||
.user-panel.visible { display: block; }
|
||
.user-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
|
||
.user-panel-name { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; }
|
||
.user-panel-email { font-size: 0.92rem; color: var(--text-3); margin-top: 3px; }
|
||
.btn-close { padding: 8px 18px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
|
||
.btn-close:hover { border-color: var(--pink); color: var(--pink); }
|
||
.sess-list { display: flex; flex-direction: column; gap: 12px; }
|
||
.sess-item { display: flex; align-items: center; gap: 16px; padding: 14px 20px; border: 1px solid var(--border); border-radius: 14px; }
|
||
.sess-pct { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; min-width: 52px; text-align: center; }
|
||
.sess-info { flex: 1; }
|
||
.sess-subj { font-size: 0.94rem; font-weight: 700; margin-bottom: 3px; }
|
||
.sess-meta { font-size: 0.83rem; color: var(--text-3); }
|
||
.sess-score{ font-size: 0.9rem; font-weight: 600; color: var(--text-2); white-space: nowrap; }
|
||
|
||
.spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--violet); border-radius: 50%; animation: spin 0.8s linear infinite; margin: 40px auto; display: block; }
|
||
.empty { text-align: center; padding: 40px; color: var(--text-3); font-size: 0.95rem; }
|
||
.error { color: var(--pink); font-size: 0.92rem; padding: 14px 0; }
|
||
|
||
/* ── admin two-column layout ── */
|
||
.admin-layout { display: flex; gap: 0; align-items: flex-start; }
|
||
|
||
.admin-nav {
|
||
width: 200px; flex-shrink: 0;
|
||
position: sticky; top: 24px;
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: var(--r-lg); padding: 12px 10px;
|
||
box-shadow: var(--shadow);
|
||
display: flex; flex-direction: column; gap: 2px;
|
||
}
|
||
.admin-nav-label {
|
||
font-size: .68rem; font-weight: 800; text-transform: uppercase;
|
||
letter-spacing: .09em; color: var(--text-3);
|
||
padding: 12px 12px 5px; margin-top: 4px;
|
||
}
|
||
.admin-nav-label:first-child { margin-top: 0; }
|
||
.admin-nav-sep { height: 1px; background: var(--border); margin: 8px 6px; }
|
||
.admin-nav-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 10px 12px; border-radius: 10px; border: none;
|
||
background: transparent; width: 100%; text-align: left;
|
||
font-family: 'Manrope', sans-serif; font-size: .93rem; font-weight: 600;
|
||
color: var(--text-2); cursor: pointer; transition: all .14s;
|
||
white-space: nowrap;
|
||
}
|
||
.admin-nav-item svg { flex-shrink: 0; opacity: .7; }
|
||
.admin-nav-item:hover { background: rgba(155,93,229,.07); color: var(--text); }
|
||
.admin-nav-item:hover svg { opacity: 1; }
|
||
.admin-nav-item.active {
|
||
background: rgba(155,93,229,.1); color: var(--violet);
|
||
font-weight: 700;
|
||
}
|
||
.admin-nav-item.active svg { opacity: 1; color: var(--violet); }
|
||
|
||
.admin-main { flex: 1; min-width: 0; padding-left: 28px; }
|
||
|
||
/* tab panes */
|
||
.tab-pane { display: none; }
|
||
.tab-pane.active { display: block; }
|
||
|
||
/* permissions tab */
|
||
.perm-header { margin-bottom: 24px; }
|
||
.perm-role-block { background: var(--surface); border: 1.5px solid var(--border-h); border-radius: var(--r-xl); padding: 20px 24px; margin-bottom: 20px; }
|
||
.perm-role-title { margin-bottom: 16px; }
|
||
.perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||
.perm-card { display: flex; align-items: flex-start; gap: 16px; padding: 16px 20px; border: 1.5px solid var(--border); border-radius: var(--r-lg); background: var(--surface-2, #f8fafc); transition: border-color .2s, box-shadow .2s; }
|
||
.perm-card:hover { border-color: var(--violet); box-shadow: 0 2px 12px rgba(109,40,217,.08); }
|
||
.perm-card.enabled { border-color: rgba(6,214,160,.5); background: rgba(6,214,160,.04); }
|
||
.perm-info { flex: 1; min-width: 0; }
|
||
.perm-label { font-size: 15px; font-weight: 700; color: var(--text); line-height: 1.3; }
|
||
.perm-desc { font-size: 13px; color: var(--muted); margin-top: 4px; line-height: 1.45; }
|
||
/* toggle switch */
|
||
.perm-toggle { flex-shrink: 0; position: relative; width: 46px; height: 26px; cursor: pointer; }
|
||
.perm-toggle input { opacity: 0; width: 0; height: 0; }
|
||
.perm-track { position: absolute; inset: 0; border-radius: 26px; background: var(--border-h); transition: background .2s; }
|
||
.perm-thumb { position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.2); transition: transform .2s; }
|
||
.perm-toggle input:checked ~ .perm-track { background: var(--green, #06d6a0); }
|
||
.perm-toggle input:checked ~ .perm-thumb { transform: translateX(20px); }
|
||
.perm-toggle input:focus-visible ~ .perm-track { outline: 2px solid var(--violet); }
|
||
|
||
/* toolbar */
|
||
.t-toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 24px; }
|
||
.t-select { padding: 10px 16px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); font-family: 'Manrope', sans-serif; font-size: 0.92rem; font-weight: 600; background: var(--surface); color: var(--text); cursor: pointer; }
|
||
.t-select:focus { outline: none; border-color: var(--violet); }
|
||
.t-input { padding: 10px 16px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); font-family: 'Manrope', sans-serif; font-size: 0.92rem; background: var(--surface); color: var(--text); width: 220px; }
|
||
.t-input:focus { outline: none; border-color: var(--violet); }
|
||
.t-count { font-size: 0.9rem; color: var(--text-3); margin-left: auto; }
|
||
.btn-add { padding: 10px 24px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.92rem; font-weight: 700; cursor: pointer; white-space: nowrap; transition: transform var(--tr), box-shadow var(--tr); }
|
||
.btn-add:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(6,214,224,0.35); }
|
||
|
||
.mode-badge { display: inline-block; padding: 3px 10px; border-radius: var(--r-pill); font-size: 0.76rem; font-weight: 700; }
|
||
.mode-exam { background: rgba(155,93,229,0.1); color: var(--violet); }
|
||
.mode-practice { background: rgba(6,214,224,0.1); color: #05aab3; }
|
||
.mode-repeat { background: rgba(6,214,224,0.1); color: #05aab3; }
|
||
.mode-ct { background: rgba(255,179,71,0.12); color: var(--amber); }
|
||
.mode-topic { background: rgba(255,179,71,0.12); color: var(--amber); }
|
||
.mode-random { background: rgba(15,23,42,0.07); color: var(--text-3); }
|
||
|
||
/* ── assignment card rows ── */
|
||
.a-summary { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
|
||
.a-sum-chip { padding: 6px 16px; border-radius: var(--r-pill); font-size: 0.85rem; font-weight: 700; font-family: 'Manrope', sans-serif; display: flex; align-items: center; gap: 7px; }
|
||
.a-sum-chip.s-all { background: rgba(15,23,42,0.06); color: var(--text-3); }
|
||
.a-sum-chip.s-active { background: rgba(155,93,229,0.1); color: var(--violet); }
|
||
.a-sum-chip.s-overdue { background: rgba(241,91,181,0.12); color: var(--pink); }
|
||
.a-sum-chip.s-done { background: rgba(6,214,100,0.12); color: #059652; }
|
||
|
||
.a-filter-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 18px; }
|
||
.a-f-chip { padding: 6px 18px; border-radius: var(--r-pill); border: 1.5px solid var(--border-h); background: var(--surface); color: var(--text-3); font-family: 'Manrope', sans-serif; font-size: 0.85rem; font-weight: 700; cursor: pointer; transition: all var(--tr); }
|
||
.a-f-chip:hover { border-color: var(--violet); color: var(--violet); }
|
||
.a-f-chip.active { background: #0F172A; color: #fff; border-color: #0F172A; }
|
||
.a-filter-sep { width: 1px; height: 22px; background: var(--border-h); margin: 0 4px; }
|
||
.a-sort-sel { padding: 6px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: var(--surface); color: var(--text-3); font-family: 'Manrope', sans-serif; font-size: 0.85rem; font-weight: 600; cursor: pointer; }
|
||
.a-sort-sel:focus { outline: none; border-color: var(--violet); }
|
||
|
||
.a-rows { display: flex; flex-direction: column; gap: 6px; }
|
||
.a-row {
|
||
display: flex; align-items: center; gap: 14px;
|
||
background: var(--surface);
|
||
border: 1.5px solid var(--border);
|
||
border-left: 4px solid var(--ac, var(--violet));
|
||
border-radius: 14px;
|
||
padding: 0 16px 0 12px;
|
||
height: 68px;
|
||
transition: box-shadow 0.15s, transform 0.15s;
|
||
}
|
||
.a-row:hover { box-shadow: 0 4px 18px rgba(15,23,42,0.1); transform: translateX(2px); }
|
||
.a-row.a-overdue { --ac: var(--pink); background: rgba(241,91,181,0.02); }
|
||
.a-row.a-done { --ac: var(--green); opacity: 0.76; }
|
||
.a-row.a-urgent { --ac: #FF4C29; }
|
||
|
||
.a-icon { width: 36px; height: 36px; border-radius: 9px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 1.05rem; }
|
||
.a-main { flex: 1; min-width: 0; }
|
||
.a-title { font-size: 0.94rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); line-height: 1.2; }
|
||
.a-meta { font-size: 0.78rem; color: var(--text-3); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.a-meta .a-tag-over { color: var(--pink); font-weight: 700; }
|
||
.a-meta .a-tag-urgent { color: #E83A1E; font-weight: 700; }
|
||
|
||
.a-prog { flex-shrink: 0; width: 120px; }
|
||
.a-prog-nums { display: flex; justify-content: space-between; font-size: 0.76rem; color: var(--text-3); font-weight: 600; margin-bottom: 5px; }
|
||
.a-prog-pct { font-family: 'Unbounded', sans-serif; font-size: 0.84rem; font-weight: 900; }
|
||
.a-prog-bar { height: 6px; border-radius: 99px; background: rgba(15,23,42,0.08); overflow: hidden; }
|
||
.a-prog-fill { height: 100%; border-radius: 99px; transition: width 0.3s; }
|
||
|
||
.a-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||
|
||
/* session drawer */
|
||
.sess-drawer { overflow: hidden; max-height: 0; transition: max-height 0.35s ease; }
|
||
.sess-drawer.open { max-height: 4000px; }
|
||
.sess-drawer-inner { padding: 28px 32px; background: rgba(238,242,255,0.6); border-top: 1px solid var(--border); }
|
||
.drawer-header { display: flex; align-items: center; gap: 18px; margin-bottom: 24px; flex-wrap: wrap; }
|
||
.drawer-meta { font-size: 0.9rem; color: var(--text-3); }
|
||
.drawer-score { font-family: 'Unbounded', sans-serif; font-size: 1.8rem; font-weight: 900; }
|
||
.qb-list { display: flex; flex-direction: column; gap: 12px; }
|
||
.qb-item { border-radius: 14px; padding: 16px 20px; border: 1px solid var(--border); background: var(--surface); }
|
||
.qb-item.correct { border-left: 3px solid var(--green); }
|
||
.qb-item.wrong { border-left: 3px solid var(--pink); }
|
||
.qb-item.skipped { border-left: 3px solid var(--text-3); }
|
||
.qb-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||
.qb-badge { padding: 3px 10px; border-radius: var(--r-pill); font-size: 0.74rem; font-weight: 700; }
|
||
.qb-badge.correct { background: rgba(6,214,100,0.1); color: var(--green); }
|
||
.qb-badge.wrong { background: rgba(241,91,181,0.1); color: var(--pink); }
|
||
.qb-badge.skipped { background: rgba(15,23,42,0.06); color: var(--text-3); }
|
||
.qb-qnum { font-size: 0.82rem; color: var(--text-3); font-weight: 600; }
|
||
.qb-time { margin-left: auto; font-size: 0.79rem; color: var(--text-3); }
|
||
.qb-text { font-size: 0.96rem; line-height: 1.6; margin-bottom: 12px; }
|
||
.qb-opts { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
|
||
.qb-opt { display: flex; align-items: center; gap: 9px; padding: 8px 12px; border-radius: 9px; font-size: 0.9rem; }
|
||
.qb-opt.correct-opt { background: rgba(6,214,100,0.08); color: var(--green); font-weight: 600; }
|
||
.qb-opt.chosen-wrong { background: rgba(241,91,181,0.08); color: var(--pink); }
|
||
.qb-opt-icon { font-size: 0.88rem; width: 18px; text-align: center; flex-shrink: 0; }
|
||
.qb-expl { font-size: 0.88rem; color: var(--text-2); background: rgba(155,93,229,0.05); border: 1px solid rgba(155,93,229,0.12); border-radius: 9px; padding: 10px 14px; line-height: 1.6; }
|
||
|
||
/* ── E: upgraded stat cards ── */
|
||
.stat-card { position: relative; overflow: hidden; }
|
||
.stat-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: var(--stat-top, var(--violet)); opacity: 0.7; }
|
||
.stat-card-icon { width: 48px; height: 48px; border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; margin-bottom: 14px; }
|
||
|
||
/* ── F: performance bar in users table ── */
|
||
.perf-bar { height: 4px; background: rgba(15,23,42,0.06); border-radius: 99px; margin-top: 6px; min-width: 70px; overflow: hidden; }
|
||
.perf-fill { height: 100%; border-radius: 99px; }
|
||
.perf-fill.pct-hi { background: var(--green); }
|
||
.perf-fill.pct-mid { background: var(--amber); }
|
||
.perf-fill.pct-lo { background: var(--pink); }
|
||
|
||
/* ── H: session timeline ── */
|
||
.sess-tl-day { font-family: 'Unbounded', sans-serif; font-size: 0.76rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin: 24px 0 12px; display: flex; align-items: center; gap: 10px; }
|
||
.sess-tl-day::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||
.sess-tl-day:first-child { margin-top: 0; }
|
||
.sess-tl-item { display: flex; align-items: center; gap: 16px; padding: 15px 20px; border: 1px solid var(--border); border-radius: 14px; margin-bottom: 10px; transition: all var(--tr); cursor: pointer; background: var(--surface); }
|
||
.sess-tl-item:hover { border-color: var(--border-h); transform: translateX(3px); box-shadow: 0 4px 20px rgba(15,23,42,0.07); }
|
||
.sess-tl-item.open { border-color: var(--violet); background: rgba(155,93,229,0.04); }
|
||
.sess-tl-ring { flex-shrink: 0; }
|
||
.sess-tl-user { flex: 1; min-width: 0; }
|
||
.sess-tl-name { font-size: 0.94rem; font-weight: 700; margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.sess-tl-meta { font-size: 0.81rem; color: var(--text-3); }
|
||
.sess-tl-score { font-size: 0.8rem; font-weight: 600; color: var(--text-2); white-space: nowrap; }
|
||
.sess-tl-time { font-size: 0.75rem; color: var(--text-3); white-space: nowrap; }
|
||
.sess-tl-drawer { overflow: hidden; max-height: 0; transition: max-height 0.35s ease; margin-bottom: 0; }
|
||
.sess-tl-drawer.open { max-height: 4000px; margin-bottom: 8px; }
|
||
.sess-tl-wrap { display: flex; flex-direction: column; }
|
||
|
||
/* ═══ QUESTION EDITOR ═══════════════════════════════════════════════ */
|
||
.q-list { display: flex; flex-direction: column; gap: 12px; }
|
||
.q-card { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; transition: border-color var(--tr), box-shadow var(--tr); }
|
||
.q-card:hover { border-color: var(--border-h); box-shadow: 0 4px 20px rgba(15,23,42,0.07); }
|
||
.q-card-head { display: flex; align-items: flex-start; gap: 14px; padding: 18px 20px; }
|
||
.q-card-num { font-family: 'Unbounded', sans-serif; font-size: 0.7rem; font-weight: 700; color: var(--text-3); min-width: 36px; padding-top: 3px; flex-shrink: 0; }
|
||
.q-card-body { flex: 1; min-width: 0; cursor: pointer; }
|
||
.q-card-text { font-size: 0.9rem; font-weight: 600; line-height: 1.5; margin-bottom: 8px; }
|
||
.q-card-meta { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.q-badge { display: inline-block; padding: 2px 8px; border-radius: var(--r-pill); font-size: 0.68rem; font-weight: 700; }
|
||
.q-badge-subj { background: rgba(155,93,229,0.1); color: var(--violet); }
|
||
.q-badge-topic { background: rgba(6,214,224,0.1); color: #05aab3; }
|
||
.diff-1 { background: rgba(6,214,100,0.1); color: var(--green); }
|
||
.diff-2 { background: rgba(255,179,71,0.1); color: var(--amber); }
|
||
.diff-3 { background: rgba(241,91,181,0.1); color: var(--pink); }
|
||
.q-card-actions { display: flex; gap: 6px; flex-shrink: 0; align-items: flex-start; padding-top: 2px; }
|
||
.btn-edit-q { padding: 5px 12px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
|
||
.btn-edit-q:hover { border-color: var(--violet); color: var(--violet); }
|
||
.btn-dup-q { padding: 5px 10px; border: 1.5px solid transparent; border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
|
||
.btn-dup-q:hover { border-color: var(--cyan); color: #05aab3; }
|
||
.btn-del-q { padding: 5px 10px; border: 1.5px solid transparent; border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
|
||
.btn-del-q:hover { border-color: var(--pink); color: var(--pink); }
|
||
.q-card-detail { display: none; padding: 0 20px 16px 70px; border-top: 1px solid var(--border); margin-top: -1px; }
|
||
.q-card-detail.open { display: block; }
|
||
.q-opt-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; font-size: 0.85rem; border-bottom: 1px solid var(--border); }
|
||
.q-opt-row:last-of-type { border-bottom: none; }
|
||
.q-opt-row.correct { color: var(--green); font-weight: 700; }
|
||
.q-opt-icon { font-size: 0.85rem; width: 18px; flex-shrink: 0; }
|
||
.q-expl { margin-top: 12px; font-size: 0.8rem; color: var(--text-2); background: rgba(155,93,229,0.05); border: 1px solid rgba(155,93,229,0.12); border-radius: 10px; padding: 10px 14px; line-height: 1.6; }
|
||
|
||
/* modal */
|
||
.q-modal { position: fixed; inset: 0; z-index: 400; display: none; align-items: flex-start; justify-content: center; padding: 32px 20px 60px; background: rgba(15,23,42,0.5); backdrop-filter: blur(10px); overflow-y: auto; }
|
||
.q-modal.open { display: flex; }
|
||
.q-modal-box { background: #fff; border-radius: 24px; padding: 36px 40px; width: 100%; max-width: 780px; box-shadow: 0 40px 100px rgba(15,23,42,0.26); }
|
||
.q-modal-title { font-family: 'Unbounded', sans-serif; font-size: 1.05rem; font-weight: 800; margin-bottom: 28px; }
|
||
.form-row { margin-bottom: 18px; }
|
||
.form-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 18px; }
|
||
.form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; margin-bottom: 18px; }
|
||
.form-label { display: block; font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
|
||
.form-hint { font-size: 0.72rem; color: var(--text-3); margin-top: 4px; }
|
||
.form-ctrl { width: 100%; padding: 10px 14px; border: 1.5px solid rgba(15,23,42,0.16); border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: 0.9rem; color: var(--text); background: #f8f9ff; resize: vertical; transition: border-color 0.2s; }
|
||
.form-ctrl:focus { outline: none; border-color: var(--violet); background: #fff; }
|
||
.char-counter { font-size: 0.72rem; color: var(--text-3); text-align: right; margin-top: 3px; }
|
||
.char-counter.warn { color: var(--amber); }
|
||
.char-counter.over { color: var(--pink); }
|
||
|
||
/* options in modal */
|
||
.opts-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||
.opts-label { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; }
|
||
.opts-grid { display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px; }
|
||
.opt-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 12px; border: 1.5px solid rgba(15,23,42,0.12); background: #f8f9ff; transition: border-color 0.2s, background 0.2s; }
|
||
.opt-row.opt-correct { border-color: var(--green); background: rgba(6,214,100,0.06); }
|
||
.opt-letter { font-size: 0.78rem; font-weight: 800; color: var(--text-3); min-width: 18px; font-family: 'Unbounded', sans-serif; }
|
||
.opt-row.opt-correct .opt-letter { color: var(--green); }
|
||
.opt-radio { width: 18px; height: 18px; accent-color: var(--green); flex-shrink: 0; cursor: pointer; }
|
||
.opt-input { flex: 1; border: none; background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.9rem; color: var(--text); outline: none; }
|
||
.opt-row.opt-correct .opt-input { font-weight: 600; color: #1a7a3e; }
|
||
.btn-rem-opt { width: 24px; height: 24px; border: none; border-radius: 50%; background: rgba(15,23,42,0.07); color: var(--text-3); font-size: 1rem; line-height: 1; cursor: pointer; flex-shrink: 0; transition: background 0.2s, color 0.2s; display: flex; align-items: center; justify-content: center; }
|
||
.btn-rem-opt:hover { background: rgba(241,91,181,0.12); color: var(--pink); }
|
||
.btn-add-opt { padding: 7px 16px; border: 1.5px dashed rgba(15,23,42,0.2); border-radius: 10px; background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--text-3); cursor: pointer; width: 100%; margin-bottom: 18px; transition: border-color 0.2s, color 0.2s; }
|
||
.btn-add-opt:hover { border-color: var(--violet); color: var(--violet); }
|
||
|
||
.img-upload-row { display: flex; gap: 8px; align-items: center; }
|
||
.btn-img-upload { display: inline-flex; align-items: center; gap: 6px; padding: 0 16px; height: 40px; border: 1.5px solid rgba(15,23,42,0.18); border-radius: 10px; background: var(--surface); font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--text-2); cursor: pointer; white-space: nowrap; transition: border-color 0.2s, color 0.2s; flex-shrink: 0; }
|
||
.btn-img-upload:hover { border-color: var(--violet); color: var(--violet); }
|
||
.btn-img-upload:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
#qf-image-preview { display: none; }
|
||
#qf-image-preview.visible { display: inline-block !important; }
|
||
|
||
.modal-footer { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; border-top: 1px solid var(--border); padding-top: 20px; }
|
||
.btn-cancel2 { padding: 10px 22px; border: 1.5px solid rgba(15,23,42,0.2); border-radius: 999px; background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; }
|
||
.btn-save { padding: 10px 28px; border: none; border-radius: 999px; background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; min-width: 130px; transition: opacity 0.2s; }
|
||
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.form-error { font-size: 0.82rem; color: var(--pink); margin-top: 8px; min-height: 20px; }
|
||
/* question type buttons */
|
||
.type-btn { padding: 6px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
|
||
.type-btn.active { background: var(--violet); border-color: var(--violet); color: #fff; }
|
||
|
||
/* ═══ TESTS TAB ══════════════════════════════════════════════════════ */
|
||
.tst-drawer { border-top: 1px solid var(--border); background: rgba(238,242,255,0.5); }
|
||
.tst-drawer-inner { padding: 20px 24px; }
|
||
.tst-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||
@media(max-width:700px){ .tst-cols { grid-template-columns: 1fr; } }
|
||
.tst-panel-title { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 10px; }
|
||
.tst-q-list { display: flex; flex-direction: column; gap: 2px; max-height: 400px; overflow-y: auto; border: 1px solid var(--border); border-radius: 10px; background: #fff; padding: 4px; }
|
||
.tst-q-item { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border-radius: 7px; font-size: 0.83rem; transition: background var(--tr); }
|
||
.tst-q-item:hover { background: rgba(15,23,42,0.03); }
|
||
.tst-q-num { font-size: 0.68rem; font-weight: 700; color: var(--text-3); min-width: 22px; padding-top: 2px; }
|
||
.tst-q-body { flex: 1; min-width: 0; }
|
||
.tst-q-text { display: block; line-height: 1.45; word-break: break-word; margin-bottom: 4px; }
|
||
.tst-q-meta { display: flex; gap: 4px; flex-wrap: wrap; }
|
||
.tst-q-badge { font-size: 0.62rem; font-weight: 700; padding: 1px 6px; border-radius: 999px; flex-shrink: 0; }
|
||
.tst-q-opts { font-size: 0.68rem; color: var(--text-3); padding-top: 2px; }
|
||
.btn-tst-rem { width: 22px; height: 22px; border: none; border-radius: 50%; background: rgba(241,91,181,0.1); color: var(--pink); font-size: 0.85rem; cursor: pointer; flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: background var(--tr); }
|
||
.btn-tst-rem:hover { background: rgba(241,91,181,0.22); }
|
||
.btn-tst-add { width: 22px; height: 22px; border: none; border-radius: 50%; background: rgba(6,214,100,0.12); color: var(--green); font-size: 1rem; line-height: 1; cursor: pointer; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-weight: 700; transition: background var(--tr); }
|
||
.btn-tst-add:hover { background: rgba(6,214,100,0.25); }
|
||
.btn-tst-add.added { background: rgba(6,214,100,0.25); cursor: default; opacity: 0.6; }
|
||
.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; }
|
||
.src-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
|
||
/* formula bar */
|
||
.formula-bar { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; padding: 8px 12px; background: #f0f2ff; border-radius: 10px; margin-bottom: 8px; }
|
||
.fml { padding: 4px 9px; border: 1px solid rgba(155,93,229,0.2); border-radius: 6px; background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--violet); cursor: pointer; transition: background 0.15s; }
|
||
.fml:hover { background: rgba(155,93,229,0.08); }
|
||
/* preview */
|
||
.q-preview-wrap { background: rgba(155,93,229,0.04); border: 1px solid rgba(155,93,229,0.15); border-radius: 10px; padding: 12px 16px; margin-top: 8px; margin-bottom: 18px; min-height: 40px; }
|
||
.q-preview-text { font-size: 0.95rem; line-height: 1.6; color: var(--text); }
|
||
.form-hint { font-size: 0.72rem; color: var(--text-3); margin-top: 4px; }
|
||
|
||
/* ── Mobile responsive ── */
|
||
@media (max-width: 768px) {
|
||
.container { padding: 16px 12px 80px; }
|
||
.page-title { font-size: 1.1rem; }
|
||
.page-sub { font-size: 0.83rem; margin-bottom: 24px; }
|
||
|
||
/* Stats */
|
||
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||
.stat-val { font-size: 1.5rem; }
|
||
|
||
/* Admin nav — collapse to horizontal scroll strip on mobile */
|
||
.admin-layout { flex-direction: column; gap: 0; align-items: stretch; }
|
||
.admin-nav {
|
||
width: 100%; position: static;
|
||
flex-direction: row; overflow-x: auto; overflow-y: hidden;
|
||
flex-wrap: nowrap; padding: 6px; gap: 2px;
|
||
scrollbar-width: none; border-radius: 14px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.admin-nav::-webkit-scrollbar { display: none; }
|
||
.admin-nav-label { display: none; }
|
||
.admin-nav-sep { display: none !important; }
|
||
.admin-nav-item { padding: 7px 12px; font-size: .78rem; white-space: nowrap; border-radius: 8px; }
|
||
.admin-nav-item svg { display: none; }
|
||
/* admin-main must fill full width when stacked */
|
||
.admin-main { padding-left: 0; width: 100%; min-width: 0; box-sizing: border-box; }
|
||
|
||
/* Toolbar: selects wrap and fill available space */
|
||
.t-toolbar { gap: 7px; flex-wrap: wrap; }
|
||
.t-select { flex: 1 1 120px; min-width: 0; max-width: 100%; font-size: 0.82rem; padding: 8px 10px; }
|
||
.t-input { width: 100%; flex: 1 1 140px; min-width: 0; }
|
||
.t-count { margin-left: 0; width: 100%; }
|
||
.btn-add { width: 100%; justify-content: center; }
|
||
|
||
/* Tables: horizontal scroll container */
|
||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||
table { min-width: 540px; }
|
||
th, td { padding: 10px 12px; }
|
||
|
||
/* Assignment rows */
|
||
.a-row { height: auto; min-height: 56px; padding: 8px 10px; gap: 8px; flex-wrap: wrap; }
|
||
.a-prog { display: none; } /* hide progress column on mobile */
|
||
.a-meta { white-space: normal; }
|
||
|
||
/* Subject config cards */
|
||
.sc-row-top { padding: 12px 14px; gap: 10px; }
|
||
.sc-icon { width: 36px; height: 36px; border-radius: 10px; }
|
||
.sc-icon svg { width: 16px; height: 16px; }
|
||
.sc-name { font-size: 0.82rem; }
|
||
.sc-body { padding: 0 14px 16px; gap: 10px; }
|
||
.sc-presets { gap: 4px; padding-top: 10px; }
|
||
.sc-preset { font-size: 0.7rem; padding: 5px 10px; }
|
||
.sc-field { flex-wrap: wrap; gap: 6px; }
|
||
.sc-label { min-width: 56px; font-size: 0.68rem; }
|
||
.sc-input { width: 60px; }
|
||
.sc-footer { gap: 6px; }
|
||
|
||
/* Question editor modal */
|
||
.q-modal { padding: 0; align-items: flex-end; overflow-y: hidden; }
|
||
.q-modal-box {
|
||
border-radius: 22px 22px 0 0;
|
||
padding: 24px 16px 32px;
|
||
max-height: 92vh;
|
||
overflow-y: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.q-modal-title { font-size: 0.9rem; margin-bottom: 20px; }
|
||
.form-row-2, .form-row-3 { grid-template-columns: 1fr; }
|
||
|
||
/* User panel */
|
||
.user-panel { padding: 18px 14px; }
|
||
.user-panel-header { flex-wrap: wrap; gap: 10px; }
|
||
|
||
/* Session drawer */
|
||
.sess-drawer-inner { padding: 16px 12px; }
|
||
.drawer-header { gap: 10px; }
|
||
.drawer-score { font-size: 1.2rem; }
|
||
|
||
/* Session timeline */
|
||
.sess-tl-item { padding: 10px 12px; gap: 10px; }
|
||
.sess-tl-time { display: none; }
|
||
|
||
/* Subj stats: stretch to fill row */
|
||
.subj-stats { gap: 8px; }
|
||
.subj-stat { padding: 10px 12px; flex: 1 1 calc(50% - 8px); min-width: 0; box-sizing: border-box; }
|
||
.subj-stat-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.subj-stat-info { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
|
||
/* adm-panel (shop/gam/perms tabs): reduce padding */
|
||
.adm-panel { padding: 18px 14px; }
|
||
|
||
/* q-card-detail: reduce deep left indent */
|
||
.q-card-detail { padding-left: 20px; }
|
||
|
||
/* modal footer: wrap buttons on narrow screens */
|
||
.q-modal-box .modal-footer { flex-wrap: wrap; }
|
||
.q-modal-box .modal-footer .btn-cancel2,
|
||
.q-modal-box .modal-footer .btn-save { flex: 1; min-width: 0; text-align: center; justify-content: center; }
|
||
|
||
/* subj-stat: don't let % value push items off screen */
|
||
.subj-stat-pct { min-width: 36px; font-size: 0.95rem; }
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.container { padding: 14px 10px 80px; }
|
||
.stats-grid { grid-template-columns: 1fr; gap: 8px; }
|
||
.stat-val { font-size: 1.2rem; }
|
||
.stat-card { padding: 16px 14px; }
|
||
.admin-nav-item { padding: 6px 11px; font-size: 0.75rem; }
|
||
.a-actions { gap: 4px; }
|
||
.a-icon { display: none; }
|
||
/* subj-stat: 1 column on very narrow */
|
||
.subj-stat { flex: 1 1 100%; }
|
||
/* perm-grid: single column at 480px */
|
||
.perm-grid { grid-template-columns: 1fr; }
|
||
/* adm-form-row: full-width inputs */
|
||
.adm-form-group input,
|
||
.adm-form-group select,
|
||
.adm-form-group textarea { width: 100%; box-sizing: border-box; }
|
||
/* toolbar: each select full row */
|
||
.t-select { flex: 1 1 100%; }
|
||
}
|
||
|
||
/* ═══ SHOP / GAM / TPL tabs ═══ */
|
||
.shop-items-table td, .gam-top-table td, .gam-log-table td, .tpl-table td { font-size: 0.92rem; }
|
||
.adm-panel { background: var(--surface); backdrop-filter: var(--blur); border: 1.5px solid var(--border); border-radius: var(--r-lg); padding: 28px 32px; margin-bottom: 28px; }
|
||
.adm-panel-title { font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 20px; }
|
||
.adm-form-row { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 16px; }
|
||
.adm-form-group { display: flex; flex-direction: column; gap: 5px; }
|
||
.adm-form-group label { font-size: 0.76rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
|
||
.adm-form-group input, .adm-form-group select, .adm-form-group textarea { padding: 10px 14px; border: 1.5px solid var(--border-h); border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: 0.92rem; background: var(--surface); color: var(--text); }
|
||
.adm-form-group input:focus, .adm-form-group select:focus, .adm-form-group textarea:focus { outline: none; border-color: var(--violet); }
|
||
.adm-form-group textarea { resize: vertical; min-height: 70px; }
|
||
.adm-btn { padding: 10px 24px; border: none; border-radius: var(--r-pill); font-family: 'Manrope', sans-serif; font-size: 0.92rem; font-weight: 700; cursor: pointer; transition: opacity .2s; }
|
||
.adm-btn-primary { background: var(--grad-1); color: #fff; }
|
||
.adm-btn-danger { background: var(--pink); color: #fff; }
|
||
.adm-btn-small { padding: 6px 14px; font-size: 0.84rem; }
|
||
.adm-btn:hover { opacity: 0.88; }
|
||
.adm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.adm-toggle { position: relative; width: 42px; height: 24px; cursor: pointer; display: inline-block; vertical-align: middle; }
|
||
.adm-toggle input { opacity: 0; width: 0; height: 0; }
|
||
.adm-toggle .track { position: absolute; inset: 0; border-radius: 24px; background: var(--border-h); transition: background .2s; }
|
||
.adm-toggle .thumb { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.15); transition: transform .2s; }
|
||
.adm-toggle input:checked ~ .track { background: var(--green, #06d6a0); }
|
||
.adm-toggle input:checked ~ .thumb { transform: translateX(18px); }
|
||
.adm-user-search { position: relative; }
|
||
.adm-user-search .us-results { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; background: #fff; border: 1.5px solid var(--border-h); border-radius: 12px; max-height: 240px; overflow-y: auto; box-shadow: 0 8px 24px rgba(15,23,42,0.12); display: none; }
|
||
.adm-user-search .us-results.open { display: block; }
|
||
.adm-user-search .us-item { padding: 10px 16px; font-size: 0.92rem; cursor: pointer; transition: background .15s; display: flex; justify-content: space-between; align-items: center; }
|
||
.adm-user-search .us-item:hover { background: rgba(155,93,229,0.06); }
|
||
.adm-user-search .us-item .us-role { font-size: 0.76rem; color: var(--text-3); }
|
||
|
||
/* ── Submission log ── */
|
||
.sl-wrap {
|
||
overflow-x: auto; border: 1.5px solid var(--border); border-radius: 16px;
|
||
background: #fff; box-shadow: 0 2px 12px rgba(15,23,42,0.05);
|
||
}
|
||
.sl-table { width: 100%; border-collapse: collapse; min-width: 800px; }
|
||
.sl-table th {
|
||
padding: 12px 14px; text-align: left; font-size: 0.68rem; font-weight: 800;
|
||
color: var(--violet); text-transform: uppercase; letter-spacing: 0.07em;
|
||
border-bottom: 2px solid rgba(155,93,229,0.12);
|
||
background: linear-gradient(180deg, rgba(155,93,229,0.04) 0%, rgba(155,93,229,0.01) 100%);
|
||
white-space: nowrap;
|
||
}
|
||
.sl-table td {
|
||
padding: 12px 14px; font-size: 0.82rem; color: #1E293B;
|
||
border-bottom: 1px solid rgba(15,23,42,0.06); vertical-align: middle;
|
||
}
|
||
.sl-table tr:last-child td { border-bottom: none; }
|
||
.sl-table tbody tr { transition: background 0.12s; }
|
||
.sl-table tbody tr:hover td { background: rgba(155,93,229,0.03); }
|
||
.sl-table tbody tr:nth-child(even) td { background: rgba(15,23,42,0.015); }
|
||
.sl-table tbody tr:nth-child(even):hover td { background: rgba(155,93,229,0.04); }
|
||
|
||
.sl-date { font-size: 0.78rem; color: #64748B; white-space: nowrap; }
|
||
.sl-student {
|
||
font-weight: 700; color: #0F172A; display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.sl-student-avatar {
|
||
width: 28px; height: 28px; border-radius: 8px; flex-shrink: 0;
|
||
background: linear-gradient(135deg, rgba(155,93,229,0.15), rgba(6,214,224,0.1));
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.65rem; font-weight: 800; color: var(--violet);
|
||
}
|
||
.sl-file {
|
||
max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
font-size: 0.78rem; color: #64748B; font-family: monospace;
|
||
}
|
||
.sl-assignment { font-weight: 600; color: #3D4F6B; }
|
||
.sl-class { font-size: 0.78rem; color: #8898AA; }
|
||
|
||
.sl-status {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 3px 10px; border-radius: 99px; font-size: 0.7rem; font-weight: 700;
|
||
white-space: nowrap;
|
||
}
|
||
.sl-status-new { background: rgba(6,214,224,0.1); color: #06aab3; }
|
||
.sl-status-reviewed { background: rgba(5,150,82,0.08); color: #059652; }
|
||
.sl-status-accepted { background: rgba(5,150,82,0.12); color: #047857; }
|
||
.sl-status-revision { background: rgba(241,91,181,0.08); color: #c0306a; }
|
||
.sl-status-resubmitted { background: rgba(59,130,246,0.08); color: #3B82F6; }
|
||
|
||
.sl-grade {
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
|
||
min-width: 32px; text-align: center;
|
||
}
|
||
.sl-grade-hi { color: #059652; }
|
||
.sl-grade-mid { color: #c07c00; }
|
||
.sl-grade-lo { color: #c0306a; }
|
||
.sl-grade-none { color: #cbd5e1; font-weight: 400; font-family: 'Manrope', sans-serif; font-size: 0.78rem; }
|
||
|
||
.sl-deleted-by {
|
||
display: flex; align-items: center; gap: 6px;
|
||
font-size: 0.78rem; color: #64748B;
|
||
}
|
||
.sl-role-badge {
|
||
font-size: 0.62rem; font-weight: 700; padding: 1px 6px; border-radius: 4px;
|
||
text-transform: uppercase; letter-spacing: 0.04em;
|
||
}
|
||
.sl-role-admin { background: rgba(241,91,181,0.1); color: #c0306a; }
|
||
.sl-role-teacher { background: rgba(155,93,229,0.1); color: var(--violet); }
|
||
.sl-role-student { background: rgba(6,214,224,0.1); color: #06aab3; }
|
||
|
||
.sl-empty {
|
||
padding: 48px 24px; text-align: center; color: #8898AA; font-size: 0.88rem;
|
||
}
|
||
.sl-empty-icon { margin-bottom: 12px; opacity: 0.3; }
|
||
|
||
.sl-filter-row { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; }
|
||
.sl-filter-select {
|
||
padding: 8px 14px; border: 1.5px solid var(--border); border-radius: 10px;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600;
|
||
color: #3D4F6B; background: #fff; cursor: pointer; min-width: 200px;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.sl-filter-select:focus { border-color: var(--violet); outline: none; }
|
||
.sl-count { font-size: 0.78rem; color: #8898AA; font-weight: 600; }
|
||
|
||
/* ══════════ CLASSROOM ADMIN TAB ══════════ */
|
||
.cr-admin-section { margin-bottom: 40px; }
|
||
.cr-admin-section-title {
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
|
||
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.07em;
|
||
margin-bottom: 16px; display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.cr-admin-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||
|
||
/* Active session card */
|
||
.cr-live-list { display: flex; flex-direction: column; gap: 10px; }
|
||
.cr-live-card {
|
||
display: flex; align-items: center; gap: 14px;
|
||
background: var(--surface); border: 1.5px solid var(--border);
|
||
border-left: 4px solid #EF4444; border-radius: 16px;
|
||
padding: 14px 18px; transition: box-shadow 0.15s, transform 0.15s;
|
||
}
|
||
.cr-live-card:hover { box-shadow: 0 4px 20px rgba(15,23,42,0.1); transform: translateX(2px); }
|
||
.cr-live-pulse {
|
||
width: 10px; height: 10px; border-radius: 50%; background: #EF4444; flex-shrink: 0;
|
||
animation: pulse-live 1.4s ease-in-out infinite;
|
||
}
|
||
@keyframes pulse-live {
|
||
0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.5); }
|
||
50% { box-shadow: 0 0 0 6px rgba(239,68,68,0); }
|
||
}
|
||
.cr-live-info { flex: 1; min-width: 0; }
|
||
.cr-live-title { font-size: 0.96rem; font-weight: 700; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cr-live-meta { font-size: 0.81rem; color: var(--text-3); }
|
||
.cr-live-badges { display: flex; gap: 6px; flex-shrink: 0; align-items: center; }
|
||
.cr-badge {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
padding: 4px 10px; border-radius: 99px; font-size: 0.76rem; font-weight: 700;
|
||
}
|
||
.cr-badge-online { background: rgba(6,214,100,0.12); color: #059652; }
|
||
.cr-badge-msgs { background: rgba(6,214,224,0.12); color: #05aab3; }
|
||
.cr-badge-dur { background: rgba(155,93,229,0.1); color: var(--violet); }
|
||
.cr-live-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||
|
||
/* History session row */
|
||
.cr-hist-list { display: flex; flex-direction: column; gap: 8px; }
|
||
.cr-hist-row {
|
||
display: flex; align-items: center; gap: 14px;
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: 14px; padding: 12px 16px; cursor: pointer;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
}
|
||
.cr-hist-row:hover { border-color: var(--violet); box-shadow: 0 2px 12px rgba(109,40,217,0.07); }
|
||
.cr-hist-row.open { border-color: var(--violet); background: rgba(155,93,229,0.03); border-radius: 14px 14px 0 0; border-bottom: none; }
|
||
.cr-hist-icon { width: 38px; height: 38px; border-radius: 10px; background: rgba(155,93,229,0.1); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||
.cr-hist-main { flex: 1; min-width: 0; }
|
||
.cr-hist-title { font-size: 0.94rem; font-weight: 700; margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cr-hist-meta { font-size: 0.79rem; color: var(--text-3); }
|
||
.cr-hist-chips { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
|
||
.cr-hist-chevron { width: 18px; height: 18px; color: var(--text-3); transition: transform 0.2s; flex-shrink: 0; }
|
||
.cr-hist-row.open .cr-hist-chevron { transform: rotate(180deg); color: var(--violet); }
|
||
|
||
/* Session detail drawer */
|
||
.cr-detail-drawer {
|
||
overflow: hidden; max-height: 0; transition: max-height 0.35s ease;
|
||
border: 1px solid var(--violet); border-top: none;
|
||
border-radius: 0 0 14px 14px; background: rgba(238,242,255,0.5);
|
||
margin-bottom: 0;
|
||
}
|
||
.cr-detail-drawer.open { max-height: 3000px; margin-bottom: 8px; }
|
||
.cr-detail-inner { padding: 20px 24px; }
|
||
.cr-detail-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
|
||
@media(max-width:700px) { .cr-detail-grid { grid-template-columns: repeat(2, 1fr); } }
|
||
.cr-detail-stat {
|
||
background: #fff; border: 1px solid var(--border); border-radius: 12px;
|
||
padding: 14px 16px; text-align: center;
|
||
}
|
||
.cr-detail-val { font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800; color: var(--violet); margin-bottom: 4px; }
|
||
.cr-detail-label { font-size: 0.72rem; color: var(--text-3); font-weight: 700; text-transform: uppercase; }
|
||
.cr-attend-list { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
|
||
.cr-attend-row {
|
||
display: flex; align-items: center; gap: 12px; padding: 8px 12px;
|
||
border: 1px solid var(--border); border-radius: 10px; background: #fff;
|
||
font-size: 0.88rem;
|
||
}
|
||
.cr-attend-name { flex: 1; font-weight: 600; }
|
||
.cr-attend-time { color: var(--text-3); font-size: 0.8rem; }
|
||
.cr-attend-dur { color: var(--cyan); font-weight: 700; font-size: 0.8rem; min-width: 60px; text-align: right; }
|
||
.cr-pages-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px,1fr)); gap: 8px; margin-top: 10px; }
|
||
.cr-page-chip {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
background: #fff; border: 1px solid var(--border); border-radius: 8px;
|
||
padding: 8px 12px; font-size: 0.82rem;
|
||
}
|
||
.cr-page-num { font-weight: 700; color: var(--violet); }
|
||
.cr-page-cnt { color: var(--text-3); font-size: 0.76rem; }
|
||
.cr-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||
.btn-cr-export { padding: 8px 18px; border: 1.5px solid var(--cyan); border-radius: 99px; background: rgba(6,214,224,0.06); color: #05aab3; font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: all 0.15s; }
|
||
.btn-cr-export:hover { background: rgba(6,214,224,0.15); }
|
||
.btn-cr-del { padding: 8px 18px; border: 1.5px solid rgba(241,91,181,0.4); border-radius: 99px; background: transparent; color: var(--pink); font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: all 0.15s; }
|
||
.btn-cr-del:hover { background: rgba(241,91,181,0.08); border-color: var(--pink); }
|
||
.btn-cr-end { padding: 8px 18px; border: none; border-radius: 99px; background: #EF4444; color: #fff; font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: opacity 0.15s; }
|
||
.btn-cr-end:hover { opacity: 0.85; }
|
||
|
||
/* Pagination */
|
||
.cr-pagination { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 24px; flex-wrap: wrap; }
|
||
.cr-page-btn {
|
||
min-width: 36px; height: 36px; padding: 0 12px; border: 1.5px solid var(--border);
|
||
border-radius: 10px; background: var(--surface); font-family:'Manrope',sans-serif;
|
||
font-size:0.85rem; font-weight:700; color:var(--text-2); cursor:pointer; transition:all 0.14s;
|
||
display:flex; align-items:center; justify-content:center;
|
||
}
|
||
.cr-page-btn:hover:not(:disabled) { border-color:var(--violet); color:var(--violet); }
|
||
.cr-page-btn.active { background:var(--violet); border-color:var(--violet); color:#fff; }
|
||
.cr-page-btn:disabled { opacity:0.4; cursor:default; }
|
||
.cr-page-info { font-size:0.82rem; color:var(--text-3); font-weight:600; }
|
||
|
||
/* toolbar for classroom history */
|
||
.cr-hist-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
|
||
.cr-hist-search { flex: 1; min-width: 180px; padding: 9px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); font-family:'Manrope',sans-serif; font-size:0.88rem; background:var(--surface); color:var(--text); }
|
||
.cr-hist-search:focus { outline:none; border-color:var(--violet); }
|
||
.cr-hist-count { font-size:0.85rem; color:var(--text-3); font-weight:600; white-space:nowrap; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app-layout">
|
||
<aside class="sidebar">
|
||
<div class="sb-brand">
|
||
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
|
||
<button class="sb-toggle" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
|
||
</div>
|
||
<nav class="sb-nav">
|
||
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
|
||
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
|
||
<a href="/board" class="sb-link" id="sbl-board" style="display:none"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
|
||
<a href="/classes" class="sb-link"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
|
||
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
|
||
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
|
||
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
|
||
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
|
||
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
|
||
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
|
||
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
|
||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||
<div class="sb-divider"></div>
|
||
<span class="sb-link active"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></span>
|
||
</nav>
|
||
<div class="sb-foot">
|
||
<a href="/profile" class="sb-user-row" style="text-decoration:none">
|
||
<div class="sb-avatar" id="nav-avatar">?</div>
|
||
<div class="sb-user-info">
|
||
<div class="sb-user-name" id="nav-user">—</div>
|
||
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
</aside>
|
||
<div class="sb-content">
|
||
|
||
<div class="container">
|
||
<div class="page-title">Панель управления</div>
|
||
<div class="page-sub" id="page-sub">Загрузка…</div>
|
||
|
||
<div class="admin-layout">
|
||
<nav class="admin-nav" id="admin-nav">
|
||
|
||
<div class="admin-nav-label">Аналитика</div>
|
||
<button class="admin-nav-item active" data-tab="stats" onclick="switchTab(this)">
|
||
<i data-lucide="bar-chart-2" style="width:15px;height:15px"></i> Статистика
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="sessions" onclick="switchTab(this)">
|
||
<i data-lucide="clock" style="width:15px;height:15px"></i> История сессий
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="classroom" onclick="switchTab(this)">
|
||
<i data-lucide="video" style="width:15px;height:15px"></i> Онлайн-уроки
|
||
</button>
|
||
|
||
<div class="admin-nav-sep"></div>
|
||
<div class="admin-nav-label">Контент</div>
|
||
<button class="admin-nav-item" data-tab="questions" onclick="switchTab(this)">
|
||
<i data-lucide="help-circle" style="width:15px;height:15px"></i> Вопросы
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="tests" onclick="switchTab(this)">
|
||
<i data-lucide="clipboard-list" style="width:15px;height:15px"></i> Тесты
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="assignments" onclick="switchTab(this)">
|
||
<i data-lucide="file-check" style="width:15px;height:15px"></i> Задания
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="subjects" onclick="switchTab(this)" id="btn-tab-subjects" style="display:none">
|
||
<i data-lucide="book-marked" style="width:15px;height:15px"></i> Доступные тесты
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="tpl" onclick="switchTab(this)" id="btn-tab-tpl" style="display:none">
|
||
<i data-lucide="copy" style="width:15px;height:15px"></i> Шаблоны
|
||
</button>
|
||
|
||
<div class="admin-nav-sep"></div>
|
||
<div class="admin-nav-label">Пользователи</div>
|
||
<button class="admin-nav-item" data-tab="users" onclick="switchTab(this)">
|
||
<i data-lucide="users" style="width:15px;height:15px"></i> Пользователи
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="permissions" onclick="switchTab(this)" id="btn-tab-permissions" style="display:none">
|
||
<i data-lucide="shield" style="width:15px;height:15px"></i> Права доступа
|
||
</button>
|
||
|
||
<div class="admin-nav-sep" id="admin-nav-system-sep" style="display:none"></div>
|
||
<div class="admin-nav-label" id="admin-nav-system-label" style="display:none">Система</div>
|
||
<button class="admin-nav-item" data-tab="shop" onclick="switchTab(this)" id="btn-tab-shop" style="display:none">
|
||
<i data-lucide="shopping-bag" style="width:15px;height:15px"></i> Магазин
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="gam" onclick="switchTab(this)" id="btn-tab-gam" style="display:none">
|
||
<i data-lucide="trophy" style="width:15px;height:15px"></i> Геймификация
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="sims" onclick="switchTab(this)" id="btn-tab-sims" style="display:none">
|
||
<i data-lucide="atom" 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> Игры
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="sublog" onclick="switchTab(this)">
|
||
<i data-lucide="file-x" style="width:15px;height:15px"></i> Журнал работ
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="topics" onclick="switchTab(this)">
|
||
<i data-lucide="list-tree" style="width:15px;height:15px"></i> Темы
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="broadcast" onclick="switchTab(this)">
|
||
<i data-lucide="megaphone" style="width:15px;height:15px"></i> Рассылка
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="audit" onclick="switchTab(this)">
|
||
<i data-lucide="scroll-text" style="width:15px;height:15px"></i> Аудит-лог
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="errors" onclick="switchTab(this)">
|
||
<i data-lucide="bug" style="width:15px;height:15px"></i> Ошибки
|
||
</button>
|
||
<button class="admin-nav-item" data-tab="health" onclick="switchTab(this)">
|
||
<i data-lucide="activity" style="width:15px;height:15px"></i> Здоровье
|
||
</button>
|
||
|
||
</nav>
|
||
<div class="admin-main">
|
||
|
||
<!-- ── Статистика ── -->
|
||
<div class="tab-pane active" id="tab-stats">
|
||
<div class="section-title">Общая статистика</div>
|
||
<div class="stats-grid" id="stats-grid"><div class="spinner"></div></div>
|
||
<div class="section-title">По предметам</div>
|
||
<div class="subj-stats" id="subj-stats"><div class="spinner"></div></div>
|
||
</div>
|
||
|
||
<!-- ── Вопросы ── -->
|
||
<div class="tab-pane" id="tab-questions">
|
||
<div class="t-toolbar">
|
||
<select class="t-select" id="q-subject" onchange="onQSubjectChange()">
|
||
<option value="">Все предметы</option>
|
||
<option value="bio">Биология</option>
|
||
<option value="chem">Химия</option>
|
||
<option value="math">Математика</option>
|
||
<option value="phys">Физика</option>
|
||
</select>
|
||
<select class="t-select" id="q-topic" onchange="loadQuestions()">
|
||
<option value="">Все темы</option>
|
||
</select>
|
||
<select class="t-select" id="q-sort" onchange="loadQuestions()">
|
||
<option value="date_desc">Новые сначала</option>
|
||
<option value="date_asc">Старые сначала</option>
|
||
<option value="diff_asc">Сложность <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></option>
|
||
<option value="diff_desc">Сложность <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg></option>
|
||
</select>
|
||
<input class="t-input" id="q-search" type="text" placeholder="Поиск по тексту…" oninput="renderQuestions()" />
|
||
<span class="t-count" id="q-count"></span>
|
||
<button class="btn-add" onclick="openQModal()">+ Добавить вопрос</button>
|
||
<button class="btn-add" style="background:var(--grad-1)" onclick="document.getElementById('csv-file-input').click()"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Импорт CSV</button>
|
||
<input type="file" id="csv-file-input" accept=".csv,.txt" style="display:none" onchange="importCSVFile(this)" />
|
||
<a id="csv-template-link" href="#" onclick="downloadCSVTemplate(event)" style="font-size:0.78rem;color:var(--violet);text-decoration:none;margin-left:4px"><i data-lucide="file-text" style="width:12px;height:12px;vertical-align:-2px"></i> Шаблон</a>
|
||
</div>
|
||
<div id="q-list-wrap"><div class="empty">Выберите предмет или загрузите все вопросы</div></div>
|
||
</div>
|
||
|
||
<!-- ── Тесты (шаблоны) ── -->
|
||
<div class="tab-pane" id="tab-tests">
|
||
<div class="t-toolbar">
|
||
<select class="t-select" id="tst-subj" onchange="loadTests()">
|
||
<option value="">Все предметы</option>
|
||
<option value="bio">Биология</option>
|
||
<option value="chem">Химия</option>
|
||
<option value="math">Математика</option>
|
||
<option value="phys">Физика</option>
|
||
</select>
|
||
<input class="t-input" id="tst-search" type="text" placeholder="Поиск по названию…" oninput="renderTests()" />
|
||
<span class="t-count" id="tst-count"></span>
|
||
<button class="btn-add" onclick="openTstModal()">+ Создать тест</button>
|
||
</div>
|
||
<div id="tst-list-wrap"><div class="empty">Загрузка…</div></div>
|
||
</div>
|
||
|
||
<!-- ── Задания ── -->
|
||
<div class="tab-pane" id="tab-assignments">
|
||
<div class="t-toolbar" style="margin-bottom:12px">
|
||
<input class="t-input" id="a-search" type="text" placeholder="Поиск по названию…" oninput="renderAssignments()" style="flex:1;max-width:280px" />
|
||
<select class="t-select" id="a-subject" onchange="renderAssignments()">
|
||
<option value="">Все предметы</option>
|
||
<option value="bio">Биология</option>
|
||
<option value="chem">Химия</option>
|
||
<option value="math">Математика</option>
|
||
<option value="phys">Физика</option>
|
||
</select>
|
||
<span class="t-count" id="a-count" style="margin-left:0"></span>
|
||
</div>
|
||
<div class="a-summary" id="a-summary"></div>
|
||
<div class="a-filter-row">
|
||
<button class="a-f-chip active" onclick="setAFilter('all')">Все</button>
|
||
<button class="a-f-chip" onclick="setAFilter('active')">Активные</button>
|
||
<button class="a-f-chip" onclick="setAFilter('overdue')">Просрочены</button>
|
||
<button class="a-f-chip" onclick="setAFilter('done')">Завершены</button>
|
||
<div class="a-filter-sep"></div>
|
||
<select class="a-sort-sel" id="a-sort" onchange="renderAssignments()">
|
||
<option value="date">По дате <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg></option>
|
||
<option value="deadline">По дедлайну <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></option>
|
||
<option value="progress_asc">Прогресс <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></option>
|
||
<option value="progress_desc">Прогресс <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg></option>
|
||
</select>
|
||
<a class="btn-add" href="/classes" style="text-decoration:none;margin-left:auto">Перейти в классы <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></a>
|
||
</div>
|
||
<div class="a-rows" id="a-body"><div class="spinner"></div></div>
|
||
</div>
|
||
|
||
<!-- ── Доступные тесты (настройки предметов) ── -->
|
||
<div class="tab-pane" id="tab-subjects">
|
||
<div class="section-title" style="margin-bottom:6px">Настройка доступных тестов</div>
|
||
<div class="perm-desc" style="margin-bottom:20px">Настройте что увидят ученики на дашборде: режим, количество вопросов, источник.</div>
|
||
<div class="sc-list" id="subj-config-list"></div>
|
||
</div>
|
||
|
||
<!-- ── Пользователи ── -->
|
||
<div class="tab-pane" id="tab-users">
|
||
<div class="section-title">Пользователи</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Пользователь</th><th>Роль</th><th>Тестов</th><th>Средний %</th><th>Регистрация</th><th>Посл. вход</th><th></th></tr></thead>
|
||
<tbody id="users-body"><tr><td colspan="7"><div class="spinner"></div></td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="user-panel" id="user-panel">
|
||
<div class="user-panel-header">
|
||
<div><div class="user-panel-name" id="up-name"></div><div class="user-panel-email" id="up-email"></div></div>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<button class="btn-edit-q" id="up-edit-btn" onclick="openEditUserModal()" style="display:none"><i data-lucide="pencil" style="width:13px;height:13px;vertical-align:-2px"></i> Изменить</button>
|
||
<button class="btn-edit-q" id="up-perms-btn" onclick="openUserPermsModal()" style="display:none"><i data-lucide="shield" style="width:13px;height:13px;vertical-align:-2px"></i> Права</button>
|
||
<button class="btn-del-q" id="up-clear-btn" onclick="clearUserHistory()" style="display:none"><i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i> История</button>
|
||
<button class="btn-del-q" id="up-ban-btn" onclick="toggleBanUser()" style="display:none"><i data-lucide="ban" style="width:13px;height:13px;vertical-align:-2px"></i> <span id="up-ban-label">Заблокировать</span></button>
|
||
<button class="btn-del-q" id="up-delete-btn" onclick="confirmDeleteUser()" style="display:none;background:rgba(239,68,68,.12);color:#EF4444;border-color:rgba(239,68,68,.25)"><i data-lucide="user-x" style="width:13px;height:13px;vertical-align:-2px"></i> Удалить</button>
|
||
<button class="btn-close" onclick="closeUserPanel()"><i data-lucide="x" style="width:13px;height:13px;vertical-align:-2px"></i> Закрыть</button>
|
||
</div>
|
||
</div>
|
||
<div class="section-title">История тестов</div>
|
||
<div id="up-sessions"><div class="spinner"></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Тесты (сессии) ── -->
|
||
<div class="tab-pane" id="tab-sessions">
|
||
<div class="t-toolbar">
|
||
<select class="t-select" id="t-subject" onchange="loadSessions()">
|
||
<option value="">Все предметы</option>
|
||
<option value="bio">Биология</option>
|
||
<option value="chem">Химия</option>
|
||
<option value="math">Математика</option>
|
||
<option value="phys">Физика</option>
|
||
</select>
|
||
<select class="t-select" id="t-mode" onchange="renderSessions()">
|
||
<option value="">Все режимы</option>
|
||
<option value="exam">Экзамен</option>
|
||
<option value="practice">Тренировка</option>
|
||
<option value="repeat">Обычный</option>
|
||
<option value="ct">ЦТ/ЦЭ</option>
|
||
</select>
|
||
<input class="t-input" id="t-search" type="text" placeholder="Поиск по имени…" oninput="renderSessions()" />
|
||
<span class="t-count" id="t-count"></span>
|
||
</div>
|
||
<div id="t-body"><div class="spinner"></div></div>
|
||
</div>
|
||
|
||
<!-- ── Онлайн-уроки ── -->
|
||
<div class="tab-pane" id="tab-classroom">
|
||
|
||
<!-- Module master toggle -->
|
||
<div style="display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1.5px solid var(--border-h);border-radius:var(--r-lg);padding:20px 24px;margin-bottom:32px">
|
||
<div>
|
||
<div style="font-size:0.97rem;font-weight:700;margin-bottom:4px">Модуль онлайн-уроков</div>
|
||
<div class="perm-desc" style="margin:0">Если отключить, учителя не смогут создавать новые уроки. Уже активные сессии продолжат работу до завершения.</div>
|
||
</div>
|
||
<label class="perm-toggle" id="cr-master-lbl" title="Включить / выключить модуль" style="margin-left:24px;flex-shrink:0">
|
||
<input type="checkbox" id="cr-master-chk" onchange="crMasterToggle(this.checked)" checked />
|
||
<span class="perm-track"></span>
|
||
<span class="perm-thumb"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Active sessions -->
|
||
<div class="cr-admin-section">
|
||
<div class="cr-admin-section-title">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>
|
||
Активные уроки
|
||
<span id="cr-live-refresh-btn" style="font-size:0.76rem;font-weight:600;color:var(--violet);cursor:pointer;text-transform:none;letter-spacing:0;margin-left:-4px" onclick="loadCrActiveSessions()">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||
Обновить
|
||
</span>
|
||
</div>
|
||
<div id="cr-live-list"><div class="spinner"></div></div>
|
||
</div>
|
||
|
||
<!-- Session history -->
|
||
<div class="cr-admin-section">
|
||
<div class="cr-admin-section-title">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||
История уроков
|
||
</div>
|
||
<div class="cr-hist-toolbar">
|
||
<input class="cr-hist-search" id="cr-hist-q" type="text" placeholder="Поиск по теме или учителю…" oninput="crHistDebounce()">
|
||
<span class="cr-hist-count" id="cr-hist-count"></span>
|
||
</div>
|
||
<div id="cr-hist-list"><div class="spinner"></div></div>
|
||
<div id="cr-hist-pagination"></div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ── Права доступа ── -->
|
||
<div class="tab-pane" id="tab-permissions">
|
||
<div class="perm-header">
|
||
<div class="section-title" style="margin:0">Права доступа по ролям</div>
|
||
<p style="color:var(--muted);font-size:13px;margin:4px 0 0">Настройте, что могут делать учителя и ученики. Администраторы имеют все права всегда.</p>
|
||
</div>
|
||
|
||
<div class="perm-role-block">
|
||
<div class="perm-role-title">
|
||
<span class="badge badge-warn" style="font-size:13px;padding:4px 12px">Учитель</span>
|
||
</div>
|
||
<div class="perm-grid" id="perm-teacher"></div>
|
||
</div>
|
||
|
||
<div class="perm-role-block">
|
||
<div class="perm-role-title">
|
||
<span class="badge badge-info" style="font-size:13px;padding:4px 12px">Ученик</span>
|
||
</div>
|
||
<div class="perm-grid" id="perm-student"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Магазин ── -->
|
||
<div class="tab-pane" id="tab-shop">
|
||
<div class="section-title">Магазин</div>
|
||
<div class="stats-grid" id="shop-stats-grid"><div class="spinner"></div></div>
|
||
|
||
<div class="section-title" style="margin-top:32px">Товары</div>
|
||
<div style="margin-bottom:14px">
|
||
<button class="btn-add" onclick="shopAdminCreateItem()">+ Добавить товар</button>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr>
|
||
<th>ID</th><th>Название</th><th>Тип</th><th>Цена</th><th>Продано</th><th>Активен</th><th>Действия</th>
|
||
</tr></thead>
|
||
<tbody id="shop-items-body"><tr><td colspan="7"><div class="spinner"></div></td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="adm-panel" id="shop-item-form" style="display:none">
|
||
<div class="adm-panel-title" id="shop-form-title">Новый товар</div>
|
||
<div class="adm-form-row">
|
||
<div class="adm-form-group" style="flex:1">
|
||
<label>Название</label>
|
||
<input type="text" id="shop-f-name" placeholder="Название товара" />
|
||
</div>
|
||
<div class="adm-form-group" style="flex:1">
|
||
<label>Тип</label>
|
||
<select id="shop-f-type">
|
||
<option value="frame">Рамка</option>
|
||
<option value="title">Титул</option>
|
||
<option value="theme">Тема</option>
|
||
<option value="effect">Эффект</option>
|
||
</select>
|
||
</div>
|
||
<div class="adm-form-group" style="width:100px">
|
||
<label>Цена</label>
|
||
<input type="number" id="shop-f-price" min="0" value="100" />
|
||
</div>
|
||
</div>
|
||
<div class="adm-form-row">
|
||
<div class="adm-form-group" style="flex:1">
|
||
<label>Описание</label>
|
||
<textarea id="shop-f-desc" rows="2" placeholder="Описание товара"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="adm-form-row">
|
||
<div class="adm-form-group" style="flex:1">
|
||
<label>Иконка (emoji/код)</label>
|
||
<input type="text" id="shop-f-icon" placeholder="SVG-код или эмодзи" />
|
||
</div>
|
||
<div class="adm-form-group" style="flex:1">
|
||
<label>Данные (JSON)</label>
|
||
<input type="text" id="shop-f-data" placeholder='{"key":"value"}' />
|
||
</div>
|
||
<div class="adm-form-group">
|
||
<label>Активен</label>
|
||
<label class="adm-toggle">
|
||
<input type="checkbox" id="shop-f-active" checked />
|
||
<span class="track"></span><span class="thumb"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:10px">
|
||
<button class="adm-btn adm-btn-primary" onclick="shopAdminSaveItem()">Сохранить</button>
|
||
<button class="adm-btn" style="background:var(--border-h);color:var(--text-3)" onclick="shopAdminCancelForm()">Отмена</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-title" style="margin-top:32px">Начислить монеты</div>
|
||
<div class="adm-panel">
|
||
<div class="adm-form-row">
|
||
<div class="adm-form-group adm-user-search" style="flex:1">
|
||
<label>Пользователь</label>
|
||
<input type="text" id="shop-award-user" placeholder="Поиск по имени…" autocomplete="off" oninput="shopSearchUser(this.value)" />
|
||
<div class="us-results" id="shop-award-results"></div>
|
||
<input type="hidden" id="shop-award-uid" />
|
||
</div>
|
||
<div class="adm-form-group" style="width:120px">
|
||
<label>Кол-во монет</label>
|
||
<input type="number" id="shop-award-amount" min="1" value="10" />
|
||
</div>
|
||
<div class="adm-form-group" style="flex:1">
|
||
<label>Причина</label>
|
||
<input type="text" id="shop-award-reason" placeholder="За активность" />
|
||
</div>
|
||
<button class="adm-btn adm-btn-primary" onclick="shopAdminAwardCoins()" style="align-self:flex-end">Начислить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Геймификация ── -->
|
||
<div class="tab-pane" id="tab-gam">
|
||
<div class="section-title">Геймификация</div>
|
||
<div class="stats-grid" id="gam-stats-grid"><div class="spinner"></div></div>
|
||
|
||
<div class="section-title" style="margin-top:32px">Топ-10 по XP</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr>
|
||
<th>#</th><th>Имя</th><th>XP</th><th>Уровень</th><th>Монеты</th>
|
||
</tr></thead>
|
||
<tbody id="gam-top-body"><tr><td colspan="5"><div class="spinner"></div></td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="section-title" style="margin-top:32px">Покупки в магазине</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr>
|
||
<th>Время</th><th>Пользователь</th><th>Предмет</th><th>Тип</th><th>Цена</th>
|
||
</tr></thead>
|
||
<tbody id="gam-purchases-body"><tr><td colspan="5"><div class="spinner"></div></td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="section-title" style="margin-top:32px">Последние начисления XP</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr>
|
||
<th>Время</th><th>Имя</th><th>XP</th><th>Причина</th>
|
||
</tr></thead>
|
||
<tbody id="gam-log-body"><tr><td colspan="4"><div class="spinner"></div></td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="section-title" style="margin-top:32px">Начислить XP / Монеты</div>
|
||
<div class="adm-panel">
|
||
<div class="adm-form-row">
|
||
<div class="adm-form-group adm-user-search" style="flex:1">
|
||
<label>Пользователь</label>
|
||
<input type="text" id="gam-award-user" placeholder="Поиск по имени…" autocomplete="off" oninput="gamSearchUser(this.value,'gam-award')" />
|
||
<div class="us-results" id="gam-award-results"></div>
|
||
<input type="hidden" id="gam-award-uid" />
|
||
</div>
|
||
<div class="adm-form-group" style="width:100px">
|
||
<label>XP</label>
|
||
<input type="number" id="gam-award-xp" min="0" value="10" />
|
||
</div>
|
||
<div class="adm-form-group" style="width:100px">
|
||
<label>Монеты</label>
|
||
<input type="number" id="gam-award-coins" min="0" value="0" />
|
||
</div>
|
||
<div class="adm-form-group" style="flex:1">
|
||
<label>Причина</label>
|
||
<input type="text" id="gam-award-reason" placeholder="За участие" />
|
||
</div>
|
||
<button class="adm-btn adm-btn-primary" onclick="gamAdminAward()" style="align-self:flex-end">Начислить</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-title" style="margin-top:32px">Сбросить прогресс пользователя</div>
|
||
<div class="adm-panel">
|
||
<div class="adm-form-row">
|
||
<div class="adm-form-group adm-user-search" style="flex:1">
|
||
<label>Пользователь</label>
|
||
<input type="text" id="gam-reset-user" placeholder="Поиск по имени…" autocomplete="off" oninput="gamSearchUser(this.value,'gam-reset')" />
|
||
<div class="us-results" id="gam-reset-results"></div>
|
||
<input type="hidden" id="gam-reset-uid" />
|
||
</div>
|
||
<button class="adm-btn adm-btn-danger" onclick="gamAdminReset()" style="align-self:flex-end">Сбросить прогресс</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Шаблоны ── -->
|
||
<div class="tab-pane" id="tab-tpl">
|
||
<div class="section-title">Шаблоны курсов</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr>
|
||
<th>ID</th><th>Название</th><th>Предмет</th><th>Категория</th><th>Автор</th><th>Публичный</th><th>Действия</th>
|
||
</tr></thead>
|
||
<tbody id="tpl-course-body"><tr><td colspan="7"><div class="spinner"></div></td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="section-title" style="margin-top:32px">Шаблоны уроков</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr>
|
||
<th>ID</th><th>Название</th><th>Предмет</th><th>Категория</th><th>Автор</th><th>Публичный</th><th>Действия</th>
|
||
</tr></thead>
|
||
<tbody id="tpl-lesson-body"><tr><td colspan="7"><div class="spinner"></div></td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tab-pane" id="tab-sims">
|
||
<div class="section-title">Управление симуляциями</div>
|
||
|
||
<!-- Master toggle -->
|
||
<div class="perm-role-block" style="margin-bottom:24px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px">
|
||
<div>
|
||
<div class="perm-label" style="font-size:15px">Модуль симуляций</div>
|
||
<div class="perm-desc">Отключить полностью — страница «Лаборатория» станет недоступна для всех пользователей</div>
|
||
</div>
|
||
<label class="perm-toggle" id="sims-master-lbl" title="Включить / выключить весь модуль">
|
||
<input type="checkbox" id="sims-master-chk" onchange="simsMasterToggle(this.checked)" />
|
||
<span class="perm-track"></span>
|
||
<span class="perm-thumb"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Per-sim grid -->
|
||
<div class="perm-desc" style="margin-bottom:16px">Отключённые симуляции не отображаются в лаборатории. Симуляции в статусе «скоро» не показываются независимо от этой настройки.</div>
|
||
<div class="perm-grid" id="sims-grid">
|
||
<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="perm-grid" id="games-features-grid">
|
||
<div style="color:var(--muted);font-size:0.84rem">Загрузка…</div>
|
||
</div>
|
||
|
||
<div class="section-title" style="margin-top:32px">Модули для «Своб. ученика»</div>
|
||
<div class="perm-desc" style="margin-bottom:20px">Отключённые модули скрываются только для пользователей с ролью <b>Своб. ученик</b>. Глобальные настройки выше применяются поверх этих.</div>
|
||
<div class="perm-grid" id="fs-features-grid">
|
||
<div style="color:var(--muted);font-size:0.84rem">Загрузка…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Журнал удалённых работ ── -->
|
||
<div class="tab-pane" id="tab-sublog">
|
||
<div class="section-title">Журнал удалённых работ</div>
|
||
<div class="perm-desc" style="margin-bottom:16px">Все удалённые работы учеников записываются сюда. Данные сохраняются даже после удаления файлов.</div>
|
||
<div class="sl-filter-row">
|
||
<select class="sl-filter-select" id="sublog-class-filter" onchange="loadSubmissionLog()">
|
||
<option value="">Все классы</option>
|
||
</select>
|
||
<span class="sl-count" id="sublog-count"></span>
|
||
<button class="btn-del-q" id="btn-clear-sublog" style="display:none;margin-left:auto" onclick="clearSubmissionLog()">
|
||
<i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i> Очистить журнал
|
||
</button>
|
||
</div>
|
||
<div id="sublog-list"></div>
|
||
</div>
|
||
|
||
<!-- ── Темы ── -->
|
||
<div class="tab-pane" id="tab-topics">
|
||
<div class="section-title">Управление темами</div>
|
||
<div style="display:flex;gap:12px;margin-bottom:18px;flex-wrap:wrap;align-items:center">
|
||
<select class="t-select" id="topics-subj-filter" onchange="loadTopics()" style="max-width:220px"></select>
|
||
<button class="adm-btn adm-btn-primary adm-btn-small" onclick="showAddTopic()">+ Добавить тему</button>
|
||
<span class="sl-count" id="topics-count"></span>
|
||
</div>
|
||
<div id="topics-add-row" style="display:none;margin-bottom:16px">
|
||
<div class="adm-panel" style="padding:16px 20px">
|
||
<div class="adm-form-row" style="margin:0">
|
||
<div class="adm-form-group" style="flex:1"><label>Название</label><input type="text" id="topics-new-name" placeholder="Название темы" /></div>
|
||
<button class="adm-btn adm-btn-primary adm-btn-small" onclick="createTopic()" style="align-self:flex-end">Создать</button>
|
||
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-3);align-self:flex-end" onclick="document.getElementById('topics-add-row').style.display='none'">Отмена</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="topics-list"></div>
|
||
</div>
|
||
|
||
<!-- ── Рассылка ── -->
|
||
<div class="tab-pane" id="tab-broadcast">
|
||
<div class="section-title">Рассылка уведомлений</div>
|
||
<div class="adm-panel">
|
||
<div class="adm-form-row">
|
||
<div class="adm-form-group" style="flex:1">
|
||
<label>Сообщение</label>
|
||
<textarea id="bc-message" rows="3" placeholder="Текст уведомления (макс. 500 символов)" maxlength="500"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="adm-form-row">
|
||
<div class="adm-form-group" style="width:200px">
|
||
<label>Кому</label>
|
||
<select id="bc-role">
|
||
<option value="all">Всем пользователям</option>
|
||
<option value="student">Только ученикам</option>
|
||
<option value="teacher">Только учителям</option>
|
||
<option value="free_student">Свободным ученикам</option>
|
||
</select>
|
||
</div>
|
||
<div class="adm-form-group" style="flex:1">
|
||
<label>Ссылка (необязательно)</label>
|
||
<input type="text" id="bc-link" placeholder="/dashboard" />
|
||
</div>
|
||
<button class="adm-btn adm-btn-primary" onclick="sendBroadcast()" style="align-self:flex-end">Отправить</button>
|
||
</div>
|
||
<div id="bc-result" style="font-size:0.85rem;color:var(--green);margin-top:8px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Аудит-лог ── -->
|
||
<div class="tab-pane" id="tab-audit">
|
||
<div class="section-title" style="display:flex;align-items:center;justify-content:space-between">
|
||
Журнал действий администраторов
|
||
<button class="adm-btn adm-btn-danger adm-btn-small" onclick="clearAuditLog()">Очистить</button>
|
||
</div>
|
||
<div id="audit-list"></div>
|
||
</div>
|
||
|
||
<!-- ── Ошибки ── -->
|
||
<div class="tab-pane" id="tab-errors">
|
||
<div class="section-title" style="display:flex;align-items:center;justify-content:space-between">
|
||
Журнал ошибок сервера
|
||
<button class="adm-btn adm-btn-danger adm-btn-small" onclick="clearErrorLog()">Очистить</button>
|
||
</div>
|
||
<div id="errors-list"></div>
|
||
</div>
|
||
|
||
<!-- ── Здоровье системы ── -->
|
||
<div class="tab-pane" id="tab-health">
|
||
<div class="section-title">Здоровье системы</div>
|
||
<div id="health-content"></div>
|
||
</div>
|
||
|
||
</div><!-- /admin-main -->
|
||
</div><!-- /admin-layout -->
|
||
</div><!-- /container -->
|
||
|
||
<!-- ═══ MODAL ПРАВ ПОЛЬЗОВАТЕЛЯ ════════════════════════════════════ -->
|
||
<div class="q-modal" id="up-modal" onclick="if(event.target===this)closeUserPermsModal()">
|
||
<div class="q-modal-box" style="max-width:520px">
|
||
<div class="q-modal-title" id="up-modal-title">Права пользователя</div>
|
||
<p style="font-size:12.5px;color:var(--muted);margin:-8px 0 16px">Индивидуальные настройки переопределяют права роли для этого учителя.</p>
|
||
<div id="up-modal-list" style="display:flex;flex-direction:column;gap:8px;max-height:420px;overflow-y:auto;padding-right:4px"></div>
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:20px;gap:12px">
|
||
<button class="btn-del-q" onclick="doResetAllUserPerms()" id="up-modal-reset-btn">
|
||
<i data-lucide="rotate-ccw" style="width:13px;height:13px;vertical-align:-2px"></i> Сбросить всё по умолчанию
|
||
</button>
|
||
<button class="btn-close" onclick="closeUserPermsModal()">Закрыть</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ MODAL РЕДАКТИРОВАНИЯ ПОЛЬЗОВАТЕЛЯ ═══════════════════════════ -->
|
||
<div class="q-modal" id="eu-modal" onclick="if(event.target===this)closeEditUserModal()">
|
||
<div class="q-modal-box" style="max-width:460px">
|
||
<div class="q-modal-title">Редактировать пользователя</div>
|
||
<div class="form-row">
|
||
<label class="form-label">Имя *</label>
|
||
<input type="text" class="form-ctrl" id="eu-name" placeholder="Имя пользователя" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="form-label">Email *</label>
|
||
<input type="email" class="form-ctrl" id="eu-email" placeholder="email@example.com" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="form-label">Новый пароль</label>
|
||
<input type="password" class="form-ctrl" id="eu-password" placeholder="Оставьте пустым, чтобы не менять (мин. 6 символов)" />
|
||
</div>
|
||
<div class="form-error" id="eu-error"></div>
|
||
<div class="modal-footer">
|
||
<button class="btn-cancel2" onclick="closeEditUserModal()">Отмена</button>
|
||
<button class="btn-save" id="eu-save" onclick="saveEditUser()">Сохранить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ MODAL РЕДАКТОРА ВОПРОСА ══════════════════════════════════════ -->
|
||
<div class="q-modal" id="q-modal" onclick="if(event.target===this)closeQModal()">
|
||
<div class="q-modal-box">
|
||
<div class="q-modal-title" id="q-modal-title">Добавить вопрос</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Тип вопроса</label>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap" id="qf-type-btns">
|
||
<button type="button" class="type-btn active" data-type="single" onclick="setQType('single')"><i data-lucide="circle-dot" style="width:12px;height:12px;vertical-align:-1px"></i> Один ответ</button>
|
||
<button type="button" class="type-btn" data-type="multi" onclick="setQType('multi')"><i data-lucide="list-checks" style="width:12px;height:12px;vertical-align:-1px"></i> Несколько ответов</button>
|
||
<button type="button" class="type-btn" data-type="true_false" onclick="setQType('true_false')"><i data-lucide="check-circle-2" style="width:12px;height:12px;vertical-align:-1px"></i> Верно/Неверно</button>
|
||
<button type="button" class="type-btn" data-type="short_answer" onclick="setQType('short_answer')"><i data-lucide="pencil" style="width:12px;height:12px;vertical-align:-1px"></i> Краткий ответ</button>
|
||
<button type="button" class="type-btn" data-type="matching" onclick="setQType('matching')"><i data-lucide="git-compare" style="width:12px;height:12px;vertical-align:-1px"></i> Сопоставление</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row-3">
|
||
<div>
|
||
<label class="form-label">Предмет *</label>
|
||
<select class="form-ctrl" id="qf-subject" onchange="loadQModalTopics()">
|
||
<option value="">— выберите —</option>
|
||
<option value="bio">Биология</option>
|
||
<option value="chem">Химия</option>
|
||
<option value="math">Математика</option>
|
||
<option value="phys">Физика</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Тема</label>
|
||
<input type="text" class="form-ctrl" id="qf-topic-text" placeholder="Введите или выберите…" list="qf-topic-list" autocomplete="off" />
|
||
<datalist id="qf-topic-list"></datalist>
|
||
<div class="form-hint">Введите новую — создастся автоматически</div>
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Сложность</label>
|
||
<select class="form-ctrl" id="qf-difficulty">
|
||
<option value="1">1 — Лёгкий</option>
|
||
<option value="2" selected>2 — Средний</option>
|
||
<option value="3">3 — Сложный</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="formula-bar" id="formula-bar">
|
||
<span style="font-size:0.7rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.05em">Формулы:</span>
|
||
<button type="button" class="fml" onclick="ins('\\frac{a}{b}')" title="Дробь">ᵃ⁄ᵦ</button>
|
||
<button type="button" class="fml" onclick="ins('\\sqrt{x}')" title="Корень">√</button>
|
||
<button type="button" class="fml" onclick="ins('x^{n}')" title="Степень">xⁿ</button>
|
||
<button type="button" class="fml" onclick="ins('x_{n}')" title="Индекс">xₙ</button>
|
||
<button type="button" class="fml" onclick="ins('\\pi')" title="Пи">π</button>
|
||
<button type="button" class="fml" onclick="ins('\\alpha')" title="Альфа">α</button>
|
||
<button type="button" class="fml" onclick="ins('\\beta')" title="Бета">β</button>
|
||
<button type="button" class="fml" onclick="ins('\\theta')" title="Тета">θ</button>
|
||
<button type="button" class="fml" onclick="ins('\\infty')" title="Бесконечность">∞</button>
|
||
<button type="button" class="fml" onclick="ins('\\leq')" title="≤">≤</button>
|
||
<button type="button" class="fml" onclick="ins('\\geq')" title="≥">≥</button>
|
||
<button type="button" class="fml" onclick="ins('\\neq')" title="≠">≠</button>
|
||
<button type="button" class="fml" onclick="ins('\\pm')" title="±">±</button>
|
||
<button type="button" class="fml" onclick="ins('\\cdot')" title="·">·</button>
|
||
<button type="button" class="fml" onclick="ins('\\log_{a}{x}')" title="Логарифм">log</button>
|
||
<button type="button" class="fml" onclick="ins('\\sin')" title="Синус">sin</button>
|
||
<button type="button" class="fml" onclick="ins('\\cos')" title="Косинус">cos</button>
|
||
<button type="button" class="fml" onclick="wrapMath()" title="Обернуть в \(...\)" style="font-weight:800">\( \)</button>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Текст вопроса *</label>
|
||
<textarea class="form-ctrl" id="qf-text" rows="3" placeholder="Введите текст вопроса…" oninput="updateCharCounter(this, 'qf-text-cnt', 500)" maxlength="500"></textarea>
|
||
<div class="char-counter" id="qf-text-cnt">0 / 500</div>
|
||
</div>
|
||
|
||
<div class="q-preview-wrap" id="q-preview-wrap">
|
||
<div style="font-size:0.7rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px">Предпросмотр</div>
|
||
<div class="q-preview-text" id="q-preview-text">Введите текст вопроса…</div>
|
||
</div>
|
||
|
||
<div class="opts-header" id="qf-opts-header">
|
||
<span class="opts-label">Варианты ответов — отметьте правильный <svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor" stroke="none"/></svg></span>
|
||
</div>
|
||
<div class="opts-grid" id="qf-opts"></div>
|
||
<!-- short_answer input -->
|
||
<div id="qf-short-wrap" style="display:none;margin-bottom:18px">
|
||
<label class="form-label">Правильный ответ *</label>
|
||
<input type="text" class="form-ctrl" id="qf-correct-text" placeholder="Введите точный правильный ответ (регистр не важен)…" />
|
||
<div class="form-hint">Сравнение без учёта регистра и лишних пробелов</div>
|
||
</div>
|
||
<!-- matching pairs UI -->
|
||
<div id="qf-match-wrap" style="display:none;margin-bottom:18px">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:8px;align-items:center;margin-bottom:8px">
|
||
<span class="form-label" style="margin:0">Левая часть</span>
|
||
<span class="form-label" style="margin:0">Правая часть (пара)</span>
|
||
<span></span>
|
||
</div>
|
||
<div id="qf-match-rows"></div>
|
||
<button type="button" class="btn-add-opt" onclick="addMatchPair()">+ Добавить пару</button>
|
||
</div>
|
||
<button type="button" class="btn-add-opt" id="btn-add-opt" onclick="addOpt()">+ Добавить вариант</button>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Изображение к вопросу</label>
|
||
<div class="img-upload-row">
|
||
<button type="button" class="btn-img-upload" id="btn-img-upload" onclick="document.getElementById('qf-image-file').click()">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||
<span id="btn-img-upload-lbl">Загрузить</span>
|
||
</button>
|
||
<input type="file" id="qf-image-file" accept="image/*" style="display:none" onchange="handleImageFileSelect(this)">
|
||
<input type="text" class="form-ctrl" id="qf-image" placeholder="или вставьте URL" style="flex:1;min-width:0" />
|
||
</div>
|
||
<div id="qf-image-preview" style="display:none;margin-top:8px;position:relative;display:inline-block">
|
||
<img id="qf-image-img" style="max-width:100%;max-height:180px;border-radius:8px;border:1px solid var(--border);display:block" />
|
||
<button type="button" onclick="clearQuestionImage()" style="position:absolute;top:6px;right:6px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:50%;width:26px;height:26px;cursor:pointer;font-size:15px;line-height:26px;text-align:center"><svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Пояснение к правильному ответу</label>
|
||
<textarea class="form-ctrl" id="qf-explanation" rows="2" placeholder="Необязательно. Появится после ответа в режиме тренировки…"></textarea>
|
||
</div>
|
||
|
||
<div class="form-error" id="qf-error"></div>
|
||
<div class="modal-footer">
|
||
<button class="btn-cancel2" onclick="closeQModal()">Отмена</button>
|
||
<button class="btn-save" id="qf-save" onclick="saveQuestion()">Сохранить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ MODAL СОЗДАНИЯ ЗАДАНИЯ ═══════════════════════════════════════ -->
|
||
<div class="q-modal" id="ac-modal" onclick="if(event.target===this)closeCreateAModal()">
|
||
<div class="q-modal-box">
|
||
<div class="q-modal-title">Создать задание</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Назначить</label>
|
||
<div class="src-toggle">
|
||
<button type="button" class="type-btn active" data-actgt="class" onclick="setAcTarget('class')"><i data-lucide="users" style="width:12px;height:12px;vertical-align:-1px"></i> Классу</button>
|
||
<button type="button" class="type-btn" data-actgt="user" onclick="setAcTarget('user')"><i data-lucide="user" style="width:12px;height:12px;vertical-align:-1px"></i> Ученику</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="acf-class-field">
|
||
<div class="form-row">
|
||
<label class="form-label">Класс *</label>
|
||
<select class="form-ctrl" id="acf-class">
|
||
<option value="">— загрузка… —</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="acf-user-field" style="display:none">
|
||
<div class="form-row">
|
||
<label class="form-label">Ученик *</label>
|
||
<div style="position:relative">
|
||
<input type="text" class="form-ctrl" id="acf-student-search" placeholder="Поиск по имени или email…" autocomplete="off"
|
||
oninput="filterAcStudents(this.value)" onfocus="openAcStudentDrop()" onblur="setTimeout(closeAcStudentDrop,180)" />
|
||
<div id="acf-student-drop" style="display:none;position:absolute;top:100%;left:0;right:0;background:#fff;border:1px solid #d1d5db;border-radius:6px;max-height:180px;overflow-y:auto;z-index:200;box-shadow:0 4px 12px rgba(0,0,0,.12)"></div>
|
||
</div>
|
||
<div id="acf-student-selected" style="display:none;margin-top:6px;padding:6px 10px;background:#f0fdf4;border-radius:6px;font-size:13px;color:#166534"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Название задания *</label>
|
||
<input type="text" class="form-ctrl" id="acf-title" placeholder="Например: Контрольная работа по биологии" />
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Источник</label>
|
||
<div class="src-toggle">
|
||
<button type="button" class="type-btn active" data-src="random" onclick="setAcSrc('random')"><i data-lucide="shuffle" style="width:12px;height:12px;vertical-align:-1px"></i> Случайные</button>
|
||
<button type="button" class="type-btn" data-src="test" onclick="setAcSrc('test')"><i data-lucide="clipboard-list" style="width:12px;height:12px;vertical-align:-1px"></i> Тест</button>
|
||
<button type="button" class="type-btn" data-src="file" onclick="setAcSrc('file')"><i data-lucide="paperclip" style="width:12px;height:12px;vertical-align:-1px"></i> Файл</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Random source fields -->
|
||
<div id="acf-random-fields">
|
||
<div class="form-row-3">
|
||
<div>
|
||
<label class="form-label">Предмет *</label>
|
||
<select class="form-ctrl" id="acf-subject">
|
||
<option value="">— выберите —</option>
|
||
<option value="bio">Биология</option>
|
||
<option value="chem">Химия</option>
|
||
<option value="math">Математика</option>
|
||
<option value="phys">Физика</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Режим</label>
|
||
<select class="form-ctrl" id="acf-mode">
|
||
<option value="exam">Экзамен (1 раз, таймер)</option>
|
||
<option value="repeat">Обычный (многократно)</option>
|
||
<option value="ct">ЦТ/ЦЭ (A+Б части)</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Кол-во вопросов</label>
|
||
<input type="number" class="form-ctrl" id="acf-count" min="5" max="100" value="25" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Test source fields -->
|
||
<div id="acf-test-fields" style="display:none">
|
||
<div class="form-row-2">
|
||
<div>
|
||
<label class="form-label">Тест *</label>
|
||
<select class="form-ctrl" id="acf-test">
|
||
<option value="">— загрузка тестов… —</option>
|
||
</select>
|
||
<div class="form-hint">Вопросы берутся из теста в заданном порядке</div>
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Режим</label>
|
||
<select class="form-ctrl" id="acf-mode-test">
|
||
<option value="exam">Экзамен (1 раз, таймер)</option>
|
||
<option value="repeat">Обычный (многократно)</option>
|
||
<option value="ct">ЦТ/ЦЭ (A+Б части)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- File source fields -->
|
||
<div id="acf-file-fields" style="display:none">
|
||
<div class="form-row">
|
||
<label class="form-label">Файл из библиотеки *</label>
|
||
<input type="text" class="form-ctrl" id="acf-file-search" placeholder="Поиск по названию…" oninput="filterAcFiles(this.value)" />
|
||
<div id="acf-file-list" style="max-height:180px;overflow-y:auto;border:1.5px solid rgba(15,23,42,0.15);border-radius:8px;margin-top:6px"></div>
|
||
<div id="acf-file-selected" style="display:none;margin-top:6px;font-size:0.82rem;color:var(--violet);font-weight:600"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Дедлайн (необязательно)</label>
|
||
<input type="date" class="form-ctrl" id="acf-deadline" style="max-width:220px" />
|
||
</div>
|
||
|
||
<div class="form-error" id="acf-error"></div>
|
||
<div class="modal-footer">
|
||
<button class="btn-cancel2" onclick="closeCreateAModal()">Отмена</button>
|
||
<button class="btn-save" id="acf-save" onclick="saveNewAssignment()">Создать</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ MODAL РЕДАКТОРА ЗАДАНИЯ ══════════════════════════════════════ -->
|
||
<div class="q-modal" id="a-modal" onclick="if(event.target===this)closeAModal()">
|
||
<div class="q-modal-box">
|
||
<div class="q-modal-title" id="a-modal-title">Редактировать задание</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Название задания *</label>
|
||
<input type="text" class="form-ctrl" id="af-title" placeholder="Например: Итоговый тест по биологии" />
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Источник вопросов</label>
|
||
<div class="src-toggle">
|
||
<button type="button" class="type-btn active" data-afsrc="random" onclick="setAfSrc('random')"><i data-lucide="shuffle" style="width:12px;height:12px;vertical-align:-1px"></i> Случайные из предмета</button>
|
||
<button type="button" class="type-btn" data-afsrc="test" onclick="setAfSrc('test')"><i data-lucide="clipboard-list" style="width:12px;height:12px;vertical-align:-1px"></i> Готовый тест</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Random source fields -->
|
||
<div id="af-random-fields">
|
||
<div class="form-row-2">
|
||
<div>
|
||
<label class="form-label">Предмет *</label>
|
||
<select class="form-ctrl" id="af-subject">
|
||
<option value="">— выберите —</option>
|
||
<option value="bio">Биология</option>
|
||
<option value="chem">Химия</option>
|
||
<option value="math">Математика</option>
|
||
<option value="phys">Физика</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Режим</label>
|
||
<select class="form-ctrl" id="af-mode">
|
||
<option value="exam">Экзамен (1 раз, таймер)</option>
|
||
<option value="repeat">Обычный (многократно)</option>
|
||
<option value="ct">ЦТ/ЦЭ (A+Б части)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="form-label">Количество вопросов</label>
|
||
<input type="number" class="form-ctrl" id="af-count" min="5" max="100" value="25" style="max-width:160px" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Test source fields -->
|
||
<div id="af-test-fields" style="display:none">
|
||
<div class="form-row-2">
|
||
<div>
|
||
<label class="form-label">Тест *</label>
|
||
<select class="form-ctrl" id="af-test">
|
||
<option value="">— загрузка… —</option>
|
||
</select>
|
||
<div class="form-hint">Вопросы берутся из теста в заданном порядке</div>
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Режим</label>
|
||
<select class="form-ctrl" id="af-mode-test">
|
||
<option value="exam">Экзамен (1 раз, таймер)</option>
|
||
<option value="repeat">Обычный (многократно)</option>
|
||
<option value="ct">ЦТ/ЦЭ (A+Б части)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Дедлайн (необязательно)</label>
|
||
<input type="date" class="form-ctrl" id="af-deadline" style="max-width:220px" />
|
||
</div>
|
||
|
||
<div class="form-error" id="af-error"></div>
|
||
<div class="modal-footer">
|
||
<button class="btn-cancel2" onclick="closeAModal()">Отмена</button>
|
||
<button class="btn-save" id="af-save" onclick="saveAssignment()">Сохранить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ MODAL СОЗДАНИЯ/РЕДАКТИРОВАНИЯ ТЕСТА ════════════════════════ -->
|
||
<div class="q-modal" id="tst-modal" onclick="if(event.target===this)closeTstModal()">
|
||
<div class="q-modal-box" style="max-width:560px">
|
||
<div class="q-modal-title" id="tst-modal-title">Создать тест</div>
|
||
|
||
<div class="form-row-2">
|
||
<div>
|
||
<label class="form-label">Название *</label>
|
||
<input type="text" class="form-ctrl" id="tstf-title" placeholder="Название теста…" />
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Предмет *</label>
|
||
<select class="form-ctrl" id="tstf-subject">
|
||
<option value="">— выберите —</option>
|
||
<option value="bio">Биология</option>
|
||
<option value="chem">Химия</option>
|
||
<option value="math">Математика</option>
|
||
<option value="phys">Физика</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Описание (необязательно)</label>
|
||
<textarea class="form-ctrl" id="tstf-desc" rows="2" placeholder="Краткое описание теста…"></textarea>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">Лимит времени (мин)</label>
|
||
<input type="number" class="form-ctrl" id="tstf-time" min="1" max="600" placeholder="по умолчанию: кол-во вопросов × 1.5 мин" style="max-width:320px" />
|
||
<div class="form-hint">Оставьте пустым — время рассчитывается автоматически</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<label class="form-label">После завершения</label>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||
<button type="button" class="type-btn active" id="tstf-show-yes" onclick="setTstShowAnswers(true)"><i data-lucide="eye" style="width:12px;height:12px;vertical-align:-1px"></i> Показать ответы и решения</button>
|
||
<button type="button" class="type-btn" id="tstf-show-no" onclick="setTstShowAnswers(false)"><i data-lucide="eye-off" style="width:12px;height:12px;vertical-align:-1px"></i> Скрыть ответы</button>
|
||
</div>
|
||
<div class="form-hint">При «Скрыть» ученики видят только итоговый балл</div>
|
||
</div>
|
||
|
||
<div class="form-error" id="tstf-error"></div>
|
||
<div class="modal-footer">
|
||
<button class="btn-cancel2" onclick="closeTstModal()">Отмена</button>
|
||
<button class="btn-save" id="tstf-save" onclick="saveTst()">Сохранить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/js/api.js"></script>
|
||
<script>
|
||
const { user, isTeacher, isAdmin } = LS.initPage();
|
||
if (!isTeacher) { window.location.href = '/dashboard'; throw new Error(); }
|
||
document.getElementById('page-sub').textContent =
|
||
isAdmin ? 'Администратор · полный доступ' : 'Учитель · просмотр статистики';
|
||
|
||
if (isAdmin) {
|
||
['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = '';
|
||
});
|
||
document.getElementById('admin-nav-system-sep').style.display = '';
|
||
document.getElementById('admin-nav-system-label').style.display = '';
|
||
}
|
||
LS.showBoardIfAllowed();
|
||
const MODES = { exam:'Экзамен', practice:'Тренировка', repeat:'Обычный', ct:'ЦТ/ЦЭ', topic:'По теме', random:'Случайный' };
|
||
const DIFFS = { 1:'Лёгкий', 2:'Средний', 3:'Сложный' };
|
||
|
||
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} сек`;
|
||
}
|
||
|
||
/* ─── Tabs ─── */
|
||
let questionsInited = false, testsInited = false, assignmentsInited = false, usersInited = false, sessionsInited = false, subjectsInited = false, permissionsInited = false, shopInited = false, gamInited = false, tplInited = false, simsInited = false, gamesInited = false, sublogInited = false;
|
||
function switchTab(btn) {
|
||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||
document.querySelectorAll('.admin-nav-item').forEach(b => b.classList.remove('active'));
|
||
const name = btn.dataset.tab;
|
||
document.getElementById('tab-' + name).classList.add('active');
|
||
btn.classList.add('active');
|
||
if (name === 'questions' && !questionsInited) { questionsInited = true; loadQuestions(); }
|
||
if (name === 'tests' && !testsInited) { testsInited = true; loadTests(); }
|
||
if (name === 'assignments' && !assignmentsInited) { assignmentsInited = true; loadAssignments(); }
|
||
if (name === 'subjects' && !subjectsInited) { subjectsInited = true; loadSubjectConfig(); }
|
||
if (name === 'users' && !usersInited) { usersInited = true; loadUsers(); }
|
||
if (name === 'sessions' && !sessionsInited) { sessionsInited = true; loadSessions(); }
|
||
if (name === 'permissions' && !permissionsInited) { permissionsInited = true; loadPermissions(); }
|
||
if (name === 'shop' && !shopInited) { shopInited = true; loadShopAdmin(); }
|
||
if (name === 'gam' && !gamInited) { gamInited = true; loadGamAdmin(); }
|
||
if (name === 'tpl' && !tplInited) { tplInited = true; loadTplAdmin(); }
|
||
if (name === 'sims' && !simsInited) { simsInited = true; loadSimsAdmin(); }
|
||
if (name === 'games' && !gamesInited) { gamesInited = true; loadGamesAdmin(); loadFsFeatures(); }
|
||
if (name === 'sublog' && !sublogInited) { sublogInited = true; loadSubmissionLog(); }
|
||
}
|
||
|
||
/* Переход к вопросам конкретного предмета с открытием формы */
|
||
async function goAddQuestion(slug) {
|
||
// переключаем на вкладку Вопросы
|
||
const qBtn = document.querySelector('[data-tab="questions"]');
|
||
switchTab(qBtn);
|
||
// выставляем предмет в фильтре и обновляем список
|
||
document.getElementById('q-subject').value = slug;
|
||
if (!questionsInited) { questionsInited = true; }
|
||
await loadQuestions();
|
||
// открываем форму с предзаполненным предметом
|
||
openQModal();
|
||
document.getElementById('qf-subject').value = slug;
|
||
await loadQModalTopics();
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
СТАТИСТИКА
|
||
════════════════════════════════════════════════ */
|
||
async function loadStats() {
|
||
try {
|
||
const s = await LS.adminGetStats();
|
||
document.getElementById('stats-grid').innerHTML = `
|
||
<div class="stat-card" style="--stat-top:var(--violet)">
|
||
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="users" class="stat-icon"></i></div>
|
||
<div class="stat-val violet">${s.totalUsers}</div>
|
||
<div class="stat-label">Пользователей</div>
|
||
</div>
|
||
<div class="stat-card" style="--stat-top:var(--cyan)">
|
||
<div class="stat-card-icon" style="background:rgba(6,214,224,0.1)"><i data-lucide="file-text" class="stat-icon"></i></div>
|
||
<div class="stat-val cyan">${s.totalTests}</div>
|
||
<div class="stat-label">Тестов пройдено</div>
|
||
</div>
|
||
<div class="stat-card" style="--stat-top:var(--green)">
|
||
<div class="stat-card-icon" style="background:rgba(6,214,100,0.1)"><i data-lucide="target" class="stat-icon"></i></div>
|
||
<div class="stat-val green">${s.avgScore ?? '—'}%</div>
|
||
<div class="stat-label">Средний результат</div>
|
||
</div>`;
|
||
if (window.lucide) lucide.createIcons();
|
||
const subjEl = document.getElementById('subj-stats');
|
||
if (!s.bySubject?.length) { subjEl.innerHTML = '<div class="empty">Нет данных</div>'; return; }
|
||
subjEl.innerHTML = s.bySubject.map(b => {
|
||
const pct = b.avg_pct ?? 0;
|
||
const barColor = pct >= 75 ? 'var(--green)' : pct >= 50 ? 'var(--amber)' : 'var(--pink)';
|
||
return `<div class="subj-stat">
|
||
<div><div class="subj-stat-name">${esc(b.name)}</div><div class="subj-stat-info">${b.tests} тестов</div></div>
|
||
<div>
|
||
<div class="subj-stat-pct">${b.avg_pct ?? '—'}%</div>
|
||
<div style="width:60px;height:3px;background:rgba(15,23,42,0.06);border-radius:99px;margin-top:5px;overflow:hidden"><div style="width:${pct}%;height:100%;background:${barColor};border-radius:99px"></div></div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
document.getElementById('stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
РЕДАКТОР ВОПРОСОВ
|
||
════════════════════════════════════════════════ */
|
||
let allQuestions = [];
|
||
let editingQId = null;
|
||
let openQId = null;
|
||
let _topicMap = {}; // topic name (lower) <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> id, for current subject
|
||
|
||
/* ─── KaTeX rendering ─── */
|
||
const KATEX_OPTS = {
|
||
delimiters: [
|
||
{ left: '\\(', right: '\\)', display: false },
|
||
{ left: '\\[', right: '\\]', display: true },
|
||
],
|
||
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;
|
||
}
|
||
|
||
function updateCharCounter(el, cntId, max) {
|
||
const n = el.value.length;
|
||
const cnt = document.getElementById(cntId);
|
||
if (!cnt) return;
|
||
cnt.textContent = `${n} / ${max}`;
|
||
cnt.className = 'char-counter' + (n > max * 0.9 ? ' warn' : '') + (n >= max ? ' over' : '');
|
||
}
|
||
|
||
async function onQSubjectChange() {
|
||
const slug = document.getElementById('q-subject').value;
|
||
const sel = document.getElementById('q-topic');
|
||
sel.innerHTML = '<option value="">Все темы</option>';
|
||
if (slug) {
|
||
try {
|
||
const topics = await LS.getTopics(slug);
|
||
topics.forEach(t => sel.appendChild(new Option(t.name, t.id)));
|
||
} catch {}
|
||
}
|
||
loadQuestions();
|
||
}
|
||
|
||
async function loadQuestions() {
|
||
const subject = document.getElementById('q-subject').value;
|
||
const topic_id = document.getElementById('q-topic').value;
|
||
const sort = document.getElementById('q-sort').value;
|
||
const wrap = document.getElementById('q-list-wrap');
|
||
wrap.innerHTML = LS.skeleton(5);
|
||
try {
|
||
allQuestions = await LS.getQuestions(subject || null, topic_id || null, sort);
|
||
renderQuestions();
|
||
} catch (e) {
|
||
wrap.innerHTML = `<div class="error">Ошибка загрузки: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderQuestions() {
|
||
const search = document.getElementById('q-search').value.toLowerCase();
|
||
const filtered = search
|
||
? allQuestions.filter(q => q.text.toLowerCase().includes(search) || (q.topic||'').toLowerCase().includes(search))
|
||
: allQuestions;
|
||
|
||
document.getElementById('q-count').textContent = `${filtered.length} вопросов`;
|
||
|
||
if (!filtered.length) {
|
||
document.getElementById('q-list-wrap').innerHTML = '<div class="empty">Вопросов не найдено</div>';
|
||
return;
|
||
}
|
||
|
||
const wrap = document.getElementById('q-list-wrap');
|
||
wrap.innerHTML =
|
||
`<div class="q-list">${filtered.map(q => {
|
||
const diffCls = `diff-${q.difficulty}`;
|
||
const optsHtml = (q.options || []).map(o =>
|
||
`<div class="q-opt-row ${o.is_correct ? 'correct' : ''}">
|
||
<span class="q-opt-icon">${o.is_correct ? '<i data-lucide="check" style="width:13px;height:13px"></i>' : '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>'}</span>${esc(o.text)}
|
||
</div>`).join('');
|
||
const explHtml = q.explanation
|
||
? `<div class="q-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>` : '';
|
||
return `<div class="q-card" id="qcard-${q.id}">
|
||
<div class="q-card-head">
|
||
<span class="q-card-num">#${q.id}</span>
|
||
<div class="q-card-body" onclick="toggleQDetail(${q.id})">
|
||
<div class="q-card-text">${esc(q.text)}</div>
|
||
<div class="q-card-meta">
|
||
${q.subject_name ? `<span class="q-badge q-badge-subj">${esc(q.subject_name)}</span>` : ''}
|
||
${q.topic ? `<span class="q-badge q-badge-topic">${esc(q.topic)}</span>` : ''}
|
||
<span class="q-badge ${diffCls}">${DIFFS[q.difficulty]||q.difficulty}</span>
|
||
<span style="font-size:0.72rem;color:var(--text-3);background:rgba(15,23,42,0.05);padding:2px 7px;border-radius:999px">${{single:'Один',multi:'Несколько',true_false:'Верно/Неверно',short_answer:'Краткий',matching:'Сопост.'}[q.type]||q.type||'Один'}</span>
|
||
<span style="font-size:0.75rem;color:var(--text-3)">${q.options?.length||0} вар.</span>
|
||
</div>
|
||
</div>
|
||
<div class="q-card-actions">
|
||
<button class="btn-edit-q" onclick="editQ(${q.id})">Изменить</button>
|
||
<button class="btn-dup-q" onclick="dupQ(${q.id})" title="Дублировать">⧉</button>
|
||
<button class="btn-del-q" onclick="deleteQ(${q.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
|
||
</div>
|
||
</div>
|
||
<div class="q-card-detail" id="qdetail-${q.id}">
|
||
${optsHtml}${explHtml}
|
||
</div>
|
||
</div>`;
|
||
}).join('')}</div>`;
|
||
renderMath(wrap);
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
function toggleQDetail(id) {
|
||
if (openQId === id) {
|
||
document.getElementById('qdetail-' + id)?.classList.remove('open');
|
||
openQId = null; return;
|
||
}
|
||
if (openQId) document.getElementById('qdetail-' + openQId)?.classList.remove('open');
|
||
document.getElementById('qdetail-' + id)?.classList.add('open');
|
||
openQId = id;
|
||
}
|
||
|
||
async function dupQ(id) {
|
||
try {
|
||
const { id: newId } = await LS.duplicateQuestion(id);
|
||
await loadQuestions();
|
||
// scroll to new card
|
||
setTimeout(() => document.getElementById('qcard-' + newId)?.scrollIntoView({ behavior:'smooth', block:'center' }), 300);
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function deleteQ(id) {
|
||
if (!await LS.confirm(`Удалить вопрос #${id}?`, { title: 'Удалить вопрос', confirmText: 'Удалить' })) return;
|
||
try {
|
||
await LS.deleteQuestion(id);
|
||
allQuestions = allQuestions.filter(q => q.id !== id);
|
||
renderQuestions();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ─── Question type ─── */
|
||
let _currentType = 'single';
|
||
let _matchPairs = []; // [{left:'', right:''}]
|
||
|
||
function setQType(type) {
|
||
_currentType = type;
|
||
document.querySelectorAll('[data-type]').forEach(b => b.classList.toggle('active', b.dataset.type === type));
|
||
const isMatching = type === 'matching';
|
||
const isShort = type === 'short_answer';
|
||
const showOpts = !isShort && !isMatching;
|
||
const optsHeader = document.getElementById('qf-opts-header');
|
||
if (optsHeader) optsHeader.style.display = showOpts ? '' : 'none';
|
||
document.getElementById('qf-opts').style.display = showOpts ? '' : 'none';
|
||
document.getElementById('qf-short-wrap').style.display = isShort ? '' : 'none';
|
||
document.getElementById('qf-match-wrap').style.display = isMatching ? '' : 'none';
|
||
document.getElementById('btn-add-opt').style.display = showOpts && type !== 'true_false' ? '' : 'none';
|
||
|
||
if (type === 'true_false') {
|
||
initOpts([{ text:'Верно', is_correct:false }, { text:'Неверно', is_correct:false }]);
|
||
} else if (isShort) {
|
||
_opts = [];
|
||
} else if (isMatching) {
|
||
_opts = [];
|
||
if (_matchPairs.length === 0) _matchPairs = [{left:'',right:''},{left:'',right:''},{left:'',right:''}];
|
||
renderMatchRows();
|
||
} else {
|
||
if (_opts.length === 0 || _opts[0]?.text === 'Верно') initOpts([{},{},{},{}]);
|
||
else renderOptRows(_opts);
|
||
}
|
||
}
|
||
|
||
function renderMatchRows() {
|
||
const cont = document.getElementById('qf-match-rows');
|
||
cont.innerHTML = _matchPairs.map((p, i) => `
|
||
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin-bottom:8px" data-mi="${i}">
|
||
<input type="text" class="form-ctrl" placeholder="Элемент…" value="${esc(p.left)}"
|
||
oninput="_matchPairs[${i}].left=this.value" style="margin:0" />
|
||
<input type="text" class="form-ctrl" placeholder="Пара к нему…" value="${esc(p.right)}"
|
||
oninput="_matchPairs[${i}].right=this.value" style="margin:0" />
|
||
<button type="button" onclick="removeMatchPair(${i})" style="border:none;background:none;color:var(--text-3);cursor:pointer;padding:0 6px;display:flex;align-items:center" title="Удалить"><i data-lucide="x" style="width:15px;height:15px"></i></button>
|
||
</div>`).join('');
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
function addMatchPair() {
|
||
_matchPairs.push({left:'',right:''});
|
||
renderMatchRows();
|
||
}
|
||
|
||
function removeMatchPair(i) {
|
||
_matchPairs.splice(i, 1);
|
||
renderMatchRows();
|
||
}
|
||
|
||
/* ─── Formula bar ─── */
|
||
let _focusedInput = null;
|
||
document.addEventListener('focusin', e => {
|
||
if (e.target.closest && e.target.closest('#q-modal') &&
|
||
(e.target.tagName === 'TEXTAREA' || (e.target.tagName === 'INPUT' && e.target.type === 'text'))) {
|
||
_focusedInput = e.target;
|
||
}
|
||
});
|
||
|
||
function ins(latex) {
|
||
const el = _focusedInput || document.getElementById('qf-text');
|
||
if (!el) return;
|
||
const s = el.selectionStart ?? el.value.length;
|
||
const e2= el.selectionEnd ?? el.value.length;
|
||
const before = el.value.slice(0, s), after = el.value.slice(e2);
|
||
const opens = (before.match(/\\\(/g)||[]).length;
|
||
const closes = (before.match(/\\\)/g)||[]).length;
|
||
const insert = opens > closes ? latex : `\\(${latex}\\)`;
|
||
el.value = before + insert + after;
|
||
el.setSelectionRange(s + insert.length, s + insert.length);
|
||
el.focus();
|
||
updateQPreview();
|
||
}
|
||
|
||
function wrapMath() {
|
||
const el = _focusedInput || document.getElementById('qf-text');
|
||
if (!el) return;
|
||
const s = el.selectionStart, e2 = el.selectionEnd;
|
||
const sel = el.value.slice(s, e2) || 'x';
|
||
el.value = el.value.slice(0, s) + `\\(${sel}\\)` + el.value.slice(e2);
|
||
el.focus();
|
||
updateQPreview();
|
||
}
|
||
|
||
/* ─── Live preview ─── */
|
||
let _prevTimer = null;
|
||
function updateQPreview() {
|
||
clearTimeout(_prevTimer);
|
||
_prevTimer = setTimeout(() => {
|
||
const text = document.getElementById('qf-text').value || 'Введите текст вопроса…';
|
||
const el = document.getElementById('q-preview-text');
|
||
el.textContent = text;
|
||
renderMath(el);
|
||
}, 150);
|
||
}
|
||
// Wire textarea input to preview
|
||
setTimeout(() => {
|
||
const ta = document.getElementById('qf-text');
|
||
if (ta) ta.addEventListener('input', updateQPreview);
|
||
}, 0);
|
||
|
||
/* ─── Dynamic options ─── */
|
||
const OPT_LETTERS = 'АБВГДЕ';
|
||
|
||
function renderOptRows(opts) {
|
||
const grid = document.getElementById('qf-opts');
|
||
const isMulti = _currentType === 'multi';
|
||
grid.innerHTML = opts.map((o, i) => `
|
||
<div class="opt-row${o.is_correct ? ' opt-correct' : ''}" data-i="${i}">
|
||
<span class="opt-letter">${OPT_LETTERS[i]}</span>
|
||
${isMulti
|
||
? `<input type="checkbox" class="opt-radio" value="${i}" ${o.is_correct ? 'checked' : ''}
|
||
onchange="onCheckChange(${i}, this.checked)" />`
|
||
: `<input type="radio" name="qf-correct" class="opt-radio" value="${i}" ${o.is_correct ? 'checked' : ''}
|
||
onchange="onRadioChange(${i})" />`}
|
||
<input type="text" class="opt-input" placeholder="Вариант ${OPT_LETTERS[i]}"
|
||
value="${esc(o.text||'')}" oninput="syncOptText(${i}, this.value)" />
|
||
${opts.length > 2
|
||
? `<button type="button" class="btn-rem-opt" onclick="removeOpt(${i})" title="Удалить">−</button>`
|
||
: '<span style="width:24px;flex-shrink:0"></span>'}
|
||
</div>`).join('');
|
||
document.getElementById('btn-add-opt').style.display = opts.length >= 6 ? 'none' : '';
|
||
}
|
||
|
||
function onCheckChange(idx, checked) {
|
||
_opts[idx].is_correct = checked;
|
||
document.querySelector(`#qf-opts .opt-row[data-i="${idx}"]`)?.classList.toggle('opt-correct', checked);
|
||
}
|
||
|
||
let _opts = []; // current options state
|
||
|
||
function initOpts(opts) {
|
||
_opts = opts.length ? opts.map(o => ({ text: o.text||'', is_correct: !!o.is_correct }))
|
||
: [{text:'',is_correct:false},{text:'',is_correct:false},{text:'',is_correct:false},{text:'',is_correct:false}];
|
||
renderOptRows(_opts);
|
||
}
|
||
|
||
function onRadioChange(idx) {
|
||
_opts.forEach((o, i) => o.is_correct = (i === idx));
|
||
renderOptRows(_opts);
|
||
}
|
||
|
||
function syncOptText(idx, val) { _opts[idx].text = val; }
|
||
|
||
function addOpt() {
|
||
if (_opts.length >= 6) return;
|
||
_opts.push({ text: '', is_correct: false });
|
||
renderOptRows(_opts);
|
||
// focus new input
|
||
const rows = document.querySelectorAll('#qf-opts .opt-row');
|
||
rows[rows.length - 1]?.querySelector('input[type=text]')?.focus();
|
||
}
|
||
|
||
function removeOpt(idx) {
|
||
if (_opts.length <= 2) return;
|
||
const wasCorrect = _opts[idx].is_correct;
|
||
_opts.splice(idx, 1);
|
||
if (wasCorrect && _opts.length > 0) _opts[0].is_correct = true;
|
||
renderOptRows(_opts);
|
||
}
|
||
|
||
/* ─── Modal ─── */
|
||
function openQModal(q = null) {
|
||
editingQId = q ? q.id : null;
|
||
document.getElementById('q-modal-title').textContent = q ? `Редактировать вопрос #${q.id}` : 'Новый вопрос';
|
||
const textEl = document.getElementById('qf-text');
|
||
textEl.value = q?.text || '';
|
||
updateCharCounter(textEl, 'qf-text-cnt', 500);
|
||
document.getElementById('qf-explanation').value = q?.explanation || '';
|
||
document.getElementById('qf-difficulty').value = q?.difficulty ?? 2;
|
||
document.getElementById('qf-subject').value = q?.subject_slug || '';
|
||
document.getElementById('qf-topic-text').value = q?.topic || '';
|
||
document.getElementById('qf-correct-text').value = q?.correct_text || '';
|
||
document.getElementById('qf-error').textContent = '';
|
||
const imgVal = q?.image || '';
|
||
document.getElementById('qf-image').value = imgVal;
|
||
updateImagePreview(imgVal);
|
||
|
||
if (q?.type === 'matching') {
|
||
_matchPairs = (q.options || []).map(o => ({ left: o.text, right: o.match_pair || '' }));
|
||
if (!_matchPairs.length) _matchPairs = [{left:'',right:''},{left:'',right:''},{left:'',right:''}];
|
||
} else {
|
||
_matchPairs = [];
|
||
}
|
||
setQType(q?.type || 'single');
|
||
if (q?.type !== 'matching') initOpts(q?.options || []);
|
||
updateQPreview();
|
||
loadQModalTopics();
|
||
document.getElementById('q-modal').classList.add('open');
|
||
setTimeout(() => textEl.focus(), 80);
|
||
}
|
||
|
||
function editQ(id) {
|
||
const q = allQuestions.find(x => x.id === id);
|
||
if (q) openQModal(q);
|
||
}
|
||
|
||
function closeQModal() {
|
||
document.getElementById('q-modal').classList.remove('open');
|
||
editingQId = null;
|
||
}
|
||
|
||
async function loadQModalTopics() {
|
||
const slug = document.getElementById('qf-subject').value;
|
||
const dl = document.getElementById('qf-topic-list');
|
||
dl.innerHTML = '';
|
||
_topicMap = {};
|
||
if (!slug) return;
|
||
try {
|
||
const topics = await LS.getTopics(slug);
|
||
topics.forEach(t => {
|
||
dl.appendChild(new Option(t.name));
|
||
_topicMap[t.name.toLowerCase()] = t.id;
|
||
});
|
||
} catch {}
|
||
}
|
||
|
||
async function saveQuestion() {
|
||
const text = document.getElementById('qf-text').value.trim();
|
||
const explanation = document.getElementById('qf-explanation').value.trim();
|
||
const difficulty = Number(document.getElementById('qf-difficulty').value);
|
||
const subject_slug = document.getElementById('qf-subject').value;
|
||
const topicText = document.getElementById('qf-topic-text').value.trim();
|
||
const type = _currentType;
|
||
const errEl = document.getElementById('qf-error');
|
||
errEl.textContent = '';
|
||
|
||
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
|
||
if (!text) { errEl.textContent = 'Введите текст вопроса'; return; }
|
||
|
||
let options = null;
|
||
let correct_text = null;
|
||
|
||
if (type === 'short_answer') {
|
||
correct_text = document.getElementById('qf-correct-text').value.trim();
|
||
if (!correct_text) { errEl.textContent = 'Введите правильный ответ'; return; }
|
||
} else if (type === 'matching') {
|
||
// sync text from DOM inputs
|
||
document.querySelectorAll('#qf-match-rows [data-mi]').forEach((row, i) => {
|
||
const [l, r] = row.querySelectorAll('input');
|
||
if (_matchPairs[i]) { _matchPairs[i].left = l.value.trim(); _matchPairs[i].right = r.value.trim(); }
|
||
});
|
||
if (_matchPairs.length < 2) { errEl.textContent = 'Нужно минимум 2 пары'; return; }
|
||
if (_matchPairs.some(p => !p.left || !p.right)) { errEl.textContent = 'Заполните все пары'; return; }
|
||
options = _matchPairs.map(p => ({ text: p.left, match_pair: p.right, is_correct: 0 }));
|
||
} else {
|
||
// sync any unsaved text from DOM inputs
|
||
document.querySelectorAll('#qf-opts .opt-row').forEach((row, i) => {
|
||
if (_opts[i]) _opts[i].text = row.querySelector('input[type=text]').value.trim();
|
||
});
|
||
options = _opts.map(o => ({ text: o.text, is_correct: o.is_correct }));
|
||
if (options.length < 2) { errEl.textContent = 'Нужно минимум 2 варианта ответа'; return; }
|
||
if (options.some(o => !o.text)) { errEl.textContent = 'Заполните все варианты ответов'; return; }
|
||
if (!options.some(o => o.is_correct)) { errEl.textContent = 'Отметьте правильный ответ'; return; }
|
||
}
|
||
|
||
// resolve topic: use id if known, else send topic_name for find-or-create
|
||
const knownId = _topicMap[topicText.toLowerCase()];
|
||
const topic_id = knownId || null;
|
||
const topic_name = !knownId && topicText ? topicText : null;
|
||
|
||
const image = document.getElementById('qf-image').value.trim() || null;
|
||
|
||
const btn = document.getElementById('qf-save');
|
||
btn.disabled = true; btn.textContent = 'Сохранение…';
|
||
try {
|
||
if (editingQId) {
|
||
await LS.updateQuestion(editingQId, { text, type, correct_text, difficulty, explanation: explanation||null, topic_id, topic_name, options, image });
|
||
} else {
|
||
await LS.createQuestion({ subject_slug, topic_id, topic_name, text, type, correct_text, difficulty, explanation: explanation||null, options, image });
|
||
}
|
||
closeQModal();
|
||
loadQuestions();
|
||
} catch (e) {
|
||
errEl.textContent = 'Ошибка: ' + e.message;
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Сохранить';
|
||
}
|
||
}
|
||
|
||
/* ── Image upload & preview ── */
|
||
function updateImagePreview(url) {
|
||
const wrap = document.getElementById('qf-image-preview');
|
||
const img = document.getElementById('qf-image-img');
|
||
if (url) { img.src = url; wrap.classList.add('visible'); }
|
||
else { wrap.classList.remove('visible'); img.src = ''; }
|
||
}
|
||
|
||
function clearQuestionImage() {
|
||
document.getElementById('qf-image').value = '';
|
||
updateImagePreview('');
|
||
}
|
||
|
||
async function handleImageFileSelect(input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
input.value = '';
|
||
const btn = document.getElementById('btn-img-upload');
|
||
const lbl = document.getElementById('btn-img-upload-lbl');
|
||
btn.disabled = true;
|
||
lbl.textContent = 'Загрузка…';
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
fd.append('title', 'Question image: ' + file.name);
|
||
fd.append('is_public', '1');
|
||
const res = await fetch('/api/files', {
|
||
method: 'POST',
|
||
headers: { Authorization: 'Bearer ' + localStorage.getItem('ls_token') },
|
||
body: fd
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
|
||
const { id } = await res.json();
|
||
const url = `/api/files/${id}/download`;
|
||
document.getElementById('qf-image').value = url;
|
||
updateImagePreview(url);
|
||
} catch (e) {
|
||
document.getElementById('qf-error').textContent = 'Ошибка загрузки: ' + e.message;
|
||
} finally {
|
||
btn.disabled = false;
|
||
lbl.textContent = 'Загрузить';
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const imgInput = document.getElementById('qf-image');
|
||
if (imgInput) imgInput.addEventListener('input', e => updateImagePreview(e.target.value.trim()));
|
||
});
|
||
|
||
/* ── CSV Import ── */
|
||
async function importCSVFile(input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
input.value = '';
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
const btn = document.querySelector('[onclick="document.getElementById(\'csv-file-input\').click()"]');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Импорт…'; }
|
||
try {
|
||
const { imported, errors } = await LS.importQuestions(fd);
|
||
LS.toast(`Импортировано: ${imported} вопросов${errors.length ? ` (${errors.length} ошибок)` : ''}`, imported > 0 ? 'success' : 'warn', 5000);
|
||
loadQuestions();
|
||
} catch (e) {
|
||
LS.toast('Ошибка импорта: ' + e.message, 'error');
|
||
} finally {
|
||
if (btn) { btn.disabled = false; btn.innerHTML = '<i data-lucide="upload" style="width:14px;height:14px;vertical-align:-2px"></i> Импорт CSV'; if(window.lucide)lucide.createIcons(); }
|
||
}
|
||
}
|
||
|
||
function downloadCSVTemplate(e) {
|
||
e.preventDefault();
|
||
const header = 'subject_slug;topic;text;difficulty;type;opt1;c1;opt2;c2;opt3;c3;opt4;c4;correct_text;explanation;year';
|
||
const example = [
|
||
'bio;Клетки;Что является «электростанцией» клетки?;2;single;Митохондрия;1;Рибосома;0;Лизосома;0;Ядро;0;;Митохондрии синтезируют АТФ;2024',
|
||
'bio;Клетки;Какие органоиды участвуют в синтезе белка?;2;multi;Рибосома;1;Митохондрия;0;Эндоплазматическая сеть;1;Лизосома;0;;',
|
||
'chem;Кислоты;Формула серной кислоты;1;short_answer;;;;;;;;H2SO4;;',
|
||
].join('\n');
|
||
const blob = new Blob(['\ufeff' + header + '\n' + example], { type: 'text/csv;charset=utf-8' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'questions_template.csv';
|
||
a.click();
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
ПОЛЬЗОВАТЕЛИ
|
||
════════════════════════════════════════════════ */
|
||
async function loadUsers() {
|
||
try {
|
||
const users = await LS.adminGetUsers();
|
||
const tbody = document.getElementById('users-body');
|
||
if (!users.length) { tbody.innerHTML = '<tr><td colspan="7"><div class="empty">Пользователей нет</div></td></tr>'; return; }
|
||
tbody.innerHTML = users.map(u => {
|
||
const pc = pctClass(u.avg_pct);
|
||
const initials = (u.name||'?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'?';
|
||
const avatarBg = u.role==='admin' ? 'linear-gradient(135deg,#9B5DE5,#c084fc)' : u.role==='teacher' ? 'linear-gradient(135deg,#06D6E0,#9B5DE5)' : u.role==='free_student' ? 'linear-gradient(135deg,#10B981,#059669)' : 'linear-gradient(135deg,#8898AA,#3D4F6B)';
|
||
const roleCell = isAdmin && u.id !== user.id
|
||
? `<select class="role-select" data-uid="${u.id}" onchange="changeRole(this)">
|
||
<option value="student" ${u.role==='student' ?'selected':''}>Ученик</option>
|
||
<option value="free_student" ${u.role==='free_student' ?'selected':''}>Своб. ученик</option>
|
||
<option value="teacher" ${u.role==='teacher' ?'selected':''}>Учитель</option>
|
||
<option value="admin" ${u.role==='admin' ?'selected':''}>Админ</option>
|
||
</select>`
|
||
: `<span class="role-badge ${u.role}">${{student:'Ученик',free_student:'Своб. ученик',teacher:'Учитель',admin:'Админ'}[u.role]||u.role}</span>`;
|
||
return `<tr class="clickable${u.is_banned ? ' banned-row' : ''}" onclick="openUserPanel(event,${u.id},'${u.role}')">
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<div style="width:36px;height:36px;border-radius:10px;background:${avatarBg};display:flex;align-items:center;justify-content:center;font-family:'Unbounded',sans-serif;font-size:0.62rem;font-weight:800;color:#fff;flex-shrink:0;${u.is_banned?'filter:grayscale(1);opacity:.5':''}">${initials}</div>
|
||
<div>
|
||
<div style="font-weight:700;font-size:0.88rem;color:var(--text)">${esc(u.name)}${u.is_banned ? ' <span style="font-size:0.7rem;background:rgba(239,68,68,.12);color:#EF4444;border-radius:4px;padding:1px 5px;font-weight:600;vertical-align:middle">заблокирован</span>' : ''}</div>
|
||
<div style="color:var(--text-3);font-size:0.76rem">${esc(u.email)}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td onclick="event.stopPropagation()">${roleCell}</td>
|
||
<td style="font-weight:700">${u.tests_count}</td>
|
||
<td>
|
||
<span class="pct-cell ${pc}">${u.avg_pct !== null ? u.avg_pct+'%' : '—'}</span>
|
||
${u.avg_pct !== null ? `<div class="perf-bar"><div class="perf-fill ${pc}" style="width:${u.avg_pct}%"></div></div>` : ''}
|
||
</td>
|
||
<td style="color:var(--text-3);font-size:0.8rem">${fmtDate(u.created_at)}</td>
|
||
<td style="color:var(--text-3);font-size:0.8rem">${u.last_login ? new Date(u.last_login).toLocaleDateString('ru',{day:'numeric',month:'short'}) : '—'}</td>
|
||
<td style="text-align:right;color:var(--text-3);font-size:0.85rem;opacity:0.4">›</td>
|
||
</tr>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
document.getElementById('users-body').innerHTML = `<tr><td colspan="7" class="error">Ошибка: ${esc(e.message)}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
async function changeRole(select) {
|
||
select.disabled = true;
|
||
try { await LS.adminUpdateRole(select.dataset.uid, select.value); LS.toast('Роль изменена', 'success', 2000); }
|
||
catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
finally { select.disabled = false; }
|
||
}
|
||
|
||
let activeTr = null;
|
||
let activeUid = null;
|
||
let activeUserRole = null;
|
||
|
||
async function openUserPanel(e, uid, role) {
|
||
if (activeTr) activeTr.classList.remove('selected');
|
||
activeTr = e.currentTarget; activeTr.classList.add('selected');
|
||
activeUid = uid;
|
||
activeUserRole = role;
|
||
const panel = document.getElementById('user-panel');
|
||
panel.classList.add('visible');
|
||
panel.scrollIntoView({ behavior:'smooth', block:'nearest' });
|
||
document.getElementById('up-sessions').innerHTML = LS.skeleton(3, 'row');
|
||
document.getElementById('up-name').textContent = '…';
|
||
document.getElementById('up-email').textContent = '';
|
||
if (isAdmin) {
|
||
document.getElementById('up-edit-btn').style.display = '';
|
||
document.getElementById('up-clear-btn').style.display = '';
|
||
document.getElementById('up-perms-btn').style.display = role === 'teacher' ? '' : 'none';
|
||
document.getElementById('up-ban-btn').style.display = '';
|
||
document.getElementById('up-delete-btn').style.display = '';
|
||
}
|
||
await reloadUserPanel(uid);
|
||
}
|
||
|
||
async function reloadUserPanel(uid) {
|
||
try {
|
||
const { user: u, sessions } = await LS.adminGetUserSessions(uid);
|
||
activeUserRole = u.role;
|
||
document.getElementById('up-name').innerHTML = u.name + (u.is_banned ? ' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' : '');
|
||
document.getElementById('up-email').textContent = u.email;
|
||
// Sync button in case role changed after panel was opened
|
||
if (isAdmin) {
|
||
document.getElementById('up-perms-btn').style.display = u.role === 'teacher' ? '' : 'none';
|
||
const banBtn = document.getElementById('up-ban-btn');
|
||
const banLbl = document.getElementById('up-ban-label');
|
||
if (u.is_banned) {
|
||
banBtn.style.background = 'rgba(34,197,94,.12)';
|
||
banBtn.style.color = '#22C55E';
|
||
banBtn.style.borderColor = 'rgba(34,197,94,.25)';
|
||
banLbl.textContent = 'Разблокировать';
|
||
} else {
|
||
banBtn.style.background = '';
|
||
banBtn.style.color = '';
|
||
banBtn.style.borderColor = '';
|
||
banLbl.textContent = 'Заблокировать';
|
||
}
|
||
}
|
||
const el = document.getElementById('up-sessions');
|
||
if (!sessions.length) { el.innerHTML = '<div class="empty">Тестов нет</div>'; return; }
|
||
el.innerHTML = '<div class="sess-list">' + sessions.map(s => {
|
||
const pct = s.score !== null ? Math.round((s.score/s.total)*100) : null;
|
||
return `<div class="sess-item">
|
||
<div class="sess-pct ${pctClass(pct)}">${pct !== null ? pct+'%' : '—'}</div>
|
||
<div class="sess-info"><div class="sess-subj">${s.subject_name||'Тест'}</div><div class="sess-meta">${fmtDate(s.started_at)} · ${MODES[s.mode]||s.mode}</div></div>
|
||
<div class="sess-score">${s.score??'—'} / ${s.total}</div>
|
||
</div>`;
|
||
}).join('') + '</div>';
|
||
} catch (e) { document.getElementById('up-sessions').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
function closeUserPanel() {
|
||
document.getElementById('user-panel').classList.remove('visible');
|
||
if (activeTr) { activeTr.classList.remove('selected'); activeTr = null; }
|
||
activeUid = null;
|
||
}
|
||
|
||
async function clearUserHistory() {
|
||
const name = document.getElementById('up-name').textContent;
|
||
if (!await LS.confirm(`Удалить всю историю тестов пользователя «${name}»?\nЭто действие нельзя отменить.`, { title: 'Очистить историю', confirmText: 'Удалить историю' })) return;
|
||
try {
|
||
await LS.adminClearUserSessions(activeUid);
|
||
await reloadUserPanel(activeUid);
|
||
loadUsers();
|
||
} catch (e) { LS.toast('Ошибка очистки истории: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function toggleBanUser() {
|
||
const banLbl = document.getElementById('up-ban-label');
|
||
const isBanning = banLbl.textContent === 'Заблокировать';
|
||
const name = document.getElementById('up-name').innerHTML.replace(' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>','');
|
||
const msg = isBanning
|
||
? `Заблокировать пользователя «${name}»?\nОн не сможет войти в систему.`
|
||
: `Разблокировать пользователя «${name}»?`;
|
||
if (!await LS.confirm(msg, { title: isBanning ? 'Блокировка' : 'Разблокировка', confirmText: isBanning ? 'Заблокировать' : 'Разблокировать' })) return;
|
||
try {
|
||
await LS.adminBanUser(activeUid, isBanning);
|
||
LS.toast(isBanning ? 'Пользователь заблокирован' : 'Пользователь разблокирован', isBanning ? 'warning' : 'success');
|
||
await reloadUserPanel(activeUid);
|
||
loadUsers();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function confirmDeleteUser() {
|
||
const name = document.getElementById('up-name').innerHTML.replace(' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>','');
|
||
if (!await LS.confirm(`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`, { title: 'Удалить пользователя', confirmText: 'Удалить навсегда' })) return;
|
||
try {
|
||
await LS.adminDeleteUser(activeUid);
|
||
LS.toast('Пользователь удалён', 'success');
|
||
closeUserPanel();
|
||
loadUsers();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
let _editUid = null;
|
||
function closeEditUserModal() {
|
||
document.getElementById('eu-modal').classList.remove('open');
|
||
_editUid = null;
|
||
}
|
||
function openEditUserModal() {
|
||
_editUid = activeUid;
|
||
document.getElementById('eu-name').value = document.getElementById('up-name').textContent;
|
||
document.getElementById('eu-email').value = document.getElementById('up-email').textContent;
|
||
document.getElementById('eu-password').value = '';
|
||
document.getElementById('eu-error').textContent = '';
|
||
document.getElementById('eu-modal').classList.add('open');
|
||
setTimeout(() => document.getElementById('eu-name').focus(), 80);
|
||
}
|
||
async function saveEditUser() {
|
||
const name = document.getElementById('eu-name').value.trim();
|
||
const email = document.getElementById('eu-email').value.trim();
|
||
const password = document.getElementById('eu-password').value;
|
||
const errEl = document.getElementById('eu-error');
|
||
errEl.textContent = '';
|
||
if (!name) { errEl.textContent = 'Введите имя'; return; }
|
||
if (!email) { errEl.textContent = 'Введите email'; return; }
|
||
if (password && password.length < 6) { errEl.textContent = 'Пароль должен быть не менее 6 символов'; return; }
|
||
const payload = { name, email };
|
||
if (password) payload.password = password;
|
||
const btn = document.getElementById('eu-save');
|
||
btn.disabled = true; btn.textContent = 'Сохранение…';
|
||
try {
|
||
await LS.adminUpdateUser(_editUid, payload);
|
||
closeEditUserModal();
|
||
await reloadUserPanel(activeUid);
|
||
loadUsers();
|
||
} catch (e) {
|
||
errEl.textContent = 'Ошибка: ' + e.message;
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Сохранить';
|
||
}
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
СЕССИИ
|
||
════════════════════════════════════════════════ */
|
||
let allSessions = [];
|
||
let openDrawerId = null;
|
||
|
||
async function loadSessions() {
|
||
const subject = document.getElementById('t-subject').value;
|
||
document.getElementById('t-body').innerHTML = '<div class="spinner"></div>';
|
||
openDrawerId = null;
|
||
try {
|
||
allSessions = await LS.adminGetSessions({ subject: subject || undefined });
|
||
renderSessions();
|
||
} catch (e) {
|
||
document.getElementById('t-body').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function sessPctRing(pct) {
|
||
const pc = pctClass(pct);
|
||
const colorMap = {'pct-hi':'var(--green)','pct-mid':'var(--amber)','pct-lo':'var(--pink)'};
|
||
const color = colorMap[pc] || 'var(--text-3)';
|
||
const circ = 106.8;
|
||
const dash = (pct / 100 * circ).toFixed(1);
|
||
return `<svg class="sess-tl-ring" width="48" height="48" viewBox="0 0 48 48">
|
||
<circle cx="24" cy="24" r="17" fill="none" stroke="rgba(15,23,42,0.08)" stroke-width="4"/>
|
||
<circle cx="24" cy="24" r="17" fill="none" stroke="${color}" stroke-width="4"
|
||
stroke-dasharray="${dash} ${circ}" stroke-dashoffset="26.7" stroke-linecap="round"
|
||
transform="rotate(-90 24 24)"/>
|
||
<text x="24" y="28" text-anchor="middle" font-family="Unbounded,sans-serif" font-size="8" font-weight="800" fill="${color}">${pct}%</text>
|
||
</svg>`;
|
||
}
|
||
|
||
function renderSessions() {
|
||
const modeF = document.getElementById('t-mode').value;
|
||
const searchF = document.getElementById('t-search').value.toLowerCase();
|
||
const filtered = allSessions.filter(s => {
|
||
if (modeF && s.mode !== modeF) return false;
|
||
if (searchF && !s.user_name.toLowerCase().includes(searchF) && !s.user_email.toLowerCase().includes(searchF)) return false;
|
||
return true;
|
||
});
|
||
document.getElementById('t-count').textContent = `${filtered.length} тестов`;
|
||
if (!filtered.length) {
|
||
document.getElementById('t-body').innerHTML = '<div class="empty">Нет тестов</div>';
|
||
return;
|
||
}
|
||
// Group by date
|
||
const groups = {};
|
||
filtered.forEach(s => {
|
||
const key = fmtDate(s.started_at);
|
||
(groups[key] = groups[key] || []).push(s);
|
||
});
|
||
document.getElementById('t-body').innerHTML = Object.entries(groups).map(([date, sessions]) =>
|
||
`<div class="sess-tl-day">${date}</div>
|
||
<div class="sess-tl-wrap">${sessions.map(s => {
|
||
const ring = s.percent !== null
|
||
? sessPctRing(s.percent)
|
||
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;font-family:'Unbounded',sans-serif;font-size:0.85rem;font-weight:800;color:var(--text-3)">—</div>`;
|
||
return `<div class="sess-tl-item" id="trow-${s.id}" onclick="toggleDrawer(${s.id})">
|
||
${ring}
|
||
<div class="sess-tl-user">
|
||
<div class="sess-tl-name">${esc(s.user_name)}</div>
|
||
<div class="sess-tl-meta">${esc(s.subject_name||'?')} · <span class="mode-badge mode-${s.mode}">${MODES[s.mode]||s.mode}</span></div>
|
||
</div>
|
||
<div class="sess-tl-score">${s.score??'—'} / ${s.total}</div>
|
||
<div class="sess-tl-time">${fmtTime(s.duration_sec)}</div>
|
||
</div>
|
||
<div class="sess-tl-drawer" id="tdrawer-${s.id}">
|
||
<div class="sess-drawer" id="drawer-${s.id}">
|
||
<div class="sess-drawer-inner" id="drawer-inner-${s.id}"><div class="spinner"></div></div>
|
||
</div>
|
||
</div>`;
|
||
}).join('')}</div>`
|
||
).join('');
|
||
}
|
||
|
||
async function toggleDrawer(id) {
|
||
const drawerEl = document.getElementById('tdrawer-' + id);
|
||
const drawer = document.getElementById('drawer-' + id);
|
||
const trow = document.getElementById('trow-' + id);
|
||
if (openDrawerId && openDrawerId !== id) {
|
||
document.getElementById('tdrawer-' + openDrawerId)?.classList.remove('open');
|
||
document.getElementById('drawer-' + openDrawerId)?.classList.remove('open');
|
||
document.getElementById('trow-' + openDrawerId)?.classList.remove('open');
|
||
}
|
||
if (openDrawerId === id) {
|
||
drawerEl.classList.remove('open'); drawer.classList.remove('open'); trow.classList.remove('open');
|
||
openDrawerId = null; return;
|
||
}
|
||
openDrawerId = id; trow.classList.add('open');
|
||
drawerEl.classList.add('open');
|
||
requestAnimationFrame(() => drawer.classList.add('open'));
|
||
const inner = document.getElementById('drawer-inner-' + id);
|
||
if (inner.dataset.loaded) return;
|
||
inner.dataset.loaded = '1';
|
||
try {
|
||
const d = await LS.adminGetSessionDetail(id);
|
||
renderDrawer(inner, d);
|
||
} catch (e) { inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
function renderDrawer(el, d) {
|
||
const pct = d.score !== null && d.total ? Math.round((d.score/d.total)*100) : null;
|
||
const pc = pctClass(pct);
|
||
const correct = d.questions.filter(q => q.is_correct).length;
|
||
const wrong = d.questions.filter(q => !q.is_correct && q.chosen_option_id).length;
|
||
const skipped = d.questions.filter(q => !q.chosen_option_id).length;
|
||
const qHtml = d.questions.map((q,i) => {
|
||
const status = !q.chosen_option_id ? 'skipped' : q.is_correct ? 'correct' : 'wrong';
|
||
const badgeTxt = { correct:'Верно', wrong:'Неверно', skipped:'Пропущено' }[status];
|
||
const opts = q.options.map(o => {
|
||
const isCor = o.is_correct, isCho = o.id === q.chosen_option_id;
|
||
let cls='', icon='<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>';
|
||
if (isCor) { cls='correct-opt'; icon='<i data-lucide="check" style="width:13px;height:13px"></i>'; }
|
||
else if (isCho && !isCor) { cls='chosen-wrong'; icon='<i data-lucide="x" style="width:13px;height:13px"></i>'; }
|
||
return `<div class="qb-opt ${cls}"><span class="qb-opt-icon">${icon}</span>${esc(o.text)}</div>`;
|
||
}).join('');
|
||
const expl = q.explanation ? `<div class="qb-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>` : '';
|
||
return `<div class="qb-item ${status}">
|
||
<div class="qb-header"><span class="qb-qnum">Вопрос ${i+1}</span><span class="qb-badge ${status}">${badgeTxt}</span><span class="qb-time">${q.time_spent_sec?q.time_spent_sec+' сек':''}</span></div>
|
||
<div class="qb-text">${esc(q.text)}</div>
|
||
<div class="qb-opts">${opts}</div>${expl}
|
||
</div>`;
|
||
}).join('');
|
||
el.innerHTML = `
|
||
<div class="drawer-header">
|
||
<div>
|
||
<div style="font-family:'Unbounded',sans-serif;font-weight:800;font-size:0.95rem">${esc(d.user_name)}</div>
|
||
<div class="drawer-meta">${esc(d.user_email)} · ${d.subject_name||'?'} · ${MODES[d.mode]||d.mode} · ${fmtDate(d.started_at)}</div>
|
||
</div>
|
||
<div class="drawer-score ${pc}">${pct !== null ? pct+'%' : '—'}</div>
|
||
<div style="display:flex;gap:20px;margin-left:auto;text-align:center">
|
||
<div><div style="font-family:'Unbounded',sans-serif;color:var(--green);font-weight:700">${correct}</div><div style="font-size:0.72rem;color:var(--text-3)">Верно</div></div>
|
||
<div><div style="font-family:'Unbounded',sans-serif;color:var(--pink);font-weight:700">${wrong}</div><div style="font-size:0.72rem;color:var(--text-3)">Неверно</div></div>
|
||
<div><div style="font-family:'Unbounded',sans-serif;color:var(--text-3);font-weight:700">${skipped}</div><div style="font-size:0.72rem;color:var(--text-3)">Пропущено</div></div>
|
||
<div><div style="font-family:'Unbounded',sans-serif;color:var(--text-2);font-weight:700">${fmtTime(d.duration_sec)}</div><div style="font-size:0.72rem;color:var(--text-3)">Время</div></div>
|
||
</div>
|
||
</div>
|
||
<div class="qb-list">${qHtml||'<div class="empty">Вопросы не найдены</div>'}</div>`;
|
||
renderMath(el);
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
ТЕСТЫ (ШАБЛОНЫ)
|
||
════════════════════════════════════════════════ */
|
||
let allTests = [];
|
||
let openTstId = null;
|
||
let editingTstId = null;
|
||
const DIFF_LABELS = { 1:'Лёгкий', 2:'Средний', 3:'Сложный' };
|
||
const TYPE_LABELS = { single:'Один', multi:'Несколько', true_false:'Верно/Нет', short_answer:'Краткий', matching:'Сопоставление' };
|
||
|
||
async function loadTests() {
|
||
const subj = document.getElementById('tst-subj').value;
|
||
const wrap = document.getElementById('tst-list-wrap');
|
||
wrap.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
allTests = await LS.getTests(subj || null);
|
||
renderTests();
|
||
} catch (e) {
|
||
wrap.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderTests() {
|
||
const search = document.getElementById('tst-search').value.toLowerCase();
|
||
const filtered = search ? allTests.filter(t => t.title.toLowerCase().includes(search)) : allTests;
|
||
document.getElementById('tst-count').textContent = `${filtered.length} тестов`;
|
||
const wrap = document.getElementById('tst-list-wrap');
|
||
if (!filtered.length) { wrap.innerHTML = '<div class="empty">Тестов не найдено</div>'; return; }
|
||
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
|
||
wrap.innerHTML = `<div class="q-list">${filtered.map(t => `
|
||
<div class="q-card" id="tstcard-${t.id}">
|
||
<div class="q-card-head">
|
||
<span class="q-card-num">#${t.id}</span>
|
||
<div class="q-card-body" onclick="toggleTstDrawer(${t.id})">
|
||
<div class="q-card-text">${esc(t.title)}</div>
|
||
<div class="q-card-meta">
|
||
<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.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="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>
|
||
</div>
|
||
<div class="tst-drawer" id="tstdrawer-${t.id}" style="display:none">
|
||
<div class="tst-drawer-inner" id="tstdinner-${t.id}">
|
||
<div class="spinner"></div>
|
||
</div>
|
||
</div>
|
||
</div>`).join('')}</div>`;
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
async function toggleTstDrawer(id) {
|
||
const drawer = document.getElementById('tstdrawer-' + id);
|
||
if (!drawer) return;
|
||
if (openTstId && openTstId !== id) {
|
||
const old = document.getElementById('tstdrawer-' + openTstId);
|
||
if (old) old.style.display = 'none';
|
||
}
|
||
if (openTstId === id) {
|
||
drawer.style.display = 'none'; openTstId = null; return;
|
||
}
|
||
openTstId = id;
|
||
drawer.style.display = '';
|
||
await renderTstDrawer(id);
|
||
}
|
||
|
||
async function renderTstDrawer(id) {
|
||
const inner = document.getElementById('tstdinner-' + id);
|
||
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 inIds = new Set(t.questions.map(q => q.id));
|
||
// Update cache so filterTstPicker can filter locally
|
||
_tstPickerCache[id] = { subjectQs, inIds, subject_slug: t.subject_slug };
|
||
|
||
inner.innerHTML = `
|
||
<div class="tst-cols">
|
||
<div>
|
||
<div class="tst-panel-title">Вопросы в тесте (${t.questions.length})</div>
|
||
<div class="tst-q-list" id="tstql-${id}">${renderTstQList(t.questions, id)}</div>
|
||
</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>
|
||
</div>
|
||
</div>`;
|
||
renderMath(inner);
|
||
if (window.lucide) lucide.createIcons();
|
||
} catch (e) {
|
||
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
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>`;
|
||
}
|
||
|
||
function renderTstQList(questions, tid) {
|
||
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>';
|
||
return questions.map((q, i) => `
|
||
<div class="tst-q-item" id="tstqitem-${tid}-${q.id}">
|
||
<span class="tst-q-num">${i+1}.</span>
|
||
<div class="tst-q-body">
|
||
<span class="tst-q-text">${esc(q.text)}</span>
|
||
<div class="tst-q-meta">
|
||
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
|
||
${qTypeBadge(q.type)}
|
||
${qOptsPreview(q)}
|
||
</div>
|
||
</div>
|
||
<button class="btn-tst-rem" onclick="tstRemoveQ(${tid},${q.id})" title="Убрать">−</button>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function renderTstPicker(questions, inIds, tid) {
|
||
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
|
||
return questions.map(q => {
|
||
const added = inIds.has(q.id);
|
||
return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}">
|
||
<div class="tst-q-body">
|
||
<span class="tst-q-text">${esc(q.text)}</span>
|
||
<div class="tst-q-meta">
|
||
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||''}</span>
|
||
${qTypeBadge(q.type)}
|
||
${qOptsPreview(q)}
|
||
</div>
|
||
</div>
|
||
<button class="btn-tst-add${added?' added':''}" id="tstbtn-${tid}-${q.id}"
|
||
title="${added?'Уже в тесте':'Добавить'}" ${added?'disabled':'onclick="tstAddQ('+tid+','+q.id+')"'}>${added?'<i data-lucide="check" style="width:14px;height:14px"></i>':'+'}</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// Cache for picker: { tid: { subjectQs:[], inIds: Set } }
|
||
const _tstPickerCache = {};
|
||
|
||
async function filterTstPicker(tid) {
|
||
const search = document.getElementById('tstps-'+tid)?.value.toLowerCase() || '';
|
||
const cache = _tstPickerCache[tid];
|
||
if (!cache) return; // not loaded yet
|
||
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); renderMath(picker); if(window.lucide)lucide.createIcons(); }
|
||
}
|
||
|
||
async function tstAddQ(tid, qid) {
|
||
const btn = document.getElementById(`tstbtn-${tid}-${qid}`);
|
||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||
try {
|
||
await LS.addQuestionsToTest(tid, [qid]);
|
||
const t = allTests.find(x => x.id === tid);
|
||
if (t) t.question_count++;
|
||
renderTests();
|
||
openTstId = tid;
|
||
document.getElementById('tstdrawer-' + tid).style.display = '';
|
||
await renderTstDrawer(tid);
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); if (btn) { btn.disabled=false; btn.textContent='+'; } }
|
||
}
|
||
|
||
async function tstRemoveQ(tid, qid) {
|
||
try {
|
||
await LS.removeQFromTest(tid, qid);
|
||
const t = allTests.find(x => x.id === tid);
|
||
if (t) t.question_count = Math.max(0, t.question_count - 1);
|
||
renderTests();
|
||
openTstId = tid;
|
||
document.getElementById('tstdrawer-' + tid).style.display = '';
|
||
await renderTstDrawer(tid);
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ── Test modal ── */
|
||
let _tstShowAnswers = true;
|
||
function setTstShowAnswers(val) {
|
||
_tstShowAnswers = val;
|
||
document.getElementById('tstf-show-yes').classList.toggle('active', val);
|
||
document.getElementById('tstf-show-no').classList.toggle('active', !val);
|
||
}
|
||
|
||
function openTstModal(t = null) {
|
||
editingTstId = t ? t.id : null;
|
||
document.getElementById('tst-modal-title').textContent = t ? `Редактировать: ${t.title}` : 'Создать тест';
|
||
document.getElementById('tstf-title').value = t?.title || '';
|
||
document.getElementById('tstf-subject').value = t?.subject_slug || '';
|
||
document.getElementById('tstf-desc').value = t?.description || '';
|
||
document.getElementById('tstf-time').value = t?.time_limit || '';
|
||
document.getElementById('tstf-error').textContent = '';
|
||
setTstShowAnswers(t ? (t.show_answers !== 0) : true);
|
||
document.getElementById('tst-modal').classList.add('open');
|
||
setTimeout(() => document.getElementById('tstf-title').focus(), 80);
|
||
}
|
||
|
||
function editTst(id) {
|
||
const t = allTests.find(x => x.id === id);
|
||
if (t) openTstModal(t);
|
||
}
|
||
|
||
function closeTstModal() {
|
||
document.getElementById('tst-modal').classList.remove('open');
|
||
editingTstId = null;
|
||
}
|
||
|
||
async function saveTst() {
|
||
const title = document.getElementById('tstf-title').value.trim();
|
||
const subject_slug= document.getElementById('tstf-subject').value;
|
||
const description = document.getElementById('tstf-desc').value.trim();
|
||
const errEl = document.getElementById('tstf-error');
|
||
errEl.textContent = '';
|
||
if (!title) { errEl.textContent = 'Введите название'; return; }
|
||
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
|
||
|
||
const btn = document.getElementById('tstf-save');
|
||
btn.disabled = true; btn.textContent = 'Сохранение…';
|
||
const show_answers = _tstShowAnswers ? 1 : 0;
|
||
const timeVal = parseInt(document.getElementById('tstf-time').value, 10);
|
||
const time_limit = timeVal >= 1 ? Math.min(600, timeVal) : null;
|
||
try {
|
||
if (editingTstId) {
|
||
await LS.updateTest(editingTstId, { title, subject_slug, description: description||null, show_answers, time_limit });
|
||
const idx = allTests.findIndex(x => x.id === editingTstId);
|
||
if (idx !== -1) Object.assign(allTests[idx], { title, subject_slug, description, show_answers, time_limit });
|
||
} else {
|
||
const { id } = await LS.createTest({ title, subject_slug, description: description||null, show_answers, time_limit });
|
||
allTests.unshift({ id, title, subject_slug, description, question_count: 0, created_at: new Date().toISOString() });
|
||
closeTstModal();
|
||
renderTests();
|
||
// Auto-open drawer so teacher can immediately add questions
|
||
openTstId = id;
|
||
document.getElementById('tstdrawer-' + id).style.display = '';
|
||
await renderTstDrawer(id);
|
||
return;
|
||
}
|
||
closeTstModal();
|
||
renderTests();
|
||
} catch (e) {
|
||
errEl.textContent = 'Ошибка: ' + e.message;
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Сохранить';
|
||
}
|
||
}
|
||
|
||
async function deleteTst(id) {
|
||
const t = allTests.find(x => x.id === id);
|
||
if (!await LS.confirm(`Удалить тест «${t?.title}»?`, { title: 'Удалить тест', confirmText: 'Удалить' })) return;
|
||
try {
|
||
await LS.deleteTest(id);
|
||
allTests = allTests.filter(x => x.id !== id);
|
||
if (openTstId === id) openTstId = null;
|
||
renderTests();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
ЗАДАНИЯ
|
||
════════════════════════════════════════════════ */
|
||
let allAssignments = [];
|
||
let editingAId = null;
|
||
const SUBJ_NAMES = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
|
||
|
||
async function loadAssignments() {
|
||
document.getElementById('a-body').innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
allAssignments = await LS.teacherAssignments();
|
||
renderAssignments();
|
||
} catch (e) {
|
||
document.getElementById('a-body').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
const SUBJ_COLORS_A = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
|
||
const SUBJ_ICONS_A = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
|
||
let _aFilter = 'all';
|
||
|
||
function setAFilter(f) {
|
||
_aFilter = f;
|
||
document.querySelectorAll('.a-f-chip').forEach(c =>
|
||
c.classList.toggle('active', c.textContent.trim() === {all:'Все',active:'Активные',overdue:'Просрочены',done:'Завершены'}[f])
|
||
);
|
||
renderAssignments();
|
||
}
|
||
|
||
function aClassify(a) {
|
||
const pct = a.total_members ? Math.round(a.completed_count / a.total_members * 100) : null;
|
||
if (pct === 100) return 'done';
|
||
if (a.deadline && new Date(a.deadline) < new Date()) return 'overdue';
|
||
return 'active';
|
||
}
|
||
|
||
function renderAssignments() {
|
||
const subjF = document.getElementById('a-subject').value;
|
||
const searchF = document.getElementById('a-search').value.toLowerCase();
|
||
const sortF = document.getElementById('a-sort')?.value || 'date';
|
||
|
||
let list = allAssignments.filter(a => {
|
||
if (subjF && a.subject_slug !== subjF) return false;
|
||
if (searchF && !a.title.toLowerCase().includes(searchF)) return false;
|
||
if (_aFilter === 'active' && aClassify(a) !== 'active') return false;
|
||
if (_aFilter === 'overdue' && aClassify(a) !== 'overdue') return false;
|
||
if (_aFilter === 'done' && aClassify(a) !== 'done') return false;
|
||
return true;
|
||
});
|
||
|
||
// Sort
|
||
list = [...list].sort((a, b) => {
|
||
if (sortF === 'deadline') {
|
||
const da = a.deadline ? new Date(a.deadline) : new Date(9e15);
|
||
const db = b.deadline ? new Date(b.deadline) : new Date(9e15);
|
||
return da - db;
|
||
}
|
||
if (sortF === 'progress_asc') {
|
||
const pa = a.total_members ? a.completed_count / a.total_members : 0;
|
||
const pb = b.total_members ? b.completed_count / b.total_members : 0;
|
||
return pa - pb;
|
||
}
|
||
if (sortF === 'progress_desc') {
|
||
const pa = a.total_members ? a.completed_count / a.total_members : 0;
|
||
const pb = b.total_members ? b.completed_count / b.total_members : 0;
|
||
return pb - pa;
|
||
}
|
||
return 0; // date: keep server order
|
||
});
|
||
|
||
// Summary chips
|
||
const all = allAssignments;
|
||
const nActive = all.filter(a => aClassify(a) === 'active').length;
|
||
const nOverdue = all.filter(a => aClassify(a) === 'overdue').length;
|
||
const nDone = all.filter(a => aClassify(a) === 'done').length;
|
||
document.getElementById('a-summary').innerHTML = [
|
||
`<span class="a-sum-chip s-all">Всего: ${all.length}</span>`,
|
||
nActive ? `<span class="a-sum-chip s-active">Активных: ${nActive}</span>` : '',
|
||
nOverdue ? `<span class="a-sum-chip s-overdue">Просрочено: ${nOverdue}</span>` : '',
|
||
nDone ? `<span class="a-sum-chip s-done">Завершено: ${nDone}</span>` : '',
|
||
].join('');
|
||
|
||
document.getElementById('a-count').textContent = `${list.length} заданий`;
|
||
const container = document.getElementById('a-body');
|
||
|
||
if (!list.length) {
|
||
container.innerHTML = '<div class="empty">Заданий нет</div>';
|
||
return;
|
||
}
|
||
|
||
const now = new Date();
|
||
container.innerHTML = list.map(a => {
|
||
const pct = a.total_members ? Math.round(a.completed_count / a.total_members * 100) : null;
|
||
const cls = aClassify(a);
|
||
const rowCls = cls === 'overdue' ? 'a-overdue' : cls === 'done' ? 'a-done' : '';
|
||
const sColor = SUBJ_COLORS_A[a.subject_slug] || '#9B5DE5';
|
||
const dlMs = a.deadline ? new Date(a.deadline) - now : Infinity;
|
||
const isUrgent = cls === 'active' && dlMs > 0 && dlMs < 24 * 3600 * 1000;
|
||
|
||
const dl = a.deadline
|
||
? new Date(a.deadline).toLocaleDateString('ru', {day:'numeric', month:'short'})
|
||
: null;
|
||
|
||
const targetStr = a.target_user_id
|
||
? esc(a.target_user_name || 'Ученик')
|
||
: esc(a.class_name || '—');
|
||
|
||
const metaParts = [
|
||
targetStr,
|
||
SUBJ_NAMES[a.subject_slug] || a.subject_slug,
|
||
`<span class="mode-badge mode-${a.mode}">${MODES[a.mode]||a.mode}</span>`,
|
||
a.count + ' вопр.',
|
||
dl ? `до ${dl}` : null,
|
||
isUrgent ? `<span class="a-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> срочно</span>` : null,
|
||
cls === 'overdue' ? `<span class="a-tag-over">просрочено</span>` : null,
|
||
].filter(Boolean);
|
||
|
||
const barColor = pct >= 75 ? '#06D6A0' : pct >= 40 ? '#F59E0B' : '#F15BB5';
|
||
const pctLabel = pct !== null ? `${pct}%` : '—';
|
||
|
||
return `<div class="a-row ${rowCls}${isUrgent ? ' a-urgent' : ''}" style="--ac:${sColor}">
|
||
<div class="a-icon" style="background:${sColor}18;color:${sColor}"><i data-lucide="${SUBJ_ICONS_A[a.subject_slug]||'file-text'}" style="width:18px;height:18px"></i></div>
|
||
<div class="a-main">
|
||
<div class="a-title">${esc(a.title)}</div>
|
||
<div class="a-meta">${metaParts.join(' · ')}</div>
|
||
</div>
|
||
<div class="a-prog">
|
||
<div class="a-prog-nums">
|
||
<span>${a.completed_count} / ${a.total_members} сдали</span>
|
||
<span class="a-prog-pct ${pctClass(pct)}">${pctLabel}</span>
|
||
</div>
|
||
<div class="a-prog-bar">
|
||
<div class="a-prog-fill" style="width:${pct||0}%;background:${barColor}"></div>
|
||
</div>
|
||
</div>
|
||
<div class="a-actions">
|
||
<button class="btn-edit-q" onclick="openAModal(${a.id})">Изменить</button>
|
||
<button class="btn-del-q" onclick="deleteAsgn(${a.id})"><i data-lucide="x" style="width:14px;height:14px"></i></button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
let _afSrc = 'random';
|
||
let _afLoadedTests = [];
|
||
|
||
function setAfSrc(src) {
|
||
_afSrc = src;
|
||
document.querySelectorAll('[data-afsrc]').forEach(b => b.classList.toggle('active', b.dataset.afsrc === src));
|
||
document.getElementById('af-random-fields').style.display = src === 'random' ? '' : 'none';
|
||
document.getElementById('af-test-fields').style.display = src === 'test' ? '' : 'none';
|
||
}
|
||
|
||
async function openAModal(id) {
|
||
const a = allAssignments.find(x => x.id === id);
|
||
if (!a) return;
|
||
editingAId = id;
|
||
document.getElementById('a-modal-title').textContent = `Редактировать: ${a.title}`;
|
||
document.getElementById('af-title').value = a.title;
|
||
document.getElementById('af-deadline').value = a.deadline ? a.deadline.split('T')[0] : '';
|
||
document.getElementById('af-error').textContent = '';
|
||
|
||
// Load tests for the dropdown
|
||
const testSel = document.getElementById('af-test');
|
||
testSel.innerHTML = '<option value="">Загрузка…</option>';
|
||
try {
|
||
_afLoadedTests = await LS.getTests();
|
||
testSel.innerHTML = _afLoadedTests.length
|
||
? '<option value="">— выберите тест —</option>' + _afLoadedTests.map(t => `<option value="${t.id}">${esc(t.title)} (${t.question_count} вопр.)</option>`).join('')
|
||
: '<option value="">Нет тестов</option>';
|
||
} catch {
|
||
testSel.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||
_afLoadedTests = [];
|
||
}
|
||
|
||
if (a.test_id) {
|
||
setAfSrc('test');
|
||
testSel.value = a.test_id;
|
||
document.getElementById('af-mode-test').value = a.mode;
|
||
} else {
|
||
setAfSrc('random');
|
||
document.getElementById('af-subject').value = a.subject_slug;
|
||
document.getElementById('af-mode').value = a.mode;
|
||
document.getElementById('af-count').value = a.count;
|
||
}
|
||
|
||
document.getElementById('a-modal').classList.add('open');
|
||
setTimeout(() => document.getElementById('af-title').focus(), 80);
|
||
}
|
||
|
||
function closeAModal() {
|
||
document.getElementById('a-modal').classList.remove('open');
|
||
editingAId = null;
|
||
}
|
||
|
||
async function saveAssignment() {
|
||
const title = document.getElementById('af-title').value.trim();
|
||
const deadline = document.getElementById('af-deadline').value || null;
|
||
const errEl = document.getElementById('af-error');
|
||
errEl.textContent = '';
|
||
if (!title) { errEl.textContent = 'Введите название'; return; }
|
||
|
||
let payload = { title, deadline };
|
||
|
||
if (_afSrc === 'test') {
|
||
const test_id = document.getElementById('af-test').value;
|
||
const mode = document.getElementById('af-mode-test').value;
|
||
if (!test_id) { errEl.textContent = 'Выберите тест'; return; }
|
||
const testObj = _afLoadedTests.find(t => t.id === Number(test_id));
|
||
if (testObj && testObj.question_count === 0) { errEl.textContent = 'В выбранном тесте нет вопросов'; return; }
|
||
payload = { ...payload, test_id: Number(test_id), mode };
|
||
} else {
|
||
const subject_slug = document.getElementById('af-subject').value;
|
||
const mode = document.getElementById('af-mode').value;
|
||
const count = Number(document.getElementById('af-count').value);
|
||
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
|
||
if (!count || count < 1) { errEl.textContent = 'Введите количество вопросов'; return; }
|
||
payload = { ...payload, subject_slug, mode, count, test_id: null };
|
||
}
|
||
|
||
const btn = document.getElementById('af-save');
|
||
btn.disabled = true; btn.textContent = 'Сохранение…';
|
||
try {
|
||
await LS.updateAssignment(editingAId, payload);
|
||
const idx = allAssignments.findIndex(x => x.id === editingAId);
|
||
if (idx !== -1) Object.assign(allAssignments[idx], payload);
|
||
closeAModal();
|
||
renderAssignments();
|
||
} catch (e) {
|
||
errEl.textContent = 'Ошибка: ' + e.message;
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Сохранить';
|
||
}
|
||
}
|
||
|
||
/* ─── Create assignment modal ─── */
|
||
let _acSrc = 'random'; // 'random' | 'test'
|
||
let _acTarget = 'class'; // 'class' | 'user'
|
||
|
||
let _acFileId = null, _acAllFiles = null;
|
||
let _acStudentId = null, _acAllStudents = null;
|
||
|
||
function setAcTarget(t) {
|
||
_acTarget = t;
|
||
document.querySelectorAll('[data-actgt]').forEach(b => b.classList.toggle('active', b.dataset.actgt === t));
|
||
document.getElementById('acf-class-field').style.display = t === 'class' ? '' : 'none';
|
||
document.getElementById('acf-user-field').style.display = t === 'user' ? '' : 'none';
|
||
if (t === 'user' && !_acAllStudents) loadAcStudents();
|
||
}
|
||
|
||
async function loadAcStudents() {
|
||
const drop = document.getElementById('acf-student-drop');
|
||
drop.innerHTML = '<div style="padding:8px 12px;font-size:13px;color:#9ca3af">Загрузка…</div>';
|
||
drop.style.display = '';
|
||
try {
|
||
_acAllStudents = await LS.getStudentsList();
|
||
openAcStudentDrop();
|
||
} catch(e) {
|
||
_acAllStudents = [];
|
||
drop.innerHTML = `<div style="padding:8px 12px;font-size:13px;color:#ef4444">Ошибка загрузки: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function filterAcStudents(q) {
|
||
openAcStudentDrop(q);
|
||
}
|
||
|
||
function openAcStudentDrop(q) {
|
||
const drop = document.getElementById('acf-student-drop');
|
||
if (_acAllStudents === null) { loadAcStudents(); return; }
|
||
const list = _acAllStudents;
|
||
const term = (q !== undefined ? q : document.getElementById('acf-student-search').value).toLowerCase().trim();
|
||
const filtered = term ? list.filter(s => s.name.toLowerCase().includes(term) || s.email.toLowerCase().includes(term)) : list;
|
||
if (!filtered.length) {
|
||
drop.innerHTML = '<div style="padding:8px 12px;font-size:13px;color:#9ca3af">Нет учеников</div>';
|
||
drop.style.display = '';
|
||
return;
|
||
}
|
||
drop.innerHTML = filtered.slice(0, 50).map(s =>
|
||
`<div style="padding:8px 12px;cursor:pointer;border-bottom:1px solid #f3f4f6;font-size:13px" data-id="${s.id}" data-name="${esc(s.name)}" data-email="${esc(s.email)}" onmousedown="selectAcStudent(+this.dataset.id,this.dataset.name,this.dataset.email)" onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background=''">${esc(s.name)} <span style="color:#9ca3af">${esc(s.email)}</span></div>`
|
||
).join('');
|
||
drop.style.display = '';
|
||
}
|
||
|
||
function closeAcStudentDrop() {
|
||
document.getElementById('acf-student-drop').style.display = 'none';
|
||
}
|
||
|
||
function selectAcStudent(id, name, email) {
|
||
_acStudentId = id;
|
||
document.getElementById('acf-student-search').value = name;
|
||
document.getElementById('acf-student-selected').textContent = `${name} (${email})`;
|
||
document.getElementById('acf-student-selected').style.display = '';
|
||
closeAcStudentDrop();
|
||
}
|
||
|
||
function setAcSrc(src) {
|
||
_acSrc = src;
|
||
document.querySelectorAll('[data-src]').forEach(b => b.classList.toggle('active', b.dataset.src === src));
|
||
document.getElementById('acf-random-fields').style.display = src === 'random' ? '' : 'none';
|
||
document.getElementById('acf-test-fields').style.display = src === 'test' ? '' : 'none';
|
||
document.getElementById('acf-file-fields').style.display = src === 'file' ? '' : 'none';
|
||
if (src === 'file' && !_acAllFiles) loadAcFiles();
|
||
}
|
||
|
||
async function loadAcFiles() {
|
||
try {
|
||
_acAllFiles = await LS.getFiles();
|
||
renderAcFiles('');
|
||
} catch { _acAllFiles = []; }
|
||
}
|
||
|
||
function renderAcFiles(q) {
|
||
const el = document.getElementById('acf-file-list');
|
||
if (!_acAllFiles) { el.innerHTML = '<div style="padding:10px;color:#8898AA;font-size:.82rem;text-align:center">Загрузка…</div>'; return; }
|
||
const lq = q.toLowerCase();
|
||
const items = q ? _acAllFiles.filter(f => (f.title||'').toLowerCase().includes(lq)) : _acAllFiles;
|
||
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
|
||
if (!items.length) { el.innerHTML = '<div style="padding:10px;color:#8898AA;font-size:.82rem;text-align:center">Нет файлов</div>'; return; }
|
||
el.innerHTML = items.map(f => `
|
||
<div onclick="selectAcFile(${f.id},'${esc(f.title||'Файл')}','${f.subject_slug||''}')"
|
||
style="padding:9px 12px;cursor:pointer;border-bottom:1px solid rgba(15,23,42,0.07);display:flex;align-items:center;gap:8px;${_acFileId===f.id?'background:rgba(155,93,229,0.08);':''} transition:background .15s">
|
||
<div style="flex:1">
|
||
<div style="font-size:.84rem;font-weight:600">${esc(f.title||'Файл')}</div>
|
||
<div style="font-size:.74rem;color:#8898AA">${SUBJ[f.subject_slug]||f.subject_slug||''}</div>
|
||
</div>
|
||
${_acFileId===f.id ? '<span style="color:var(--violet)"><i data-lucide="check" style="width:15px;height:15px"></i></span>' : ''}
|
||
</div>`).join('');
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
function filterAcFiles(q) { renderAcFiles(q); }
|
||
|
||
function selectAcFile(id, title, subject_slug) {
|
||
_acFileId = id;
|
||
renderAcFiles(document.getElementById('acf-file-search').value);
|
||
const sel = document.getElementById('acf-file-selected');
|
||
sel.textContent = 'Выбран: ' + title;
|
||
sel.style.display = '';
|
||
}
|
||
|
||
async function openCreateAModal() {
|
||
_acSrc = 'random'; _acTarget = 'class'; _acFileId = null; _acStudentId = null; _acAllStudents = null;
|
||
setAcSrc('random');
|
||
setAcTarget('class');
|
||
loadAcStudents(); // preload students so search is ready
|
||
document.getElementById('acf-title').value = '';
|
||
document.getElementById('acf-subject').value = '';
|
||
document.getElementById('acf-mode').value = 'exam';
|
||
document.getElementById('acf-mode-test').value = 'exam';
|
||
document.getElementById('acf-count').value = '25';
|
||
document.getElementById('acf-deadline').value = '';
|
||
document.getElementById('acf-student-search').value = '';
|
||
document.getElementById('acf-student-selected').style.display = 'none';
|
||
_acStudentId = null;
|
||
document.getElementById('acf-error').textContent = '';
|
||
document.getElementById('acf-file-search').value = '';
|
||
document.getElementById('acf-file-selected').style.display = 'none';
|
||
|
||
// load classes and tests in parallel
|
||
const [clsSel, testSel] = [document.getElementById('acf-class'), document.getElementById('acf-test')];
|
||
clsSel.innerHTML = '<option value="">Загрузка…</option>';
|
||
testSel.innerHTML = '<option value="">Загрузка…</option>';
|
||
|
||
const [classesP, testsP] = await Promise.allSettled([LS.getClasses(), LS.getTests()]);
|
||
|
||
if (classesP.status === 'fulfilled') {
|
||
const classes = classesP.value;
|
||
clsSel.innerHTML = classes.length
|
||
? '<option value="">— выберите класс —</option>' + classes.map(c => `<option value="${c.id}">${esc(c.name)} (${c.member_count} уч.)</option>`).join('')
|
||
: '<option value="">Нет классов — создайте класс</option>';
|
||
} else {
|
||
clsSel.innerHTML = `<option value="">Ошибка загрузки классов</option>`;
|
||
}
|
||
|
||
if (testsP.status === 'fulfilled') {
|
||
const tests = testsP.value;
|
||
testSel.innerHTML = tests.length
|
||
? '<option value="">— выберите тест —</option>' + tests.map(t => `<option value="${t.id}">${esc(t.title)} (${t.question_count} вопр.)</option>`).join('')
|
||
: '<option value="">Нет тестов — создайте тест</option>';
|
||
} else {
|
||
testSel.innerHTML = `<option value="">Ошибка загрузки тестов</option>`;
|
||
}
|
||
|
||
document.getElementById('ac-modal').classList.add('open');
|
||
setTimeout(() => document.getElementById('acf-title').focus(), 80);
|
||
}
|
||
|
||
function closeCreateAModal() {
|
||
document.getElementById('ac-modal').classList.remove('open');
|
||
}
|
||
|
||
async function saveNewAssignment() {
|
||
const title = document.getElementById('acf-title').value.trim();
|
||
const deadline = document.getElementById('acf-deadline').value || null;
|
||
const errEl = document.getElementById('acf-error');
|
||
errEl.textContent = '';
|
||
if (!title) { errEl.textContent = 'Введите название'; return; }
|
||
|
||
let payload = { title, deadline };
|
||
|
||
if (_acSrc === 'file') {
|
||
if (!_acFileId) { errEl.textContent = 'Выберите файл из библиотеки'; return; }
|
||
const f = _acAllFiles.find(x => x.id === _acFileId);
|
||
payload = { ...payload, file_id: _acFileId, subject_slug: f?.subject_slug || 'bio', mode: 'exam', count: 1 };
|
||
} else if (_acSrc === 'test') {
|
||
const test_id = document.getElementById('acf-test').value;
|
||
const mode = document.getElementById('acf-mode-test').value;
|
||
if (!test_id) { errEl.textContent = 'Выберите тест'; return; }
|
||
const selOpt = document.querySelector(`#acf-test option[value="${test_id}"]`);
|
||
if (selOpt && selOpt.textContent.includes('(0 вопр.)')) { errEl.textContent = 'В выбранном тесте нет вопросов. Добавьте вопросы во вкладке «Тесты».'; return; }
|
||
payload = { ...payload, test_id: Number(test_id), mode };
|
||
} else {
|
||
const subject_slug = document.getElementById('acf-subject').value;
|
||
const mode = document.getElementById('acf-mode').value;
|
||
const count = Number(document.getElementById('acf-count').value);
|
||
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
|
||
if (!count || count < 1) { errEl.textContent = 'Укажите количество вопросов'; return; }
|
||
payload = { ...payload, subject_slug, mode, count };
|
||
}
|
||
|
||
const btn = document.getElementById('acf-save');
|
||
btn.disabled = true; btn.textContent = 'Создание…';
|
||
try {
|
||
if (_acTarget === 'user') {
|
||
if (!_acStudentId) { errEl.textContent = 'Выберите ученика из списка'; btn.disabled=false; btn.textContent='Создать'; return; }
|
||
await LS.createDirectAssignment({ ...payload, student_id: _acStudentId });
|
||
} else {
|
||
const class_id = document.getElementById('acf-class').value;
|
||
if (!class_id) { errEl.textContent = 'Выберите класс'; btn.disabled=false; btn.textContent='Создать'; return; }
|
||
await LS.createAssignment(class_id, payload);
|
||
}
|
||
closeCreateAModal();
|
||
loadAssignments();
|
||
} catch (e) {
|
||
errEl.textContent = 'Ошибка: ' + e.message;
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Создать';
|
||
}
|
||
}
|
||
|
||
async function deleteAsgn(id) {
|
||
const a = allAssignments.find(x => x.id === id);
|
||
if (!await LS.confirm(`Удалить задание «${a?.title}»?\nВсе связанные сессии будут удалены.`, { title: 'Удалить задание', confirmText: 'Удалить' })) return;
|
||
try {
|
||
await LS.deleteAssignment(id);
|
||
allAssignments = allAssignments.filter(x => x.id !== id);
|
||
renderAssignments();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
ДОСТУПНЫЕ ТЕСТЫ — настройка предметов
|
||
════════════════════════════════════════════════ */
|
||
let _subjConfigInited = false;
|
||
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест', topic: 'По теме', random: 'Случайный' };
|
||
const SC_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
|
||
const SC_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
|
||
|
||
// кэш тестов по предмету для селектора
|
||
const _scTests = {};
|
||
async function loadScTests(slug) {
|
||
if (_scTests[slug]) return _scTests[slug];
|
||
const tests = await LS.getTests(slug);
|
||
_scTests[slug] = tests;
|
||
return tests;
|
||
}
|
||
|
||
function setSrcMode(slug, src) {
|
||
const rndBtn = document.getElementById(`sc-src-rnd-${slug}`);
|
||
const fixBtn = document.getElementById(`sc-src-fix-${slug}`);
|
||
const pick = document.getElementById(`sc-test-pick-${slug}`);
|
||
const cntWrap = document.getElementById(`sc-count-wrap-${slug}`);
|
||
rndBtn.classList.toggle('active', src === 'random');
|
||
fixBtn.classList.toggle('active', src === 'fixed');
|
||
pick.classList.toggle('open', src === 'fixed');
|
||
cntWrap.style.display = src === 'random' ? '' : 'none';
|
||
if (src === 'fixed') {
|
||
loadAndRenderTestPick(slug);
|
||
} else {
|
||
// скрыть drawer вопросов при переключении на случайный
|
||
const dr = document.getElementById(`sc-qdr-${slug}`);
|
||
if (dr) { dr.style.display = 'none'; }
|
||
}
|
||
}
|
||
|
||
async function loadAndRenderTestPick(slug) {
|
||
const sel = document.getElementById(`sc-test-sel-${slug}`);
|
||
if (sel.dataset.loaded) return;
|
||
sel.innerHTML = '<option value="">Загрузка…</option>';
|
||
try {
|
||
const tests = await loadScTests(slug);
|
||
const cur = document.getElementById(`sc-card-${slug}`)?.dataset.testId || '';
|
||
sel.innerHTML = `<option value="">— случайные вопросы —</option>` +
|
||
tests.map(t => `<option value="${t.id}"${String(t.id) === cur ? ' selected' : ''}>${esc(t.title)} (${t.question_count ?? '?'} вопр.)</option>`).join('');
|
||
sel.dataset.loaded = '1';
|
||
} catch(e) {
|
||
sel.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||
}
|
||
}
|
||
|
||
async function loadSubjectConfig() {
|
||
const wrap = document.getElementById('subj-config-list');
|
||
wrap.innerHTML = LS.skeleton(4);
|
||
try {
|
||
const subjects = await LS.getSubjects();
|
||
wrap.innerHTML = subjects.map(s => {
|
||
const hasFix = !!s.default_test_id;
|
||
const color = SC_COLORS[s.slug] || '#9B5DE5';
|
||
const mode = s.default_mode || 'exam';
|
||
const count = s.default_count || 25;
|
||
const srcLabel = hasFix ? 'Фикс. тест' : `${count} вопросов`;
|
||
return `
|
||
<div class="sc-card" id="sc-card-${s.slug}" data-test-id="${s.default_test_id || ''}">
|
||
<div class="sc-row-top" onclick="toggleScCard('${s.slug}')">
|
||
<div class="sc-icon" style="background:${color}"><i data-lucide="${SC_ICONS[s.slug]||'book'}"></i></div>
|
||
<div class="sc-info">
|
||
<div class="sc-name">${esc(s.name)}</div>
|
||
<div class="sc-summary" id="sc-sum-${s.slug}">
|
||
<span class="sc-tag sc-tag-mode">${SC_MODES[mode]}</span>
|
||
<span class="sc-tag">${srcLabel}</span>
|
||
<span class="sc-qcount">${s.question_count ?? 0} в базе</span>
|
||
</div>
|
||
</div>
|
||
<i data-lucide="chevron-down" class="sc-chevron"></i>
|
||
</div>
|
||
<div class="sc-body">
|
||
<!-- Quick presets -->
|
||
<div class="sc-presets">
|
||
<button class="sc-preset${mode==='exam'&&count===25&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','exam',25)">Экзамен 25</button>
|
||
<button class="sc-preset${mode==='exam'&&count===40&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','exam',40)">Экзамен 40</button>
|
||
<button class="sc-preset${mode==='practice'&&count===15&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','practice',15)">Практика 15</button>
|
||
<button class="sc-preset${mode==='practice'&&count===25&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','practice',25)">Практика 25</button>
|
||
</div>
|
||
<!-- Detailed fields -->
|
||
<div class="sc-fields">
|
||
<div class="sc-field">
|
||
<span class="sc-label">Режим</span>
|
||
<select class="sc-select" id="sc-mode-${s.slug}">
|
||
${Object.entries(SC_MODES).map(([v, l]) =>
|
||
`<option value="${v}"${mode === v ? ' selected' : ''}>${l}</option>`
|
||
).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="sc-field">
|
||
<span class="sc-label">Источник</span>
|
||
<div class="sc-src-toggle">
|
||
<button class="sc-src-btn${hasFix ? '' : ' active'}" id="sc-src-rnd-${s.slug}" onclick="setSrcMode('${s.slug}','random')">Случайные</button>
|
||
<button class="sc-src-btn${hasFix ? ' active' : ''}" id="sc-src-fix-${s.slug}" onclick="setSrcMode('${s.slug}','fixed')">Из теста</button>
|
||
</div>
|
||
</div>
|
||
<div class="sc-field" id="sc-count-wrap-${s.slug}" style="${hasFix ? 'display:none' : ''}">
|
||
<span class="sc-label">Вопросов</span>
|
||
<input class="sc-input" type="number" id="sc-count-${s.slug}" min="5" max="100" value="${count}" />
|
||
</div>
|
||
<div class="sc-test-pick${hasFix ? ' open' : ''}" id="sc-test-pick-${s.slug}">
|
||
<div class="sc-field">
|
||
<span class="sc-label">Тест</span>
|
||
<select class="sc-select" id="sc-test-sel-${s.slug}" onchange="onScTestChange('${s.slug}')">
|
||
<option value="${s.default_test_id || ''}" selected>Загрузка...</option>
|
||
</select>
|
||
</div>
|
||
<button class="sc-save-add" id="sc-qdr-btn-${s.slug}" style="display:${hasFix?'':'none'};align-self:flex-start"
|
||
onclick="toggleScDrawer('${s.slug}')"><i data-lucide="list" style="width:13px;height:13px;vertical-align:-2px"></i> Вопросы</button>
|
||
</div>
|
||
</div>
|
||
<!-- Footer -->
|
||
<div class="sc-footer">
|
||
<button class="sc-save" id="sc-save-btn-${s.slug}" onclick="saveSubjectConfig('${s.slug}')">Сохранить</button>
|
||
<button class="sc-save-add" onclick="goAddQuestion('${s.slug}')"><i data-lucide="plus" style="width:13px;height:13px;vertical-align:-2px"></i> Вопрос</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="sc-qdr-${s.slug}" style="display:none;border-top:1px solid var(--border);padding:20px 24px;background:rgba(238,242,255,0.5)">
|
||
<div id="sc-qdr-inner-${s.slug}"></div>
|
||
</div>`;
|
||
}).join('');
|
||
if (window.lucide) lucide.createIcons();
|
||
// pre-load test selectors and show Вопросы button for subjects already using a fixed test
|
||
subjects.filter(s => s.default_test_id).forEach(s => {
|
||
loadAndRenderTestPick(s.slug);
|
||
const btn = document.getElementById(`sc-qdr-btn-${s.slug}`);
|
||
if (btn) btn.style.display = '';
|
||
});
|
||
} catch (e) {
|
||
wrap.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function toggleScCard(slug) {
|
||
const card = document.getElementById('sc-card-' + slug);
|
||
if (!card) return;
|
||
const wasOpen = card.classList.contains('open');
|
||
// Close all
|
||
document.querySelectorAll('.sc-card.open').forEach(c => c.classList.remove('open'));
|
||
if (!wasOpen) {
|
||
card.classList.add('open');
|
||
if (window.lucide) lucide.createIcons({ nodes: [card] });
|
||
}
|
||
}
|
||
|
||
function applyPreset(slug, mode, count) {
|
||
document.getElementById('sc-mode-' + slug).value = mode;
|
||
document.getElementById('sc-count-' + slug).value = count;
|
||
setSrcMode(slug, 'random');
|
||
// Highlight active preset
|
||
const card = document.getElementById('sc-card-' + slug);
|
||
card.querySelectorAll('.sc-preset').forEach(p => p.classList.remove('active'));
|
||
const isFix = document.getElementById('sc-src-fix-' + slug).classList.contains('active');
|
||
card.querySelectorAll('.sc-preset').forEach(p => {
|
||
const txt = p.textContent.trim();
|
||
const mLabel = SC_MODES[mode];
|
||
if (txt === mLabel + ' ' + count && !isFix) p.classList.add('active');
|
||
});
|
||
// Auto-save
|
||
saveSubjectConfig(slug);
|
||
}
|
||
|
||
function updateScSummary(slug) {
|
||
const el = document.getElementById('sc-sum-' + slug);
|
||
if (!el) return;
|
||
const mode = document.getElementById('sc-mode-' + slug).value;
|
||
const isFix = document.getElementById('sc-src-fix-' + slug).classList.contains('active');
|
||
const count = document.getElementById('sc-count-' + slug).value;
|
||
const srcLabel = isFix ? 'Фикс. тест' : count + ' вопросов';
|
||
el.innerHTML = `<span class="sc-tag sc-tag-mode">${SC_MODES[mode]}</span><span class="sc-tag">${srcLabel}</span>`;
|
||
}
|
||
|
||
function scPreviewText(s) {
|
||
if (s.default_test_id) return `На дашборде: «${SC_MODES[s.default_mode || 'exam']}», фиксированный тест`;
|
||
return `На дашборде: «${SC_MODES[s.default_mode || 'exam']}», ${s.default_count || 25} вопросов (случайные)`;
|
||
}
|
||
|
||
async function saveSubjectConfig(slug) {
|
||
const btn = document.getElementById(`sc-save-btn-${slug}`);
|
||
const mode = document.getElementById(`sc-mode-${slug}`).value;
|
||
const isFix = document.getElementById(`sc-src-fix-${slug}`).classList.contains('active');
|
||
const count = Number(document.getElementById(`sc-count-${slug}`)?.value || 25);
|
||
const testId = isFix ? (document.getElementById(`sc-test-sel-${slug}`).value || null) : null;
|
||
|
||
if (btn) { btn.disabled = true; btn.textContent = '...'; }
|
||
const payload = { default_mode: mode, default_count: count, default_test_id: testId ? Number(testId) : null };
|
||
try {
|
||
await LS.updateSubject(slug, payload);
|
||
document.getElementById(`sc-card-${slug}`).dataset.testId = testId || '';
|
||
if (isFix) document.getElementById(`sc-test-sel-${slug}`).dataset.loaded = '';
|
||
updateScSummary(slug);
|
||
// Visual feedback
|
||
if (btn) { btn.classList.add('saved'); btn.textContent = 'Сохранено'; }
|
||
setTimeout(() => { if (btn) { btn.classList.remove('saved'); btn.textContent = 'Сохранить'; btn.disabled = false; } }, 1500);
|
||
} catch (e) {
|
||
LS.toast('Ошибка: ' + e.message, 'error');
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Сохранить'; }
|
||
}
|
||
}
|
||
|
||
/* ── Subject drawer: управление вопросами теста ── */
|
||
function onScTestChange(slug) {
|
||
const tid = document.getElementById(`sc-test-sel-${slug}`).value;
|
||
const btn = document.getElementById(`sc-qdr-btn-${slug}`);
|
||
btn.style.display = tid ? '' : 'none';
|
||
// скрыть drawer если тест сменился
|
||
const dr = document.getElementById(`sc-qdr-${slug}`);
|
||
dr.style.display = 'none';
|
||
document.getElementById(`sc-qdr-inner-${slug}`).innerHTML = '';
|
||
}
|
||
|
||
const _scDrOpen = {}; // slug <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> bool
|
||
async function toggleScDrawer(slug) {
|
||
const dr = document.getElementById(`sc-qdr-${slug}`);
|
||
const tid = Number(document.getElementById(`sc-test-sel-${slug}`).value);
|
||
if (!tid) return;
|
||
if (dr.style.display !== 'none') { dr.style.display = 'none'; return; }
|
||
dr.style.display = '';
|
||
await renderScDrawer(slug, tid);
|
||
}
|
||
|
||
const _scCache = {}; // tid <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> { test, subjectQs }
|
||
async function renderScDrawer(slug, tid) {
|
||
const inner = document.getElementById(`sc-qdr-inner-${slug}`);
|
||
inner.innerHTML = LS.skeleton(3, 'row');
|
||
try {
|
||
const [t, subjectQs] = await Promise.all([
|
||
LS.getTest(tid),
|
||
LS.getQuestions(slug, null, 'date_asc').catch(() => []),
|
||
]);
|
||
_scCache[tid] = { test: t, subjectQs };
|
||
inner.innerHTML = `
|
||
<div class="tst-cols">
|
||
<div>
|
||
<div class="tst-panel-title">Вопросы в тесте (<span id="sc-qcnt-${tid}">${t.questions.length}</span>)</div>
|
||
<div class="tst-q-list" id="sc-ql-${tid}">${renderScQList(t.questions, tid, slug)}</div>
|
||
</div>
|
||
<div>
|
||
<div class="tst-panel-title">Добавить из базы</div>
|
||
<input class="tst-search" placeholder="Поиск…" oninput="filterScPicker(${tid},'${slug}',this.value)" />
|
||
<div class="tst-q-list" id="sc-pick-${tid}">${renderScPicker(subjectQs, new Set(t.questions.map(q=>q.id)), tid, slug)}</div>
|
||
</div>
|
||
</div>`;
|
||
renderMath(inner);
|
||
} catch(e) {
|
||
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderScQList(questions, tid, slug) {
|
||
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>';
|
||
return questions.map((q,i) => `
|
||
<div class="tst-q-item" id="sc-qi-${tid}-${q.id}">
|
||
<span class="tst-q-num">${i+1}.</span>
|
||
<div class="tst-q-body">
|
||
<span class="tst-q-text">${esc(q.text)}</span>
|
||
<div class="tst-q-meta">
|
||
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
|
||
${qTypeBadge(q.type)}
|
||
${qOptsPreview(q)}
|
||
</div>
|
||
</div>
|
||
<button class="btn-tst-rem" onclick="scRemoveQ(${tid},'${slug}',${q.id})" title="Убрать">−</button>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function renderScPicker(questions, inIds, tid, slug) {
|
||
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
|
||
return questions.map(q => {
|
||
const added = inIds.has(q.id);
|
||
return `
|
||
<div class="tst-q-item" id="sc-pick-item-${tid}-${q.id}" style="${added?'opacity:0.4;pointer-events:none':''}">
|
||
<div class="tst-q-body" style="flex:1">
|
||
<span class="tst-q-text">${esc(q.text)}</span>
|
||
<div class="tst-q-meta">
|
||
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
|
||
${qTypeBadge(q.type)}
|
||
${q.topic ? `<span class="tst-q-badge" style="background:rgba(6,214,224,0.1);color:#05aab3">${esc(q.topic)}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<button class="btn-tst-add" id="sc-add-btn-${tid}-${q.id}" onclick="scAddQ(${tid},'${slug}',${q.id},this)" title="Добавить">${added?'<i data-lucide="check" style="width:14px;height:14px"></i>':'+' }</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function filterScPicker(tid, slug, q) {
|
||
const cache = _scCache[tid];
|
||
if (!cache) return;
|
||
const lq = q.toLowerCase();
|
||
const filtered = lq.length < 1
|
||
? cache.subjectQs
|
||
: cache.subjectQs.filter(x => x.text.toLowerCase().includes(lq) || (x.topic||'').toLowerCase().includes(lq));
|
||
const inIds = new Set(cache.test.questions.map(x=>x.id));
|
||
document.getElementById(`sc-pick-${tid}`).innerHTML = renderScPicker(filtered, inIds, tid, slug);
|
||
}
|
||
|
||
async function scAddQ(tid, slug, qid, btn) {
|
||
btn.disabled = true; btn.textContent = '…';
|
||
try {
|
||
await LS.addQuestionsToTest(tid, [qid]);
|
||
// обновить кэш и списки
|
||
const t = await LS.getTest(tid);
|
||
_scCache[tid].test = t;
|
||
const inIds = new Set(t.questions.map(q=>q.id));
|
||
document.getElementById(`sc-ql-${tid}`).innerHTML = renderScQList(t.questions, tid, slug);
|
||
document.getElementById(`sc-qcnt-${tid}`).textContent = t.questions.length;
|
||
// пометить кнопку в пикере
|
||
const item = document.getElementById(`sc-pick-item-${tid}-${qid}`);
|
||
if (item) { item.style.opacity='0.4'; item.style.pointerEvents='none'; }
|
||
const addBtn = document.getElementById(`sc-add-btn-${tid}-${qid}`);
|
||
if (addBtn) { addBtn.innerHTML = '<i data-lucide="check" style="width:14px;height:14px"></i>'; if(window.lucide)lucide.createIcons(); }
|
||
renderMath(document.getElementById(`sc-ql-${tid}`));
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); btn.disabled=false; btn.textContent='+'; }
|
||
}
|
||
|
||
async function scRemoveQ(tid, slug, qid) {
|
||
try {
|
||
await LS.removeQFromTest(tid, qid);
|
||
const t = await LS.getTest(tid);
|
||
_scCache[tid].test = t;
|
||
const inIds = new Set(t.questions.map(q=>q.id));
|
||
document.getElementById(`sc-ql-${tid}`).innerHTML = renderScQList(t.questions, tid, slug);
|
||
document.getElementById(`sc-qcnt-${tid}`).textContent = t.questions.length;
|
||
// разблокировать в пикере
|
||
const item = document.getElementById(`sc-pick-item-${tid}-${qid}`);
|
||
if (item) { item.style.opacity=''; item.style.pointerEvents=''; }
|
||
const addBtn = document.getElementById(`sc-add-btn-${tid}-${qid}`);
|
||
if (addBtn) { addBtn.textContent='+'; addBtn.disabled=false; }
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ─── User permissions modal ───────────────────────────────────────── */
|
||
let _upPermsData = null;
|
||
|
||
function closeUserPermsModal() {
|
||
document.getElementById('up-modal').classList.remove('open');
|
||
_upPermsData = null;
|
||
}
|
||
|
||
async function openUserPermsModal() {
|
||
if (!activeUid) return;
|
||
const name = document.getElementById('up-name').textContent;
|
||
document.getElementById('up-modal-title').textContent = `Права: ${name}`;
|
||
document.getElementById('up-modal-list').innerHTML = LS.skeleton(5, 'row');
|
||
document.getElementById('up-modal').classList.add('open');
|
||
try {
|
||
_upPermsData = await LS.getUserPermissions(activeUid);
|
||
renderUserPerms();
|
||
} catch(e) {
|
||
document.getElementById('up-modal-list').innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
function renderUserPerms() {
|
||
if (!_upPermsData) return;
|
||
const list = document.getElementById('up-modal-list');
|
||
list.innerHTML = _upPermsData.permissions.map(p => {
|
||
const hasOverride = p.userVal !== undefined;
|
||
const checked = p.effective;
|
||
const badge = hasOverride
|
||
? `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(155,93,229,0.12);color:var(--violet);font-weight:700">Инд.</span>`
|
||
: `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(136,152,170,0.12);color:var(--text-3);font-weight:700">По роли</span>`;
|
||
const resetBtn = hasOverride
|
||
? `<button style="background:none;border:none;cursor:pointer;color:var(--text-3);padding:3px 6px;border-radius:6px;font-size:11px;font-weight:700;transition:color .2s"
|
||
onmouseover="this.style.color='var(--danger)'" onmouseout="this.style.color='var(--text-3)'"
|
||
onclick="doResetOneUserPerm('${esc(p.key)}')" title="Сбросить к роли">×</button>`
|
||
: '';
|
||
return `
|
||
<div class="perm-card${checked ? ' enabled' : ''}" id="up-perm-card-${p.key.replace('.','_')}">
|
||
<div class="perm-info">
|
||
<div style="display:flex;align-items:center;gap:7px">
|
||
<span class="perm-label">${esc(p.label)}</span>
|
||
${badge}
|
||
${resetBtn}
|
||
</div>
|
||
<div class="perm-desc">${esc(p.desc)}</div>
|
||
</div>
|
||
<label class="perm-toggle">
|
||
<input type="checkbox" ${checked ? 'checked' : ''}
|
||
onchange="doSetUserPerm('${esc(p.key)}', this.checked, this)">
|
||
<span class="perm-track"></span>
|
||
<span class="perm-thumb"></span>
|
||
</label>
|
||
</div>`;
|
||
}).join('');
|
||
// update reset-all btn visibility
|
||
const hasAny = _upPermsData.permissions.some(p => p.userVal !== undefined);
|
||
document.getElementById('up-modal-reset-btn').style.opacity = hasAny ? '1' : '0.4';
|
||
}
|
||
|
||
async function doSetUserPerm(key, enabled, checkbox) {
|
||
checkbox.disabled = true;
|
||
try {
|
||
await LS.setUserPermission(activeUid, key, enabled);
|
||
_upPermsData = await LS.getUserPermissions(activeUid);
|
||
renderUserPerms();
|
||
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
|
||
} catch(e) {
|
||
checkbox.checked = !enabled;
|
||
LS.toast('Ошибка: ' + e.message, 'error');
|
||
} finally {
|
||
checkbox.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function doResetOneUserPerm(key) {
|
||
try {
|
||
await LS.resetUserPermissions(activeUid, key);
|
||
_upPermsData = await LS.getUserPermissions(activeUid);
|
||
renderUserPerms();
|
||
LS.toast('Сброшено к значению роли', 'success');
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function doResetAllUserPerms() {
|
||
const name = document.getElementById('up-name').textContent;
|
||
if (!await LS.confirm(`Сбросить все индивидуальные права «${name}»?\nБудут применены права роли.`, { title: 'Сбросить права', confirmText: 'Сбросить' })) return;
|
||
try {
|
||
await LS.resetUserPermissions(activeUid);
|
||
_upPermsData = await LS.getUserPermissions(activeUid);
|
||
renderUserPerms();
|
||
LS.toast('Права сброшены к роли', 'success');
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ─── Permissions tab ──────────────────────────────────────────────── */
|
||
let _permData = null;
|
||
|
||
async function loadPermissions() {
|
||
try {
|
||
_permData = await LS.getPermissions();
|
||
renderPermissions();
|
||
} catch(e) {
|
||
document.getElementById('perm-teacher').innerHTML =
|
||
`<p style="color:var(--danger);font-size:13px">Ошибка загрузки: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
function renderPermissions() {
|
||
if (!_permData) return;
|
||
const { permissions, definitions } = _permData;
|
||
['teacher', 'student'].forEach(role => {
|
||
const container = document.getElementById('perm-' + role);
|
||
const defs = definitions.filter(d => d.role === role);
|
||
container.innerHTML = defs.map(def => {
|
||
const enabled = permissions[role]?.[def.key] ?? def.default;
|
||
return `
|
||
<div class="perm-card${enabled ? ' enabled' : ''}" id="perm-card-${role}-${def.key.replace('.','_')}">
|
||
<div class="perm-info">
|
||
<div class="perm-label">${esc(def.label)}</div>
|
||
<div class="perm-desc">${esc(def.desc)}</div>
|
||
</div>
|
||
<label class="perm-toggle" title="${enabled ? 'Выключить' : 'Включить'}">
|
||
<input type="checkbox" ${enabled ? 'checked' : ''}
|
||
onchange="togglePermission('${esc(role)}','${esc(def.key)}',this.checked,this)">
|
||
<span class="perm-track"></span>
|
||
<span class="perm-thumb"></span>
|
||
</label>
|
||
</div>`;
|
||
}).join('');
|
||
});
|
||
}
|
||
|
||
async function togglePermission(role, key, enabled, checkbox) {
|
||
checkbox.disabled = true;
|
||
try {
|
||
await LS.setPermission(role, key, enabled);
|
||
// update local cache
|
||
if (!_permData.permissions[role]) _permData.permissions[role] = {};
|
||
_permData.permissions[role][key] = enabled;
|
||
// update card style
|
||
const safeKey = key.replace('.', '_');
|
||
const card = document.getElementById(`perm-card-${role}-${safeKey}`);
|
||
if (card) card.classList.toggle('enabled', enabled);
|
||
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
|
||
} catch(e) {
|
||
checkbox.checked = !enabled; // revert
|
||
LS.toast('Ошибка: ' + e.message, 'error');
|
||
} finally {
|
||
checkbox.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
МАГАЗИН (Shop)
|
||
════════════════════════════════════════════════ */
|
||
let _shopItems = [];
|
||
let _shopEditId = null;
|
||
|
||
async function loadShopAdmin() {
|
||
try {
|
||
const [stats, items] = await Promise.all([
|
||
LS.adminShopStats(),
|
||
LS.adminShopGetItems()
|
||
]);
|
||
const topName = stats.topItems?.[0]?.name || '—';
|
||
document.getElementById('shop-stats-grid').innerHTML = `
|
||
<div class="stat-card" style="--stat-top:var(--violet)">
|
||
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="shopping-bag" class="stat-icon"></i></div>
|
||
<div class="stat-val violet">${stats.activeItems}/${stats.totalItems}</div>
|
||
<div class="stat-label">Товаров</div>
|
||
</div>
|
||
<div class="stat-card" style="--stat-top:var(--cyan)">
|
||
<div class="stat-card-icon" style="background:rgba(6,214,224,0.1)"><i data-lucide="receipt" class="stat-icon"></i></div>
|
||
<div class="stat-val cyan">${stats.totalPurchases}</div>
|
||
<div class="stat-label">Покупок</div>
|
||
</div>
|
||
<div class="stat-card" style="--stat-top:var(--green)">
|
||
<div class="stat-card-icon" style="background:rgba(6,214,100,0.1)"><i data-lucide="coins" class="stat-icon"></i></div>
|
||
<div class="stat-val green">${stats.totalCoinsInCirculation}</div>
|
||
<div class="stat-label">Монет в обороте</div>
|
||
</div>
|
||
<div class="stat-card" style="--stat-top:var(--amber, #FFB347)">
|
||
<div class="stat-card-icon" style="background:rgba(255,179,71,0.1)"><i data-lucide="star" class="stat-icon"></i></div>
|
||
<div class="stat-val" style="color:var(--amber, #FFB347);font-size:1.1rem">${esc(topName)}</div>
|
||
<div class="stat-label">Топ товар</div>
|
||
</div>`;
|
||
_shopItems = items;
|
||
renderShopItems();
|
||
if (window.lucide) lucide.createIcons();
|
||
} catch(e) {
|
||
document.getElementById('shop-stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderShopItems() {
|
||
const body = document.getElementById('shop-items-body');
|
||
if (!_shopItems.length) { body.innerHTML = '<tr><td colspan="7" class="empty">Нет товаров</td></tr>'; return; }
|
||
const typeLabels = { frame:'Рамка', title:'Титул', theme:'Тема', effect:'Эффект' };
|
||
body.innerHTML = _shopItems.map(it => `<tr>
|
||
<td>${it.id}</td>
|
||
<td><strong>${esc(it.name)}</strong></td>
|
||
<td><span class="mode-badge mode-practice">${typeLabels[it.type] || esc(it.type)}</span></td>
|
||
<td>${it.price} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px;color:var(--amber, #FFB347)"></i></td>
|
||
<td>${it.sold_count || 0}</td>
|
||
<td>
|
||
<label class="adm-toggle">
|
||
<input type="checkbox" ${it.is_active ? 'checked' : ''} onchange="shopAdminToggleActive(${it.id}, this.checked)" />
|
||
<span class="track"></span><span class="thumb"></span>
|
||
</label>
|
||
</td>
|
||
<td>
|
||
<button class="btn-edit-q" onclick="shopAdminEditItem(${it.id})">Ред.</button>
|
||
<button class="btn-del-q" onclick="shopAdminDeleteItem(${it.id})">Удалить</button>
|
||
</td>
|
||
</tr>`).join('');
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
function shopAdminCreateItem() {
|
||
_shopEditId = null;
|
||
document.getElementById('shop-form-title').textContent = 'Новый товар';
|
||
document.getElementById('shop-f-name').value = '';
|
||
document.getElementById('shop-f-type').value = 'frame';
|
||
document.getElementById('shop-f-price').value = '100';
|
||
document.getElementById('shop-f-desc').value = '';
|
||
document.getElementById('shop-f-icon').value = '';
|
||
document.getElementById('shop-f-data').value = '';
|
||
document.getElementById('shop-f-active').checked = true;
|
||
document.getElementById('shop-item-form').style.display = '';
|
||
}
|
||
|
||
function shopAdminEditItem(id) {
|
||
const it = _shopItems.find(i => i.id === id);
|
||
if (!it) return;
|
||
_shopEditId = id;
|
||
document.getElementById('shop-form-title').textContent = 'Редактировать товар #' + id;
|
||
document.getElementById('shop-f-name').value = it.name || '';
|
||
document.getElementById('shop-f-type').value = it.type || 'frame';
|
||
document.getElementById('shop-f-price').value = it.price ?? 100;
|
||
document.getElementById('shop-f-desc').value = it.description || '';
|
||
document.getElementById('shop-f-icon').value = it.icon || '';
|
||
document.getElementById('shop-f-data').value = it.data ? (typeof it.data === 'string' ? it.data : JSON.stringify(it.data)) : '';
|
||
document.getElementById('shop-f-active').checked = !!it.is_active;
|
||
document.getElementById('shop-item-form').style.display = '';
|
||
}
|
||
|
||
function shopAdminCancelForm() {
|
||
document.getElementById('shop-item-form').style.display = 'none';
|
||
_shopEditId = null;
|
||
}
|
||
|
||
let _shopSaving = false;
|
||
async function shopAdminSaveItem() {
|
||
if (_shopSaving) return;
|
||
_shopSaving = true;
|
||
const data = {
|
||
name: document.getElementById('shop-f-name').value.trim(),
|
||
type: document.getElementById('shop-f-type').value,
|
||
price: parseInt(document.getElementById('shop-f-price').value) || 0,
|
||
description: document.getElementById('shop-f-desc').value.trim(),
|
||
icon: document.getElementById('shop-f-icon').value.trim(),
|
||
data: document.getElementById('shop-f-data').value.trim() || null,
|
||
is_active: document.getElementById('shop-f-active').checked ? 1 : 0
|
||
};
|
||
if (!data.name) { LS.toast('Введите название', 'error'); return; }
|
||
try {
|
||
if (_shopEditId) {
|
||
await LS.adminShopUpdateItem(_shopEditId, data);
|
||
LS.toast('Товар обновлён', 'success');
|
||
} else {
|
||
await LS.adminShopCreateItem(data);
|
||
LS.toast('Товар создан', 'success');
|
||
}
|
||
shopAdminCancelForm();
|
||
shopInited = false;
|
||
loadShopAdmin();
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
finally { _shopSaving = false; }
|
||
}
|
||
|
||
async function shopAdminDeleteItem(id) {
|
||
if (!await LS.confirm('Все покупки этого товара будут удалены.', { title: 'Удалить товар?', confirmText: 'Удалить', danger: true })) return;
|
||
try {
|
||
await LS.adminShopDeleteItem(id);
|
||
LS.toast('Товар удалён', 'success');
|
||
shopInited = false;
|
||
loadShopAdmin();
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function shopAdminToggleActive(id, active) {
|
||
try {
|
||
await LS.adminShopUpdateItem(id, { is_active: active ? 1 : 0 });
|
||
LS.toast(active ? 'Товар активирован' : 'Товар деактивирован', 'success');
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
let _shopSearchTimer = null;
|
||
async function shopSearchUser(q) {
|
||
clearTimeout(_shopSearchTimer);
|
||
const box = document.getElementById('shop-award-results');
|
||
if (q.length < 2) { box.classList.remove('open'); return; }
|
||
_shopSearchTimer = setTimeout(async () => {
|
||
try {
|
||
const r = await LS.adminGetUsers({ q, limit: 8 });
|
||
box.innerHTML = (r.users || []).map(u => `<div class="us-item" onclick="shopPickUser(${u.id}, '${esc(u.name || u.email)}')">
|
||
<span>${esc(u.name || u.email)}</span><span class="us-role">${u.role}</span>
|
||
</div>`).join('') || '<div class="us-item" style="color:var(--text-3)">Не найдено</div>';
|
||
box.classList.add('open');
|
||
} catch(e) { box.classList.remove('open'); }
|
||
}, 300);
|
||
}
|
||
function shopPickUser(id, name) {
|
||
document.getElementById('shop-award-uid').value = id;
|
||
document.getElementById('shop-award-user').value = name;
|
||
document.getElementById('shop-award-results').classList.remove('open');
|
||
}
|
||
|
||
let _coinsAwarding = false;
|
||
async function shopAdminAwardCoins() {
|
||
if (_coinsAwarding) return;
|
||
const userId = parseInt(document.getElementById('shop-award-uid').value);
|
||
const amount = parseInt(document.getElementById('shop-award-amount').value);
|
||
const reason = document.getElementById('shop-award-reason').value.trim();
|
||
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
|
||
if (!amount || amount <= 0) { LS.toast('Введите количество монет', 'error'); return; }
|
||
_coinsAwarding = true;
|
||
try {
|
||
const r = await LS.adminShopAwardCoins({ userId, amount, reason });
|
||
LS.toast(`Начислено ${amount} монет. Баланс: ${r.coins}`, 'success');
|
||
document.getElementById('shop-award-uid').value = '';
|
||
document.getElementById('shop-award-user').value = '';
|
||
document.getElementById('shop-award-reason').value = '';
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
finally { _coinsAwarding = false; }
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
ГЕЙМИФИКАЦИЯ (Gamification)
|
||
════════════════════════════════════════════════ */
|
||
async function loadGamAdmin() {
|
||
try {
|
||
const stats = await LS.adminGamStats();
|
||
document.getElementById('gam-stats-grid').innerHTML = `
|
||
<div class="stat-card" style="--stat-top:var(--violet)">
|
||
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="zap" class="stat-icon"></i></div>
|
||
<div class="stat-val violet">${stats.totalXP}</div>
|
||
<div class="stat-label">Суммарный XP</div>
|
||
</div>
|
||
<div class="stat-card" style="--stat-top:var(--cyan)">
|
||
<div class="stat-card-icon" style="background:rgba(6,214,224,0.1)"><i data-lucide="coins" class="stat-icon"></i></div>
|
||
<div class="stat-val cyan">${stats.totalCoins}</div>
|
||
<div class="stat-label">Суммарные монеты</div>
|
||
</div>
|
||
<div class="stat-card" style="--stat-top:var(--green)">
|
||
<div class="stat-card-icon" style="background:rgba(6,214,100,0.1)"><i data-lucide="bar-chart-3" class="stat-icon"></i></div>
|
||
<div class="stat-val green">${(stats.avgLevel ?? 0).toFixed(1)}</div>
|
||
<div class="stat-label">Средний уровень</div>
|
||
</div>
|
||
<div class="stat-card" style="--stat-top:var(--amber, #FFB347)">
|
||
<div class="stat-card-icon" style="background:rgba(255,179,71,0.1)"><i data-lucide="trophy" class="stat-icon"></i></div>
|
||
<div class="stat-val" style="color:var(--amber, #FFB347)">${stats.achievementCount}</div>
|
||
<div class="stat-label">Достижений выдано</div>
|
||
</div>
|
||
<div class="stat-card" style="--stat-top:#FF9F1C">
|
||
<div class="stat-card-icon" style="background:rgba(255,159,28,0.1)"><i data-lucide="shopping-bag" class="stat-icon"></i></div>
|
||
<div class="stat-val" style="color:#FF9F1C">${stats.totalPurchases || 0}</div>
|
||
<div class="stat-label">Покупок</div>
|
||
</div>`;
|
||
|
||
// Top-10
|
||
const topBody = document.getElementById('gam-top-body');
|
||
if (stats.topByXP?.length) {
|
||
topBody.innerHTML = stats.topByXP.slice(0, 10).map((u, i) => `<tr>
|
||
<td><strong>${i + 1}</strong></td>
|
||
<td>${esc(u.name || u.email || 'ID:' + (u.id || u.user_id))}</td>
|
||
<td><span style="color:var(--violet);font-weight:700">${u.xp}</span></td>
|
||
<td>${u.level}</td>
|
||
<td>${u.coins} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px;color:var(--amber, #FFB347)"></i></td>
|
||
</tr>`).join('');
|
||
} else {
|
||
topBody.innerHTML = '<tr><td colspan="5" class="empty">Нет данных</td></tr>';
|
||
}
|
||
|
||
// Recent XP
|
||
const XP_REASONS = {
|
||
'daily_activity': ['sun', '#F59E0B', 'Ежедневная активность'],
|
||
'correct_answers':['check-circle', '#10B981', 'Правильные ответы'],
|
||
'test_complete': ['file-text', '#06B6D4', 'Тест завершён'],
|
||
'test_90+': ['zap', '#9B5DE5', 'Тест на 90%+'],
|
||
'test_perfect': ['trophy', '#F59E0B', 'Идеальный тест (100%)'],
|
||
'lab_experiment': ['atom', '#06D6A0', 'Лабораторный эксперимент'],
|
||
'daily_goal': ['target', '#EF476F', 'Ежедневная цель выполнена'],
|
||
'Admin award': ['crown', '#9B5DE5', 'Начисление администратором'],
|
||
};
|
||
function fmtXPReason(reason) {
|
||
if (!reason) return '—';
|
||
const entry = XP_REASONS[reason];
|
||
if (entry) {
|
||
const [icon, color, label] = entry;
|
||
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:${color};display:inline-flex">${lsIcon(icon,14)}</span>${label}</span>`;
|
||
}
|
||
if (reason.startsWith('achievement:')) {
|
||
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:#F59E0B;display:inline-flex">${lsIcon('award',14)}</span>Достижение: ${esc(reason.slice(12))}</span>`;
|
||
}
|
||
if (reason.startsWith('Испытание:')) {
|
||
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:#EF476F;display:inline-flex">${lsIcon('swords',14)}</span>${esc(reason)}</span>`;
|
||
}
|
||
return esc(reason);
|
||
}
|
||
|
||
const logBody = document.getElementById('gam-log-body');
|
||
if (stats.recentXP?.length) {
|
||
logBody.innerHTML = stats.recentXP.slice(0, 20).map(e => `<tr>
|
||
<td style="font-size:0.78rem;color:var(--text-3)">${fmtDate(e.created_at || e.date)}</td>
|
||
<td>${esc(e.name || e.user_name || '—')}</td>
|
||
<td><span style="color:var(--violet);font-weight:700">+${e.amount}</span></td>
|
||
<td style="font-size:0.82rem;color:var(--text-2)">${fmtXPReason(e.reason)}</td>
|
||
</tr>`).join('');
|
||
} else {
|
||
logBody.innerHTML = '<tr><td colspan="4" class="empty">Нет данных</td></tr>';
|
||
}
|
||
|
||
// Purchases
|
||
const purchBody = document.getElementById('gam-purchases-body');
|
||
if (stats.recentPurchases?.length) {
|
||
purchBody.innerHTML = stats.recentPurchases.slice(0, 20).map(p => `<tr>
|
||
<td style="font-size:0.78rem;color:var(--text-3)">${fmtDate(p.purchased_at)}</td>
|
||
<td>${esc(p.user_name || '—')}</td>
|
||
<td style="font-weight:600">${esc(p.item_name || '—')}</td>
|
||
<td><span class="badge" style="font-size:0.7rem">${esc(p.type || '—')}</span></td>
|
||
<td style="color:var(--amber,#FFB347);font-weight:700">${p.price} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px"></i></td>
|
||
</tr>`).join('');
|
||
} else {
|
||
purchBody.innerHTML = '<tr><td colspan="5" class="empty">Нет покупок</td></tr>';
|
||
}
|
||
|
||
if (window.lucide) lucide.createIcons();
|
||
} catch(e) {
|
||
document.getElementById('gam-stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
let _gamSearchTimer = null;
|
||
async function gamSearchUser(q, prefix) {
|
||
clearTimeout(_gamSearchTimer);
|
||
const box = document.getElementById(prefix + '-results');
|
||
if (q.length < 2) { box.classList.remove('open'); return; }
|
||
_gamSearchTimer = setTimeout(async () => {
|
||
try {
|
||
const r = await LS.adminGetUsers({ q, limit: 8 });
|
||
box.innerHTML = (r.users || []).map(u => `<div class="us-item" onclick="gamPickUser(${u.id}, '${esc(u.name || u.email)}', '${prefix}')">
|
||
<span>${esc(u.name || u.email)}</span><span class="us-role">${u.role}</span>
|
||
</div>`).join('') || '<div class="us-item" style="color:var(--text-3)">Не найдено</div>';
|
||
box.classList.add('open');
|
||
} catch(e) { box.classList.remove('open'); }
|
||
}, 300);
|
||
}
|
||
function gamPickUser(id, name, prefix) {
|
||
document.getElementById(prefix + '-uid').value = id;
|
||
document.getElementById(prefix + '-user').value = name;
|
||
document.getElementById(prefix + '-results').classList.remove('open');
|
||
}
|
||
|
||
let _gamAwarding = false;
|
||
async function gamAdminAward() {
|
||
if (_gamAwarding) return;
|
||
const userId = parseInt(document.getElementById('gam-award-uid').value);
|
||
const xp = parseInt(document.getElementById('gam-award-xp').value) || 0;
|
||
const coins = parseInt(document.getElementById('gam-award-coins').value) || 0;
|
||
const reason = document.getElementById('gam-award-reason').value.trim();
|
||
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
|
||
if (!xp && !coins) { LS.toast('Введите XP или монеты', 'error'); return; }
|
||
_gamAwarding = true;
|
||
try {
|
||
const r = await LS.adminGamAward({ userId, xp, coins, reason });
|
||
LS.toast(`Начислено! XP: ${r.xp}, Уровень: ${r.level}, Монеты: ${r.coins}`, 'success');
|
||
document.getElementById('gam-award-uid').value = '';
|
||
document.getElementById('gam-award-user').value = '';
|
||
document.getElementById('gam-award-reason').value = '';
|
||
gamInited = false;
|
||
loadGamAdmin();
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
finally { _gamAwarding = false; }
|
||
}
|
||
|
||
async function gamAdminReset() {
|
||
const userId = parseInt(document.getElementById('gam-reset-uid').value);
|
||
const userName = document.getElementById('gam-reset-user').value;
|
||
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
|
||
if (!await LS.confirm(`ВСЕ XP, монеты и достижения «${userName}» будут удалены безвозвратно.`, { title: 'Сбросить прогресс?', confirmText: 'Сбросить', danger: true })) return;
|
||
try {
|
||
await LS.adminGamReset({ userId });
|
||
LS.toast('Прогресс сброшен', 'success');
|
||
document.getElementById('gam-reset-uid').value = '';
|
||
document.getElementById('gam-reset-user').value = '';
|
||
gamInited = false;
|
||
loadGamAdmin();
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
ШАБЛОНЫ (Templates)
|
||
════════════════════════════════════════════════ */
|
||
async function loadTplAdmin() {
|
||
try {
|
||
const [courses, lessons] = await Promise.all([
|
||
LS.getCourseTemplates().catch(() => []),
|
||
LS.getLessonTemplates().catch(() => [])
|
||
]);
|
||
renderTplTable('tpl-course-body', courses, 'courses');
|
||
renderTplTable('tpl-lesson-body', lessons, 'lessons');
|
||
if (window.lucide) lucide.createIcons();
|
||
} catch(e) {
|
||
document.getElementById('tpl-course-body').innerHTML = `<tr><td colspan="7" class="error">Ошибка: ${esc(e.message)}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function renderTplTable(bodyId, items, type) {
|
||
const body = document.getElementById(bodyId);
|
||
if (!items || !items.length) {
|
||
body.innerHTML = '<tr><td colspan="7" class="empty">Нет шаблонов</td></tr>';
|
||
return;
|
||
}
|
||
body.innerHTML = items.map(t => `<tr>
|
||
<td>${t.id}</td>
|
||
<td><strong>${esc(t.name || t.title || '—')}</strong></td>
|
||
<td>${esc(t.subject || '—')}</td>
|
||
<td>${esc(t.category || '—')}</td>
|
||
<td>${esc(t.author_name || t.author || '—')}</td>
|
||
<td>
|
||
<label class="adm-toggle">
|
||
<input type="checkbox" ${t.is_public ? 'checked' : ''} onchange="tplTogglePublic('${type}', ${t.id}, this.checked)" />
|
||
<span class="track"></span><span class="thumb"></span>
|
||
</label>
|
||
</td>
|
||
<td>
|
||
<button class="btn-del-q" onclick="tplDelete('${type}', ${t.id})">Удалить</button>
|
||
</td>
|
||
</tr>`).join('');
|
||
}
|
||
|
||
async function tplTogglePublic(type, id, isPublic) {
|
||
try {
|
||
const endpoint = type === 'courses' ? '/api/templates/courses/' : '/api/templates/lessons/';
|
||
await LS.api(endpoint + id, { method: 'PUT', body: JSON.stringify({ is_public: isPublic ? 1 : 0 }) });
|
||
LS.toast(isPublic ? 'Шаблон опубликован' : 'Шаблон скрыт', 'success');
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function tplDelete(type, id) {
|
||
if (!confirm('Удалить шаблон #' + id + '?')) return;
|
||
try {
|
||
if (type === 'courses') await LS.deleteCourseTemplate(id);
|
||
else await LS.deleteLessonTemplate(id);
|
||
LS.toast('Шаблон удалён', 'success');
|
||
tplInited = false;
|
||
loadTplAdmin();
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
СИМУЛЯЦИИ
|
||
════════════════════════════════════════════════ */
|
||
|
||
// Full list of available (non-null id) sims mirrored from /lab
|
||
const ADMIN_SIMS = [
|
||
{ id: 'graph', cat: 'Математика', title: 'График функции' },
|
||
{ id: 'graphtransform', cat: 'Математика', title: 'Трансформации графиков' },
|
||
{ id: 'triangle', cat: 'Математика', title: 'Геометрия треугольника' },
|
||
{ id: 'quadratic', cat: 'Математика', title: 'Корни квадратного уравнения' },
|
||
{ id: 'stereo', cat: 'Математика', title: 'Стереометрия 3D' },
|
||
{ id: 'probability', cat: 'Математика', title: 'Теория вероятностей' },
|
||
{ id: 'trigcircle', cat: 'Математика', title: 'Тригонометрическая окружность' },
|
||
{ id: 'normaldist', cat: 'Математика', title: 'Нормальное распределение' },
|
||
{ id: 'projectile', cat: 'Физика', title: 'Бросок тела' },
|
||
{ id: 'pendulum', cat: 'Физика', title: 'Маятник' },
|
||
{ id: 'collision', cat: 'Физика', title: 'Столкновение шаров' },
|
||
{ id: 'magnetic', cat: 'Физика', title: 'Магнитное поле токов' },
|
||
{ id: 'circuit', cat: 'Физика', title: 'Электрические цепи' },
|
||
{ id: 'coulomb', cat: 'Физика', title: 'Закон Кулона' },
|
||
{ id: 'hydrostatics', cat: 'Физика', title: 'Гидростатика' },
|
||
{ id: 'dynamics', cat: 'Физика', title: 'Динамика' },
|
||
{ id: 'thinlens', cat: 'Физика', title: 'Тонкая линза' },
|
||
{ id: 'refraction', cat: 'Физика', title: 'Преломление света' },
|
||
{ id: 'mirrors', cat: 'Физика', title: 'Зеркала' },
|
||
{ id: 'isoprocess', cat: 'Физика', title: 'Изопроцессы' },
|
||
{ id: 'waves', cat: 'Физика', title: 'Волны и звук' },
|
||
{ id: 'molphys', cat: 'Химия', title: 'Молекулярная физика' },
|
||
{ id: 'chemistry', cat: 'Химия', title: 'Химические реакции' },
|
||
{ id: 'equilibrium', cat: 'Химия', title: 'Химическое равновесие' },
|
||
{ id: 'electrolysis', cat: 'Химия', title: 'Электролиз' },
|
||
{ id: 'bohratom', cat: 'Химия', title: 'Атом Бора' },
|
||
{ id: 'orbitals', cat: 'Химия', title: 'Молекулярные орбитали' },
|
||
{ id: 'titration', cat: 'Химия', title: 'pH и кривая титрования' },
|
||
{ id: 'chemsandbox', cat: 'Химия', title: 'Химическая песочница' },
|
||
{ id: 'crystal', cat: 'Химия', title: 'Кристаллическая решётка' },
|
||
{ id: 'celldivision', cat: 'Биология', title: 'Деление клетки' },
|
||
{ id: 'photosynthesis', cat: 'Биология', title: 'Фотосинтез и дыхание' },
|
||
{ id: 'angrybirds', cat: 'Игры', title: 'Angry Birds Physics' },
|
||
];
|
||
|
||
let _simsSettings = { module_disabled: false, disabled_ids: [] };
|
||
|
||
async function loadSimsAdmin() {
|
||
try {
|
||
const data = await LS.api('/api/settings/sims');
|
||
_simsSettings = data;
|
||
_renderSimsAdmin();
|
||
} catch(e) { LS.toast('Ошибка загрузки настроек: ' + e.message, 'error'); }
|
||
}
|
||
|
||
function _renderSimsAdmin() {
|
||
// master toggle
|
||
const masterChk = document.getElementById('sims-master-chk');
|
||
if (masterChk) masterChk.checked = !_simsSettings.module_disabled;
|
||
|
||
// per-sim cards
|
||
const grid = document.getElementById('sims-grid');
|
||
const dis = new Set(_simsSettings.disabled_ids || []);
|
||
// group by category
|
||
const byCat = {};
|
||
ADMIN_SIMS.forEach(s => { (byCat[s.cat] = byCat[s.cat] || []).push(s); });
|
||
|
||
let html = '';
|
||
Object.entries(byCat).forEach(([cat, sims]) => {
|
||
html += `<div style="grid-column:1/-1;font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3);margin-top:12px;margin-bottom:2px">${esc(cat)}</div>`;
|
||
sims.forEach(s => {
|
||
const enabled = !dis.has(s.id);
|
||
html += `<div class="perm-card${enabled ? ' enabled' : ''}" id="simcard-${s.id}">
|
||
<div class="perm-info">
|
||
<div class="perm-label">${esc(s.title)}</div>
|
||
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(s.id)}</div>
|
||
</div>
|
||
<label class="perm-toggle" title="${enabled ? 'Отключить' : 'Включить'}">
|
||
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="simToggleOne('${s.id}', this.checked)" />
|
||
<span class="perm-track"></span>
|
||
<span class="perm-thumb"></span>
|
||
</label>
|
||
</div>`;
|
||
});
|
||
});
|
||
grid.innerHTML = html;
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
async function simsMasterToggle(checked) {
|
||
// checked = module enabled; disabled = !checked
|
||
try {
|
||
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ module_disabled: !checked }) });
|
||
_simsSettings.module_disabled = !checked;
|
||
LS.toast(checked ? 'Модуль симуляций включён' : 'Модуль симуляций отключён', checked ? 'success' : 'warning');
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function simToggleOne(simId, enabled) {
|
||
const dis = new Set(_simsSettings.disabled_ids || []);
|
||
if (enabled) dis.delete(simId); else dis.add(simId);
|
||
const disabled_ids = [...dis];
|
||
try {
|
||
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ disabled_ids }) });
|
||
_simsSettings.disabled_ids = disabled_ids;
|
||
// update card style
|
||
const card = document.getElementById('simcard-' + simId);
|
||
if (card) card.classList.toggle('enabled', enabled);
|
||
LS.toast(enabled ? `«${simId}» включена` : `«${simId}» отключена`, enabled ? 'success' : 'warning');
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ─── Games features admin ─── */
|
||
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' },
|
||
];
|
||
|
||
async function loadGamesAdmin() {
|
||
const grid = document.getElementById('games-features-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');
|
||
}
|
||
}
|
||
|
||
/* ─── Free-student module features ─── */
|
||
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' },
|
||
];
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
/* ─── Submission log ─── */
|
||
const SL_STATUSES = { new:'На проверке', reviewed:'Проверено', accepted:'Принято', revision:'На доработке', resubmitted:'Повторно' };
|
||
|
||
async function loadSubmissionLog() {
|
||
const el = document.getElementById('sublog-list');
|
||
const countEl = document.getElementById('sublog-count');
|
||
const classId = document.getElementById('sublog-class-filter').value;
|
||
el.innerHTML = '<div class="spinner"></div>';
|
||
countEl.textContent = '';
|
||
try {
|
||
const url = classId ? `/api/submissions/log?class_id=${classId}` : '/api/submissions/log';
|
||
const rows = await LS.api(url);
|
||
|
||
// Populate class filter on first load
|
||
const sel = document.getElementById('sublog-class-filter');
|
||
if (sel.options.length <= 1 && rows.length) {
|
||
const classMap = new Map();
|
||
rows.forEach(r => { if (r.class_id && r.class_name) classMap.set(r.class_id, r.class_name); });
|
||
classMap.forEach((name, id) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = id; opt.textContent = name;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
countEl.textContent = rows.length ? `${rows.length} записей` : '';
|
||
|
||
if (!rows.length) {
|
||
el.innerHTML = `<div class="sl-empty">
|
||
<div class="sl-empty-icon"><i data-lucide="inbox" style="width:48px;height:48px"></i></div>
|
||
Удалённых работ нет
|
||
</div>`;
|
||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||
return;
|
||
}
|
||
|
||
const ROLE_LABELS = { admin: 'Админ', teacher: 'Учитель', student: 'Ученик' };
|
||
|
||
el.innerHTML = `<div class="sl-wrap"><table class="sl-table">
|
||
<thead><tr>
|
||
<th>Дата</th>
|
||
<th>Ученик</th>
|
||
<th>Файл</th>
|
||
<th>Задание</th>
|
||
<th>Класс</th>
|
||
<th>Статус</th>
|
||
<th>Оценка</th>
|
||
<th>Удалил</th>
|
||
</tr></thead>
|
||
<tbody>${rows.map(r => {
|
||
const dt = r.deleted_at ? new Date(r.deleted_at.includes('T') ? r.deleted_at : r.deleted_at.replace(' ','T')+'Z') : null;
|
||
const dateStr = dt ? dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'}) : '—';
|
||
const initials = (r.student_name || '?').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('');
|
||
const st = r.status || 'new';
|
||
const gradeVal = r.grade != null ? r.grade : null;
|
||
const gradeCls = gradeVal != null ? (gradeVal >= 80 ? 'sl-grade-hi' : gradeVal >= 50 ? 'sl-grade-mid' : 'sl-grade-lo') : 'sl-grade-none';
|
||
const roleCls = 'sl-role-' + (r.deleted_by_role || 'student');
|
||
return `<tr>
|
||
<td><span class="sl-date">${dateStr}</span></td>
|
||
<td><span class="sl-student"><span class="sl-student-avatar">${initials}</span>${esc(r.student_name || '—')}</span></td>
|
||
<td><span class="sl-file" title="${esc(r.original_name || '')}">${esc(r.original_name || '—')}</span></td>
|
||
<td><span class="sl-assignment">${esc(r.assignment_title || '—')}</span></td>
|
||
<td><span class="sl-class">${esc(r.class_name || '—')}</span></td>
|
||
<td><span class="sl-status sl-status-${st}">${SL_STATUSES[st] || st}</span></td>
|
||
<td><span class="sl-grade ${gradeCls}">${gradeVal != null ? gradeVal : '—'}</span></td>
|
||
<td><span class="sl-deleted-by">${esc(r.deleted_by_name || '—')} <span class="sl-role-badge ${roleCls}">${ROLE_LABELS[r.deleted_by_role] || r.deleted_by_role || '?'}</span></span></td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table></div>`;
|
||
document.getElementById('btn-clear-sublog').style.display = '';
|
||
} catch (e) {
|
||
el.innerHTML = `<div class="sl-empty" style="color:#c0306a">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function clearSubmissionLog() {
|
||
if (!await LS.confirm('Очистить весь журнал удалённых работ? Это действие необратимо.', { title: 'Очистка журнала', confirmText: 'Очистить', danger: true })) return;
|
||
try {
|
||
await LS.api('/api/submissions/log', { method: 'DELETE' });
|
||
document.getElementById('btn-clear-sublog').style.display = 'none';
|
||
document.getElementById('sublog-count').textContent = '';
|
||
document.getElementById('sublog-list').innerHTML = `<div class="sl-empty">
|
||
<div class="sl-empty-icon"><i data-lucide="inbox" style="width:48px;height:48px"></i></div>
|
||
Журнал очищен
|
||
</div>`;
|
||
if (window.lucide) lucide.createIcons({ nodes: [document.getElementById('sublog-list')] });
|
||
LS.toast('Журнал очищен', 'success');
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ═══ TOPICS ═══════════════════════════════════════════════════════ */
|
||
let _topicsSubjects = [];
|
||
async function loadTopicSubjects() {
|
||
if (_topicsSubjects.length) return;
|
||
try {
|
||
_topicsSubjects = await LS.getSubjects();
|
||
const sel = document.getElementById('topics-subj-filter');
|
||
sel.innerHTML = _topicsSubjects.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('');
|
||
} catch {}
|
||
}
|
||
async function loadTopics() {
|
||
await loadTopicSubjects();
|
||
const subjId = document.getElementById('topics-subj-filter').value;
|
||
const el = document.getElementById('topics-list');
|
||
el.innerHTML = LS.skeleton(4, 'row');
|
||
try {
|
||
const rows = await LS.api(`/api/admin/topics?subject_id=${subjId}`);
|
||
document.getElementById('topics-count').textContent = rows.length + ' тем';
|
||
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:#8898AA">Тем нет</div>'; return; }
|
||
el.innerHTML = '<div style="display:flex;flex-direction:column;gap:6px">' + rows.map(t => `
|
||
<div class="adm-panel" style="padding:12px 18px;margin:0;display:flex;align-items:center;gap:14px">
|
||
<span style="font-size:0.75rem;color:var(--text-3);font-weight:700;min-width:28px">#${t.order_index}</span>
|
||
<span style="flex:1;font-weight:600">${esc(t.name)}</span>
|
||
<span style="font-size:0.78rem;color:var(--text-3)">${t.question_count} вопр.</span>
|
||
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-2);padding:5px 12px" onclick="renameTopic(${t.id},'${esc(t.name).replace(/'/g,"\\'")}')">
|
||
<i data-lucide="pencil" style="width:12px;height:12px;vertical-align:-1px"></i>
|
||
</button>
|
||
<button class="adm-btn adm-btn-small" style="background:rgba(241,91,181,0.1);color:var(--pink);padding:5px 12px" onclick="deleteTopic(${t.id},'${esc(t.name).replace(/'/g,"\\'")}',${t.question_count})">
|
||
<i data-lucide="trash-2" style="width:12px;height:12px;vertical-align:-1px"></i>
|
||
</button>
|
||
</div>`).join('') + '</div>';
|
||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
|
||
}
|
||
function showAddTopic() { document.getElementById('topics-add-row').style.display = ''; document.getElementById('topics-new-name').focus(); }
|
||
async function createTopic() {
|
||
const name = document.getElementById('topics-new-name').value.trim();
|
||
if (!name) return;
|
||
const subjId = document.getElementById('topics-subj-filter').value;
|
||
try {
|
||
await LS.api('/api/admin/topics', { method:'POST', body: JSON.stringify({ subject_id: subjId, name }) });
|
||
document.getElementById('topics-new-name').value = '';
|
||
document.getElementById('topics-add-row').style.display = 'none';
|
||
LS.toast('Тема создана', 'success');
|
||
loadTopics();
|
||
} catch (e) { LS.toast(e.message, 'error'); }
|
||
}
|
||
async function renameTopic(id, oldName) {
|
||
const name = prompt('Новое название темы:', oldName);
|
||
if (!name || name === oldName) return;
|
||
try {
|
||
await LS.api(`/api/admin/topics/${id}`, { method:'PATCH', body: JSON.stringify({ name }) });
|
||
LS.toast('Тема переименована', 'success');
|
||
loadTopics();
|
||
} catch (e) { LS.toast(e.message, 'error'); }
|
||
}
|
||
async function deleteTopic(id, name, qcount) {
|
||
if (qcount > 0) { LS.toast(`Нельзя удалить тему с ${qcount} вопросами`, 'warn'); return; }
|
||
if (!await LS.confirm(`Удалить тему "${name}"?`, { danger: true })) return;
|
||
try {
|
||
await LS.api(`/api/admin/topics/${id}`, { method:'DELETE' });
|
||
LS.toast('Тема удалена', 'success');
|
||
loadTopics();
|
||
} catch (e) { LS.toast(e.message, 'error'); }
|
||
}
|
||
|
||
/* ═══ BROADCAST ═════════════════════════════════════════════════════ */
|
||
async function sendBroadcast() {
|
||
const message = document.getElementById('bc-message').value.trim();
|
||
if (!message) { LS.toast('Введите сообщение', 'warn'); return; }
|
||
const role = document.getElementById('bc-role').value;
|
||
const link = document.getElementById('bc-link').value.trim() || null;
|
||
try {
|
||
const r = await LS.api('/api/admin/broadcast', { method:'POST', body: JSON.stringify({ message, role, link }) });
|
||
document.getElementById('bc-result').textContent = `Отправлено ${r.sent} пользователям`;
|
||
document.getElementById('bc-message').value = '';
|
||
LS.toast(`Уведомление отправлено ${r.sent} пользователям`, 'success');
|
||
} catch (e) { LS.toast(e.message, 'error'); }
|
||
}
|
||
|
||
/* ═══ AUDIT LOG ════════════════════════════════════════════════════ */
|
||
async function loadAuditLog() {
|
||
const el = document.getElementById('audit-list');
|
||
el.innerHTML = LS.skeleton(5, 'row');
|
||
try {
|
||
const rows = await LS.api('/api/admin/audit-log?limit=200');
|
||
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:#8898AA">Журнал пуст</div>'; return; }
|
||
const ACTION_LABELS = {
|
||
'user.role_change': 'Смена роли', 'user.edit': 'Редактирование', 'user.ban': 'Блокировка',
|
||
'user.unban': 'Разблокировка', 'user.delete': 'Удаление', 'user.clear_sessions': 'Очистка истории',
|
||
'features.update': 'Фичи обновлены', 'topic.create': 'Создание темы', 'topic.update': 'Редакт. темы',
|
||
'topic.delete': 'Удаление темы', 'broadcast': 'Рассылка',
|
||
};
|
||
const ACTION_COLORS = {
|
||
'user.delete': 'var(--pink)', 'user.ban': 'var(--pink)', 'user.clear_sessions': 'var(--amber)',
|
||
};
|
||
el.innerHTML = `<div class="sl-wrap"><table class="sl-table">
|
||
<thead><tr><th>Дата</th><th>Админ</th><th>Действие</th><th>Цель</th><th>Детали</th><th>IP</th></tr></thead>
|
||
<tbody>${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'});
|
||
const acol = ACTION_COLORS[r.action] || 'var(--violet)';
|
||
return `<tr>
|
||
<td><span class="sl-date">${ds}</span></td>
|
||
<td>${esc(r.admin_name || '—')}</td>
|
||
<td><span style="color:${acol};font-weight:700;font-size:0.82rem">${ACTION_LABELS[r.action] || r.action}</span></td>
|
||
<td style="font-size:0.82rem;color:var(--text-3)">${esc(r.target || '')}</td>
|
||
<td style="font-size:0.82rem;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.detail || '')}">${esc(r.detail || '')}</td>
|
||
<td style="font-size:0.78rem;color:var(--text-3);font-family:monospace">${esc(r.ip || '')}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody></table></div>`;
|
||
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
|
||
}
|
||
async function clearAuditLog() {
|
||
if (!await LS.confirm('Очистить весь аудит-лог?', { danger: true })) return;
|
||
try {
|
||
await LS.api('/api/admin/audit-log', { method:'DELETE' });
|
||
document.getElementById('audit-list').innerHTML = '<div style="padding:32px;text-align:center;color:#8898AA">Журнал очищен</div>';
|
||
LS.toast('Журнал очищен', 'success');
|
||
} catch (e) { LS.toast(e.message, 'error'); }
|
||
}
|
||
|
||
/* ═══ ERROR LOG ════════════════════════════════════════════════════ */
|
||
async function loadErrorLog() {
|
||
const el = document.getElementById('errors-list');
|
||
el.innerHTML = LS.skeleton(3, 'row');
|
||
try {
|
||
const rows = await LS.api('/api/admin/error-log?limit=200');
|
||
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:#8898AA;font-size:0.88rem">Ошибок нет</div>'; return; }
|
||
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)">
|
||
<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>
|
||
<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>
|
||
<div style="font-size:0.88rem;font-weight:600;color:var(--text);margin-bottom:4px">${esc(r.message)}</div>
|
||
${r.stack ? `<details><summary style="font-size:0.75rem;color:var(--text-3);cursor:pointer">Stack trace</summary><pre style="font-size:0.72rem;color:var(--text-3);white-space:pre-wrap;max-height:200px;overflow:auto;margin-top:6px;padding:8px;background:rgba(0,0,0,0.02);border-radius:8px">${esc(r.stack)}</pre></details>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
|
||
}
|
||
async function clearErrorLog() {
|
||
if (!await LS.confirm('Очистить журнал ошибок?', { danger: true })) return;
|
||
try {
|
||
await LS.api('/api/admin/error-log', { method:'DELETE' });
|
||
document.getElementById('errors-list').innerHTML = '<div style="padding:32px;text-align:center;color:#8898AA">Журнал очищен</div>';
|
||
LS.toast('Журнал очищен', 'success');
|
||
} catch (e) { LS.toast(e.message, 'error'); }
|
||
}
|
||
|
||
/* ═══ SYSTEM HEALTH ════════════════════════════════════════════════ */
|
||
async function loadHealth() {
|
||
const el = document.getElementById('health-content');
|
||
el.innerHTML = LS.skeleton(3, 'row');
|
||
try {
|
||
const h = await LS.api('/api/admin/health');
|
||
const fmtBytes = b => b > 1e9 ? (b/1e9).toFixed(1)+' GB' : b > 1e6 ? (b/1e6).toFixed(1)+' MB' : (b/1e3).toFixed(0)+' KB';
|
||
const fmtUp = s => { const d=Math.floor(s/86400), hr=Math.floor(s%86400/3600), m=Math.floor(s%3600/60); return d>0?`${d}d ${hr}h`:hr>0?`${hr}h ${m}m`:`${m}m`; };
|
||
el.innerHTML = `
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;margin-bottom:24px">
|
||
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
|
||
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif;color:var(--green)">${fmtUp(h.uptime)}</div>
|
||
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Uptime</div>
|
||
</div>
|
||
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
|
||
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif;color:var(--violet)">${fmtBytes(h.db.sizeBytes)}</div>
|
||
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">База данных</div>
|
||
</div>
|
||
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
|
||
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif">${fmtBytes(h.uploads.sizeBytes)}</div>
|
||
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Файлы</div>
|
||
</div>
|
||
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
|
||
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif;color:${h.recentErrors>0?'var(--pink)':'var(--green)'}">${h.recentErrors}</div>
|
||
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Ошибок за 24ч</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
|
||
<div class="adm-panel" style="margin:0">
|
||
<div class="adm-panel-title">Платформа</div>
|
||
<table style="width:100%;font-size:0.88rem">
|
||
<tr><td style="color:var(--text-3);padding:4px 0">Node.js</td><td style="font-weight:600">${h.node}</td></tr>
|
||
<tr><td style="color:var(--text-3);padding:4px 0">OS</td><td style="font-weight:600">${h.platform}</td></tr>
|
||
<tr><td style="color:var(--text-3);padding:4px 0">CPU ядра</td><td style="font-weight:600">${h.cpus}</td></tr>
|
||
<tr><td style="color:var(--text-3);padding:4px 0">RAM использовано</td><td style="font-weight:600">${fmtBytes(h.memory.rss)}</td></tr>
|
||
<tr><td style="color:var(--text-3);padding:4px 0">RAM heap</td><td style="font-weight:600">${fmtBytes(h.memory.heapUsed)}</td></tr>
|
||
<tr><td style="color:var(--text-3);padding:4px 0">RAM свободно</td><td style="font-weight:600">${fmtBytes(h.freeMem)} / ${fmtBytes(h.totalMem)}</td></tr>
|
||
</table>
|
||
</div>
|
||
<div class="adm-panel" style="margin:0">
|
||
<div class="adm-panel-title">Данные</div>
|
||
<table style="width:100%;font-size:0.88rem">
|
||
<tr><td style="color:var(--text-3);padding:4px 0">Пользователей</td><td style="font-weight:600">${h.db.totalUsers}</td></tr>
|
||
<tr><td style="color:var(--text-3);padding:4px 0">Всего сессий</td><td style="font-weight:600">${h.db.totalSessions}</td></tr>
|
||
<tr><td style="color:var(--text-3);padding:4px 0">Сессий сегодня</td><td style="font-weight:600;color:var(--violet)">${h.db.todaySessions}</td></tr>
|
||
<tr><td style="color:var(--text-3);padding:4px 0">Вопросов в базе</td><td style="font-weight:600">${h.db.totalQuestions}</td></tr>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
ОНЛАЙН-УРОКИ (classroom admin)
|
||
════════════════════════════════════════════════ */
|
||
let _crHistPage = 1, _crHistTotal = 0, _crHistPages = 0, _crHistSearch = '';
|
||
let _crOpenDetailId = null, _crHistDebTimer = null;
|
||
|
||
async function loadCrModuleState() {
|
||
try {
|
||
const features = await LS.api('/api/admin/features');
|
||
const chk = document.getElementById('cr-master-chk');
|
||
if (chk) chk.checked = features.classroom !== false;
|
||
} catch(e) { /* silent */ }
|
||
}
|
||
|
||
async function crMasterToggle(enabled) {
|
||
try {
|
||
await LS.api('/api/admin/features', { method: 'PATCH', body: JSON.stringify({ classroom: enabled }) });
|
||
LS.toast(enabled ? 'Модуль онлайн-уроков включён' : 'Модуль онлайн-уроков отключён', enabled ? 'success' : 'warning', 3000);
|
||
} catch(e) {
|
||
LS.toast('Ошибка: ' + e.message, 'error');
|
||
// revert checkbox
|
||
const chk = document.getElementById('cr-master-chk');
|
||
if (chk) chk.checked = !enabled;
|
||
}
|
||
}
|
||
|
||
function fmtDuration(sec) {
|
||
if (!sec || sec < 0) return '—';
|
||
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
||
if (h) return `${h}ч ${m}м`;
|
||
if (m) return `${m} мин ${s} сек`;
|
||
return `${s} сек`;
|
||
}
|
||
function fmtLiveDuration(createdAt) {
|
||
const sec = Math.round((Date.now() - new Date(createdAt).getTime()) / 1000);
|
||
return fmtDuration(sec);
|
||
}
|
||
|
||
async function loadCrActiveSessions() {
|
||
const el = document.getElementById('cr-live-list');
|
||
try {
|
||
const { sessions } = await LS.api('/api/classroom/admin/active');
|
||
if (!sessions.length) {
|
||
el.innerHTML = '<div class="empty">Нет активных уроков</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = sessions.map(s => {
|
||
const dur = fmtLiveDuration(s.created_at);
|
||
const title = s.title || `Урок #${s.id}`;
|
||
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
|
||
return `<div class="cr-live-card">
|
||
<div class="cr-live-pulse"></div>
|
||
<div class="cr-live-info">
|
||
<div class="cr-live-title">${esc(title)}</div>
|
||
<div class="cr-live-meta">${esc(s.teacher_name)} · ${cls}</div>
|
||
</div>
|
||
<div class="cr-live-badges">
|
||
<span class="cr-badge cr-badge-online">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||
${s.online_count}
|
||
</span>
|
||
<span class="cr-badge cr-badge-msgs">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||
${s.message_count}
|
||
</span>
|
||
<span class="cr-badge cr-badge-dur">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||
${dur}
|
||
</span>
|
||
</div>
|
||
<div class="cr-live-actions">
|
||
<button class="btn-cr-end" onclick="adminEndSession(${s.id})">Завершить</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch(e) {
|
||
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
async function adminEndSession(id) {
|
||
if (!await LS.confirm(`Завершить урок #${id}? Все участники будут отключены.`, { title: 'Завершить урок', confirmText: 'Завершить' })) return;
|
||
try {
|
||
await LS.api(`/api/classroom/${id}`, { method: 'DELETE' });
|
||
LS.toast('Урок завершён', 'success', 2500);
|
||
loadCrActiveSessions();
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
function crHistDebounce() {
|
||
clearTimeout(_crHistDebTimer);
|
||
_crHistDebTimer = setTimeout(() => { _crHistPage = 1; loadCrHistory(); }, 350);
|
||
}
|
||
|
||
async function loadCrHistory(page) {
|
||
if (page) _crHistPage = page;
|
||
_crHistSearch = (document.getElementById('cr-hist-q')?.value || '').trim();
|
||
const el = document.getElementById('cr-hist-list');
|
||
el.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const params = new URLSearchParams({ page: _crHistPage, limit: 20 });
|
||
if (_crHistSearch) params.set('search', _crHistSearch);
|
||
const { sessions, total, pages } = await LS.api('/api/classroom/admin/sessions?' + params);
|
||
_crHistTotal = total; _crHistPages = pages;
|
||
document.getElementById('cr-hist-count').textContent = `${total} уроков`;
|
||
if (!sessions.length) {
|
||
el.innerHTML = '<div class="empty">Нет завершённых уроков</div>';
|
||
renderCrPagination();
|
||
return;
|
||
}
|
||
el.innerHTML = sessions.map(s => {
|
||
const title = s.title || `Урок #${s.id}`;
|
||
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
|
||
const dur = fmtDuration(s.ended_at ? Math.round((new Date(s.ended_at)-new Date(s.created_at))/1000) : null);
|
||
return `<div>
|
||
<div class="cr-hist-row${_crOpenDetailId===s.id?' open':''}" onclick="toggleCrDetail(${s.id},this)">
|
||
<div class="cr-hist-icon">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:18px;height:18px;color:var(--violet)"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||
</div>
|
||
<div class="cr-hist-main">
|
||
<div class="cr-hist-title">${esc(title)}</div>
|
||
<div class="cr-hist-meta">${esc(s.teacher_name)} · ${cls} · ${fmtDate(s.ended_at || s.created_at)}</div>
|
||
</div>
|
||
<div class="cr-hist-chips">
|
||
<span class="cr-badge cr-badge-online">${s.participant_count} уч.</span>
|
||
<span class="cr-badge cr-badge-msgs">${s.message_count} сообщ.</span>
|
||
<span class="cr-badge cr-badge-dur">${dur}</span>
|
||
</div>
|
||
<svg class="cr-hist-chevron ic" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
|
||
</div>
|
||
<div class="cr-detail-drawer${_crOpenDetailId===s.id?' open':''}" id="cr-detail-${s.id}">
|
||
<div class="cr-detail-inner" id="cr-detail-inner-${s.id}">
|
||
<div class="spinner"></div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
if (_crOpenDetailId) {
|
||
const dr = document.getElementById(`cr-detail-${_crOpenDetailId}`);
|
||
if (dr) loadCrSessionDetail(_crOpenDetailId);
|
||
}
|
||
renderCrPagination();
|
||
} catch(e) {
|
||
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
function renderCrPagination() {
|
||
const el = document.getElementById('cr-hist-pagination');
|
||
if (_crHistPages <= 1) { el.innerHTML = ''; return; }
|
||
const p = _crHistPage, total = _crHistPages;
|
||
let html = '<div class="cr-pagination">';
|
||
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p-1})" ${p<=1?'disabled':''}>
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="15 18 9 12 15 6"/></svg>
|
||
</button>`;
|
||
const range = [];
|
||
for (let i=1;i<=total;i++) {
|
||
if (i===1||i===total||Math.abs(i-p)<=1) range.push(i);
|
||
else if (range[range.length-1]!=='…') range.push('…');
|
||
}
|
||
range.forEach(r => {
|
||
if (r==='…') html += `<span class="cr-page-info">…</span>`;
|
||
else html += `<button class="cr-page-btn${r===p?' active':''}" onclick="loadCrHistory(${r})">${r}</button>`;
|
||
});
|
||
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p+1})" ${p>=total?'disabled':''}>
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="9 18 15 12 9 6"/></svg>
|
||
</button></div>`;
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
async function toggleCrDetail(id, rowEl) {
|
||
const wasOpen = _crOpenDetailId === id;
|
||
// close all
|
||
document.querySelectorAll('.cr-hist-row.open').forEach(r => r.classList.remove('open'));
|
||
document.querySelectorAll('.cr-detail-drawer.open').forEach(d => { d.classList.remove('open'); d.style.maxHeight=''; });
|
||
_crOpenDetailId = null;
|
||
if (wasOpen) return;
|
||
// open this one
|
||
rowEl.classList.add('open');
|
||
const dr = document.getElementById(`cr-detail-${id}`);
|
||
if (dr) { dr.classList.add('open'); }
|
||
_crOpenDetailId = id;
|
||
await loadCrSessionDetail(id);
|
||
}
|
||
|
||
async function loadCrSessionDetail(id) {
|
||
const inner = document.getElementById(`cr-detail-inner-${id}`);
|
||
if (!inner) return;
|
||
inner.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const { session, stats, attendance, pages } = await LS.api(`/api/classroom/${id}/summary`);
|
||
const dur = fmtDuration(stats.duration_sec);
|
||
inner.innerHTML = `
|
||
<div class="cr-detail-grid">
|
||
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.participant_count}</div><div class="cr-detail-label">Участников</div></div>
|
||
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.message_count}</div><div class="cr-detail-label">Сообщений</div></div>
|
||
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.page_count}</div><div class="cr-detail-label">Страниц</div></div>
|
||
<div class="cr-detail-stat"><div class="cr-detail-val" style="font-size:1rem">${dur}</div><div class="cr-detail-label">Длительность</div></div>
|
||
</div>
|
||
${attendance.length ? `
|
||
<div class="section-title" style="font-size:0.72rem;margin-bottom:8px">Посещаемость</div>
|
||
<div class="cr-attend-list">
|
||
${attendance.map(a => `
|
||
<div class="cr-attend-row">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px;flex-shrink:0;color:var(--violet)"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||
<span class="cr-attend-name">${esc(a.user_name)}</span>
|
||
<span class="cr-attend-time">${a.joined_at ? new Date(a.joined_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '—'}</span>
|
||
<span class="cr-attend-dur">${a.duration_sec ? fmtDuration(a.duration_sec) : (a.left_at ? '—' : '<span style="color:var(--green)">онлайн</span>')}</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
` : ''}
|
||
${pages.length > 1 ? `
|
||
<div class="section-title" style="font-size:0.72rem;margin:16px 0 8px">Страницы доски</div>
|
||
<div class="cr-pages-list">
|
||
${pages.map(p => `
|
||
<div class="cr-page-chip">
|
||
<span class="cr-page-num">Стр. ${p.page_num}</span>
|
||
<span class="cr-page-cnt">${p.stroke_count} штр.</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
` : ''}
|
||
<div class="cr-detail-actions">
|
||
<button class="btn-cr-export" onclick="adminExportChat(${id})">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||
Экспорт чата
|
||
</button>
|
||
<button class="btn-cr-del" onclick="adminDeleteSession(${id})">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
|
||
Удалить запись
|
||
</button>
|
||
</div>`;
|
||
} catch(e) {
|
||
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function adminExportChat(id) {
|
||
window.open(`/api/classroom/${id}/chat/export`, '_blank');
|
||
}
|
||
|
||
async function adminDeleteSession(id) {
|
||
if (!await LS.confirm('Удалить всю запись об этом уроке? Данные нельзя восстановить.', { title: 'Удалить урок', confirmText: 'Удалить', dangerous: true })) return;
|
||
try {
|
||
await LS.api(`/api/classroom/${id}/history`, { method: 'DELETE' });
|
||
LS.toast('Урок удалён', 'success', 2500);
|
||
_crOpenDetailId = null;
|
||
loadCrHistory();
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ─── wire tab loading ─── */
|
||
const _origSwitchTab = window.switchTab;
|
||
window.switchTab = function(btn) {
|
||
_origSwitchTab(btn);
|
||
const tab = btn.dataset.tab;
|
||
if (tab === 'topics') loadTopics();
|
||
else if (tab === 'audit') loadAuditLog();
|
||
else if (tab === 'errors') loadErrorLog();
|
||
else if (tab === 'health') loadHealth();
|
||
else if (tab === 'classroom') { loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); }
|
||
};
|
||
|
||
/* ─── init ─── */
|
||
loadStats();
|
||
if (window.lucide) lucide.createIcons();
|
||
</script>
|
||
</div>
|
||
</div>
|
||
<script src="/js/search.js"></script>
|
||
<script src="/js/mobile.js"></script>
|
||
</body>
|
||
</html>
|