Files
Learn_System/frontend/my-students.html
T
Maxim Dolgolyov 82d323547f feat(prep): тумблер «готовится к ЦТ» на странице персональных учеников
my-students.html: колонка «ЦТ» с тумблером у каждого ученика (teacher_students вне
классов). Бэкенд уже поддерживал (canManageStudent включает teacher_students);
статус грузится per-student через LS.prepStudentTracks, переключение —
prepSetStudent/prepUnsetStudent. togglePrep на window (скрипт-модуль). Иконки — SVG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:06:41 +03:00

368 lines
18 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 rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<style>
.sb-content { padding: 0; overflow-y: auto; }
.ms-wrap { max-width: 1000px; margin: 0 auto; padding: 32px 24px 80px; width: 100%; }
.ms-header { display:flex; align-items:center; gap:14px; margin-bottom:26px; }
.ms-icon {
width:52px; height:52px; border-radius:14px; flex-shrink:0;
background:linear-gradient(135deg, rgba(155,93,229,.25), rgba(6,214,224,.18));
border:1.5px solid rgba(255,255,255,.1);
display:flex; align-items:center; justify-content:center;
}
.ms-icon svg { width:26px; height:26px; stroke:#9B5DE5; stroke-width:1.8; fill:none; }
.ms-title { font-family:'Unbounded',sans-serif; font-size:1.3rem; font-weight:800; letter-spacing:-.02em; }
.ms-sub { font-size:.82rem; color:var(--text-2); margin-top:2px; }
/* Add form */
.ms-add {
background:var(--surface); border:1.5px solid var(--border); border-radius:14px;
padding:18px 20px; margin-bottom:24px;
}
.ms-add-title {
font-family:'Unbounded',sans-serif; font-size:.95rem; font-weight:800;
margin-bottom:12px;
}
.ms-add-row { display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; }
.ms-add-field { flex:1; min-width:200px; }
.ms-add-field label {
display:block; font-size:.72rem; font-weight:700; color:var(--text-2);
text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px;
}
.ms-input {
width:100%; padding:10px 14px; border:1.5px solid var(--border-h);
border-radius:10px; background:var(--surface); color:var(--text);
font-family:'Manrope',sans-serif; font-size:.9rem;
}
.ms-input:focus { outline:none; border-color:var(--violet); }
.ms-add-btn {
padding:10px 22px; border-radius:10px;
background:var(--violet); border:none; color:#fff;
font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700;
cursor:pointer; transition:filter .15s;
display:inline-flex; align-items:center; gap:6px;
}
.ms-add-btn:hover { filter:brightness(1.08); }
.ms-add-btn:disabled { opacity:.55; cursor:not-allowed; }
.ms-add-btn svg { width:15px; height:15px; }
.ms-add-msg {
margin-top:10px; padding:8px 12px; border-radius:8px;
font-size:.84rem; display:none;
}
.ms-add-msg.error { display:block; background:rgba(241,91,68,.1); border:1px solid rgba(241,91,68,.3); color:#F94144; }
.ms-add-msg.success { display:block; background:rgba(6,214,160,.1); border:1px solid rgba(6,214,160,.3); color:#06D6A0; }
/* Students list */
.ms-list {
background:var(--surface); border:1.5px solid var(--border); border-radius:14px;
overflow:hidden;
}
.ms-row {
display:grid; grid-template-columns: 36px 1.5fr 2fr 1fr auto auto auto;
gap:14px; padding:14px 20px; align-items:center;
border-bottom:1px solid var(--border);
transition:background .12s;
}
.prep-toggle { display:inline-flex; align-items:center; gap:5px; padding:4px 9px; border-radius:999px;
border:1px solid var(--border); background:var(--surface); cursor:pointer; font-size:.74rem;
font-weight:600; color:var(--text-3); transition:all .15s; white-space:nowrap; }
.prep-toggle svg { width:13px; height:13px; }
.prep-toggle:hover { border-color:var(--violet); color:var(--violet); }
.prep-toggle.on { background:rgba(155,93,229,.12); border-color:var(--violet); color:var(--violet); }
.ms-row:last-child { border-bottom:none; }
.ms-row:hover { background:rgba(155,93,229,.04); }
.ms-row.head {
background:rgba(155,93,229,.06);
font-family:'Unbounded',sans-serif; font-size:.72rem; font-weight:800;
color:var(--text-2); text-transform:uppercase; letter-spacing:.05em;
}
.ms-row.head:hover { background:rgba(155,93,229,.06); }
.ms-avatar {
width:36px; height:36px; border-radius:50%;
background:linear-gradient(135deg, var(--violet), #06D6E0);
color:#fff; font-weight:800; font-size:.95rem;
display:flex; align-items:center; justify-content:center;
}
.ms-name { font-weight:700; font-size:.95rem; }
.ms-email { font-size:.82rem; color:var(--text-2); }
.ms-meta { font-size:.78rem; color:var(--text-3); }
.ms-btn {
padding:7px 12px; border-radius:9px;
border:1.5px solid var(--border-h); background:transparent; color:var(--text);
font-family:'Manrope',sans-serif; font-size:.82rem; font-weight:700;
cursor:pointer; transition:all .15s;
display:inline-flex; align-items:center; gap:5px; text-decoration:none;
}
.ms-btn:hover { border-color:var(--violet); color:var(--violet); }
.ms-btn.primary { background:var(--violet); border-color:var(--violet); color:#fff; }
.ms-btn.primary:hover { background:#7e3eca; color:#fff; }
.ms-btn.danger { color:var(--text-3); }
.ms-btn.danger:hover { color:#F94144; border-color:#F94144; }
.ms-btn svg { width:13px; height:13px; }
.ms-empty {
padding:60px 20px; text-align:center; color:var(--text-3);
}
.ms-empty svg { width:48px; height:48px; opacity:.5; margin-bottom:14px; stroke:var(--text-3); }
.ms-empty-title { font-family:'Unbounded',sans-serif; font-weight:800; color:var(--text); margin-bottom:6px; }
@media (max-width: 700px) {
.ms-row { grid-template-columns: 36px 1fr auto; gap:10px; }
.ms-row > :nth-child(3), .ms-row > :nth-child(4), .ms-row > :nth-child(5), .ms-row > :nth-child(6) { display:none; }
.ms-row.head { display:none; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="sb-content">
<div class="ms-wrap">
<header class="ms-header">
<div class="ms-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<line x1="19" y1="8" x2="19" y2="14"/>
<line x1="22" y1="11" x2="16" y2="11"/>
</svg>
</div>
<div>
<div class="ms-title">Мои ученики</div>
<div class="ms-sub">Личный список — для назначения заданий ученикам без класса</div>
</div>
</header>
<div class="ms-add">
<div class="ms-add-title">Добавить ученика</div>
<form class="ms-add-row" onsubmit="event.preventDefault(); addStudent()">
<div class="ms-add-field">
<label>Email ученика</label>
<input type="email" class="ms-input" id="ms-email" placeholder="student@example.com" required />
</div>
<div class="ms-add-field" style="flex:1.2">
<label>Заметка (опционально)</label>
<input type="text" class="ms-input" id="ms-note" placeholder="например: репетиторство по математике" maxlength="200" />
</div>
<div>
<button type="submit" class="ms-add-btn" id="ms-add-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Добавить
</button>
</div>
</form>
<div class="ms-add-msg" id="ms-add-msg"></div>
</div>
<div id="ms-list-container">
<div class="ms-empty">Загрузка…</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
<script>
(async function () {
const { user, isTeacher } = LS.initPage();
if (!user || !isTeacher) {
location.href = '/dashboard'; return;
}
LS.showBoardIfAllowed();
LS.hideDisabledFeatures();
function esc(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
function initials(name) {
return (name || '').trim().split(/\s+/).slice(0, 2).map(w => w[0] || '').join('').toUpperCase() || '?';
}
function fmtDate(s) {
if (!s) return '—';
try { return new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z').toLocaleDateString('ru-RU'); }
catch { return s; }
}
let students = [];
/* Флаг «готовится к ЦТ» (трек ct-math открывает карточки + курс + пробники). */
const PREP_TRACK = 'ct-math';
let _prep = {}; // { studentId: true|false }
function prepCellHtml(id) {
const on = _prep[id] === true;
return `<button class="prep-toggle${on ? ' on' : ''}" onclick="togglePrep(${id})" title="${on ? 'Готовится к ЦТ — нажмите, чтобы снять' : 'Отметить «готовится к ЦТ»'}">
${on
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>ЦТ'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/></svg>нет'}</button>`;
}
async function loadPrepAll() {
if (!students.length) return;
const results = await Promise.all(students.map(s =>
LS.prepStudentTracks(s.id)
.then(r => ({ id: s.id, on: (r.tracks || []).includes(PREP_TRACK) }))
.catch(() => ({ id: s.id, on: false }))));
_prep = {};
results.forEach(r => { _prep[r.id] = r.on; });
render();
}
window.togglePrep = async function (id) {
const on = _prep[id] === true;
try {
if (on) await LS.prepUnsetStudent(id, PREP_TRACK);
else await LS.prepSetStudent(id, PREP_TRACK);
_prep[id] = !on;
render();
if (LS.toast) LS.toast(on ? 'Снято' : 'Готовится к ЦТ', 'success');
} catch (e) { if (LS.toast) LS.toast(e.message || 'Ошибка', 'error'); else alert(e.message || 'Ошибка'); }
};
async function loadStudents() {
try {
const r = await LS.api('/api/teacher-students');
students = r.students || [];
render();
loadPrepAll();
} catch (e) {
document.getElementById('ms-list-container').innerHTML = `<div class="ms-empty">Ошибка: ${esc(e.message)}</div>`;
}
}
function render() {
const el = document.getElementById('ms-list-container');
if (!students.length) {
el.innerHTML = `
<div class="ms-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
<div class="ms-empty-title">Список пуст</div>
<div>Добавьте ученика по email — после этого можно назначать ему задания и чтение учебника, даже если он не в вашем классе.</div>
</div>`;
return;
}
el.innerHTML = `
<div class="ms-list">
<div class="ms-row head">
<div></div>
<div>Ученик</div>
<div>Email</div>
<div>Заданий</div>
<div>Добавлен</div>
<div title="Готовится к ЦТ — открывает карточки, курс и пробники ЦТ">ЦТ</div>
<div></div>
</div>
${students.map(s => `
<div class="ms-row">
<div class="ms-avatar">${esc(initials(s.name))}</div>
<div>
<div class="ms-name">${esc(s.name)}</div>
${s.note ? `<div class="ms-meta">${esc(s.note)}</div>` : ''}
</div>
<div class="ms-email">${esc(s.email)}</div>
<div class="ms-meta"><b style="color:var(--text)">${s.assignment_count || 0}</b></div>
<div class="ms-meta">${fmtDate(s.added_at)}</div>
<div>${prepCellHtml(s.id)}</div>
<div style="display:flex;gap:6px">
<button class="ms-btn" onclick="showProfile(${s.id}, '${esc(s.name).replace(/'/g, "\\'")}')" title="Профиль для Квантика (слабые темы)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>
</button>
<a class="ms-btn primary" href="/classes?assign_to=${s.id}" title="Перейти к назначению">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Задание
</a>
<button class="ms-btn danger" onclick="removeStudent(${s.id}, '${esc(s.name).replace(/'/g, "\\'")}')" title="Удалить из списка">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>
</button>
</div>
</div>`).join('')}
</div>`;
}
window.addStudent = async function () {
const emailEl = document.getElementById('ms-email');
const noteEl = document.getElementById('ms-note');
const msgEl = document.getElementById('ms-add-msg');
const btn = document.getElementById('ms-add-btn');
const email = emailEl.value.trim();
if (!email) return;
msgEl.className = 'ms-add-msg';
btn.disabled = true;
try {
const r = await LS.api('/api/teacher-students', {
method: 'POST',
body: { email, note: noteEl.value.trim() || null },
});
msgEl.className = 'ms-add-msg success';
msgEl.textContent = ${r.student.name}» добавлен в ваш список`;
emailEl.value = '';
noteEl.value = '';
await loadStudents();
} catch (e) {
msgEl.className = 'ms-add-msg error';
msgEl.textContent = e.message || 'Ошибка';
} finally {
btn.disabled = false;
}
};
window.showProfile = async function (id, name) {
const r = await LS.api('/api/assistant/student-profile/' + id).catch(() => null);
if (!r) return;
const p = r.profile || {}, rows = [];
if (p.exam) rows.push('Готовится к экзамену: ' + esc(p.exam.key) + (p.exam.date ? ' (до ' + esc(p.exam.date) + ')' : ''));
if (p.weakSubjects && p.weakSubjects.length) rows.push('Слабые предметы: ' + p.weakSubjects.map(s => esc(s.name) + ' ' + s.avg + '%').join(', '));
if (p.weakTopics && p.weakTopics.length) rows.push('Трудные темы экзамена: ' + p.weakTopics.map(t => esc(t.topic) + ' ' + t.rate + '%').join(', '));
if (p.streak >= 3) rows.push('Серия занятий: ' + p.streak + ' дн.');
const body = rows.length
? rows.map(x => '<div style="padding:7px 0;border-bottom:1px solid var(--border);font-size:.86rem">' + x + '</div>').join('')
: '<div style="color:var(--text-2);font-size:.86rem;padding:6px 0">Пока недостаточно данных — слабые темы появятся после тестов и тренировок по экзамену.</div>';
const ov = document.createElement('div');
ov.style.cssText = 'position:fixed;inset:0;z-index:1200;display:flex;align-items:center;justify-content:center;background:rgba(15,23,42,.45);backdrop-filter:blur(6px);padding:20px';
ov.innerHTML = '<div style="background:var(--surface);border:1.5px solid var(--border);border-radius:18px;max-width:460px;width:100%;padding:20px 22px;box-shadow:0 24px 70px rgba(0,0,0,.3)">'
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"><b style="font-family:Unbounded,sans-serif;font-size:.95rem">' + esc(r.name || name || 'Ученик') + ' — профиль для Квантика</b>'
+ '<button data-x style="border:none;background:none;font-size:1.4rem;line-height:1;cursor:pointer;color:var(--text-2)">&times;</button></div>'
+ body
+ '<div style="font-size:.72rem;color:var(--text-3);margin-top:12px;line-height:1.5">Эти данные Квантик использует для персонализации объяснений ученику. Личные заметки ученика остаются приватными.</div></div>';
document.body.appendChild(ov);
ov.addEventListener('click', function (e) { if (e.target === ov || e.target.hasAttribute('data-x')) ov.remove(); });
};
window.removeStudent = async function (id, name) {
const ok = await LS.confirm(
`Убрать «${name}» из списка?\nСозданные задания не удалятся — ученик продолжит их видеть.`,
{ title: 'Убрать ученика', confirmText: 'Убрать', danger: true }
);
if (!ok) return;
try {
await LS.api('/api/teacher-students/' + id, { method: 'DELETE' });
students = students.filter(s => s.id !== id);
render();
LS.toast(${name}» убран из списка`, 'success');
} catch (e) {
LS.toast('Ошибка: ' + e.message, 'error');
}
};
await loadStudents();
})();
</script>
</body>
</html>