feat(homework): блок «Актуальные задания» на странице /homework

Раньше страница «Домашние задания» показывала только историю сдач, а сам список
актуальных ДЗ (что нужно сделать, с дедлайнами) жил лишь на дашборде. Теперь у ученика
сверху секция «Актуальные задания» на тех же данных (LS.myAssignments) — карточки с
дедлайном/просрочкой/срочностью, действие по типу задания: тест → Начать/Продолжить
(LS.startAssignment), учебник → Открыть §, файл → Скачать, ДЗ-загрузка → Сдать
(прокрутка к области загрузки + преднабор задания). Закрытые задания (пройденный тест,
прочитанный учебник, принятая работа) скрыты; пустая секция не показывается.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-23 12:57:50 +03:00
parent 22c7b38e9a
commit 748b0aaab1
+250 -22
View File
@@ -125,6 +125,41 @@
/* student name in teacher view */
.hw-student-name { font-size: 0.78rem; font-weight: 700; color: var(--violet); }
/* ── section titles (multi-block student view) ── */
.hw-sec-title {
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
color: #0F172A; margin: 4px 0 12px; display: flex; align-items: center; gap: 9px;
}
.hw-sec-count {
font-family: 'Manrope', sans-serif; font-size: 0.7rem; font-weight: 700;
color: var(--violet); background: rgba(155,93,229,0.1);
padding: 2px 9px; border-radius: 999px;
}
#hw-active-wrap { margin-bottom: 28px; }
/* ── active assignment cards ── */
.hw-acard {
background: #fff; border-radius: 16px; padding: 16px 18px;
border: 1px solid rgba(15,23,42,0.06); border-left: 3px solid var(--ac, #9B5DE5);
display: flex; align-items: center; gap: 14px; transition: all .15s;
}
.hw-acard:hover { box-shadow: 0 2px 12px rgba(15,23,42,0.06); }
.hw-acard.over { border-left-color: #EF476F; }
.hw-acard.urgent { border-left-color: #F59E0B; }
.hw-acard-icon {
width: 42px; height: 42px; border-radius: 12px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
}
.hw-acard-body { flex: 1; min-width: 0; }
.hw-acard-title { font-size: 0.88rem; font-weight: 700; color: #0F172A; margin-bottom: 4px; }
.hw-acard-meta { font-size: 0.74rem; color: var(--text-3); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.hw-acard-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.hw-dl-chip { font-size: 0.7rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
.hw-dl-soon { background: rgba(245,158,11,0.12); color: #F59E0B; }
.hw-dl-over { background: rgba(239,71,111,0.12); color: #EF476F; }
.hw-dl-ok { background: rgba(15,23,42,0.05); color: var(--text-3); }
.hw-sub-chip { font-size: 0.68rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
@media (max-width: 768px) {
.container { padding: 16px 14px 80px; }
.hw-top { gap: 8px; }
@@ -133,6 +168,8 @@
.hw-card-right { flex-direction: row; align-items: center; justify-content: flex-start; width: 100%; }
.hw-card-actions { flex-wrap: wrap; }
.hw-upload-area { padding: 20px 16px; }
.hw-acard { flex-wrap: wrap; }
.hw-acard-right { width: 100%; justify-content: flex-end; }
}
@media (max-width: 480px) {
.container { padding: 12px 10px 80px; }
@@ -152,8 +189,15 @@
<div class="page-title">Домашние задания</div>
<div class="page-sub" id="hw-sub">Загрузка…</div>
<!-- Student: active assignments (что нужно сделать) -->
<div id="hw-active-wrap" style="display:none">
<div class="hw-sec-title">Актуальные задания <span class="hw-sec-count" id="hw-active-count"></span></div>
<div class="hw-list" id="hw-active-list"></div>
</div>
<!-- Student: upload area -->
<div id="hw-upload-wrap" style="display:none">
<div class="hw-sec-title">Сдать работу</div>
<div class="hw-upload-area" id="hw-upload-area" onclick="document.getElementById('hw-file-input').click()">
<div class="hw-upload-icon"><i data-lucide="upload-cloud" style="width:36px;height:36px"></i></div>
<div class="hw-upload-text">Загрузить работу</div>
@@ -195,6 +239,7 @@
</div>
<!-- Student: status filters -->
<div class="hw-sec-title" id="hw-mysubs-title" style="display:none">Мои сдачи</div>
<div class="hw-top" id="hw-top-student" style="display:none">
<div class="hw-status-filters">
<button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button>
@@ -247,6 +292,14 @@
resubmitted: 'Повторно', accepted: 'Принято'
};
/* subject label/colour/icon maps (как на дашборде) */
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
const SUBJ_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap', other:'file-check' };
const SUBJ_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B', other:'#7c3aed' };
let _assignments = []; // актуальные задания (LS.myAssignments)
let _subByAsgn = new Map(); // assignment_id -> последняя сдача
/* ── filter ── */
function filterStatus(st, btn) {
_statusFilter = st;
@@ -257,33 +310,31 @@
/* ── STUDENT VIEW ── */
async function initStudent() {
document.getElementById('hw-sub').textContent = 'Сдавайте работы и отслеживайте оценки';
document.getElementById('hw-sub').textContent = 'Ваши актуальные задания и сданные работы';
document.getElementById('hw-top-student').style.display = '';
document.getElementById('hw-mysubs-title').style.display = '';
// Find student's class
// Find student's class (нужен для загрузки работ без привязки к заданию)
try {
const classes = await LS.myClasses();
if (classes.length) {
_studentClassId = classes[0].id;
document.getElementById('hw-upload-wrap').style.display = '';
// Load assignments for selector
try {
const feed = await LS.classFeed(classes[0].id);
const sel = document.getElementById('hw-assignment-sel');
(feed.assignments || []).forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.title;
sel.appendChild(opt);
});
} catch {}
}
} catch {}
// Load submissions
// Грузим актуальные задания (все классы) + сдачи параллельно
try {
_submissions = await LS.getMySubmissions();
const [assigns, subs] = await Promise.all([
LS.myAssignments().catch(() => []),
LS.getMySubmissions().catch(() => []),
]);
_assignments = Array.isArray(assigns) ? assigns : [];
_submissions = Array.isArray(subs) ? subs : [];
_subByAsgn.clear();
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
populateAssignmentSelect(_assignments);
renderActiveAssignments();
renderSubmissions();
} catch {
document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>';
@@ -312,14 +363,22 @@
}
async function submitHomework() {
if (!_selectedFile || !_studentClassId) return;
if (!_selectedFile) return;
const sel = document.getElementById('hw-assignment-sel');
const assignId = sel.value;
// Класс берём от выбранного задания (важно для учеников в нескольких классах),
// иначе — первый класс ученика.
let classId = _studentClassId;
if (assignId && sel.selectedOptions[0] && sel.selectedOptions[0].dataset.class) {
classId = sel.selectedOptions[0].dataset.class;
}
if (!classId) { LS.toast('Вы не состоите в классе', 'error'); return; }
const btn = document.getElementById('hw-submit-btn');
btn.disabled = true;
try {
const fd = new FormData();
fd.append('file', _selectedFile);
fd.append('class_id', _studentClassId);
const assignId = document.getElementById('hw-assignment-sel').value;
fd.append('class_id', classId);
if (assignId) fd.append('assignment_id', assignId);
const msg = document.getElementById('hw-message').value.trim();
if (msg) fd.append('message', msg);
@@ -336,12 +395,20 @@
// Reload
_submissions = await LS.getMySubmissions();
renderSubmissions();
syncStudentLists();
} catch (e) {
LS.toast(e.message || 'Ошибка отправки', 'error');
} finally { btn.disabled = !_selectedFile; }
}
/* Пересобрать карту сдач и перерисовать обе студенческие секции. */
function syncStudentLists() {
_subByAsgn.clear();
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
renderActiveAssignments();
renderSubmissions();
}
async function resubmitHomework(subId) {
const input = document.createElement('input');
input.type = 'file';
@@ -355,7 +422,7 @@
await LS.resubmitWork(subId, fd);
LS.toast('Работа отправлена повторно!', 'success');
_submissions = await LS.getMySubmissions();
renderSubmissions();
syncStudentLists();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
};
input.click();
@@ -366,7 +433,7 @@
try {
await LS.deleteSubmission(id);
_submissions = _submissions.filter(s => s.id !== id);
renderSubmissions();
syncStudentLists();
LS.toast('Удалено', 'info');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
@@ -381,6 +448,167 @@
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ══ АКТУАЛЬНЫЕ ЗАДАНИЯ (что нужно сделать) ══════════════════════════ */
// Тип задания — по тем же признакам, что и карточки дашборда.
function asgnType(a) {
if (a.textbook_id) return 'textbook';
if (a.file_id) return 'file';
if (a.is_homework && !a.session_id && (a.count == null || a.count <= 1)
&& (!a.subject_slug || a.subject_slug === 'other')) return 'upload';
return 'test';
}
// «Закрыто» — задание уходит из актуальных.
function asgnDone(a) {
const t = asgnType(a);
if (t === 'textbook') return !!(a.textbook_all_read || a.completed_at);
if (t === 'upload' || t === 'file') {
const s = _subByAsgn.get(a.id);
return !!(s && s.status === 'accepted');
}
// test
const maxAtt = a.max_attempts || 0;
const usedAtt = a.attempts_used != null ? a.attempts_used : 0;
if (maxAtt > 0 && usedAtt >= maxAtt) return true;
return a.session_status === 'completed' && a.mode !== 'repeat';
}
// Сортировка по срочности (меньше — выше): идёт → просрочено → <24ч → по дедлайну → без срока.
function urgencyScore(a) {
if (a.session_status === 'in_progress') return -4;
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
if (dlMs < 0) return -3;
if (dlMs < 24 * 3600 * 1000) return -2;
if (dlMs < Infinity) return dlMs;
return 1e12;
}
function deadlineChip(a) {
if (!a.deadline) return '<span class="hw-dl-chip hw-dl-ok">Без срока</span>';
const dlMs = new Date(a.deadline) - Date.now();
const date = new Date(a.deadline).toLocaleDateString('ru', { day: 'numeric', month: 'short' });
if (dlMs < 0) return `<span class="hw-dl-chip hw-dl-over">Просрочено · ${date}</span>`;
if (dlMs < 24 * 3600 * 1000) return `<span class="hw-dl-chip hw-dl-soon">Сегодня · до ${date}</span>`;
const days = Math.ceil(dlMs / 86400000);
const txt = days === 1 ? '1 день' : `${days} дн.`;
return `<span class="hw-dl-chip hw-dl-ok">${txt} · до ${date}</span>`;
}
function actionFor(a) {
const t = asgnType(a);
if (t === 'textbook') {
let hash = '';
if (a.textbook_paragraphs) { const m = String(a.textbook_paragraphs).match(/^\s*(\d+)/); if (m) hash = '#p' + m[1]; }
const href = `/textbook/${a.textbook_slug || ''}${hash}`;
return `<a class="hw-btn hw-btn-primary" href="${href}">${(a.textbook_read_count || 0) > 0 ? 'Продолжить' : 'Открыть'}</a>`;
}
if (t === 'file') {
const sub = _subByAsgn.get(a.id);
const submit = sub && sub.status !== 'revision'
? `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`
: `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
return `<a class="hw-btn" href="${LS.downloadFileUrl(a.file_id)}" target="_blank" download>Скачать</a>${submit}`;
}
if (t === 'upload') {
const sub = _subByAsgn.get(a.id);
if (sub && sub.status !== 'revision') {
return `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`;
}
return `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
}
// test
const inProgress = a.session_status === 'in_progress';
const isDone = a.session_status === 'completed';
const label = inProgress ? 'Продолжить' : (isDone && a.mode === 'repeat') ? 'Повторить' : 'Начать';
return `<button class="hw-btn hw-btn-primary" onclick="startAsgn(event,${a.id},'${a.mode || 'exam'}')">${label}</button>`;
}
function activeCardHtml(a) {
const t = asgnType(a);
const color = SUBJ_COLORS[a.subject_slug] || (t === 'textbook' ? '#7c3aed' : '#9B5DE5');
const icon = t === 'textbook' ? 'book-open-text'
: t === 'file' ? 'paperclip'
: t === 'upload' ? 'upload'
: (SUBJ_ICONS[a.subject_slug] || 'file-text');
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
const over = dlMs < 0;
const urgent = !over && dlMs < 24 * 3600 * 1000;
const cls = over ? ' over' : urgent ? ' urgent' : '';
const classStr = a.class_id ? esc(a.class_name) : 'Личное';
const subjStr = SUBJ[a.subject_slug] || (t === 'textbook' ? 'Чтение' : '');
const meta = [classStr, subjStr].filter(Boolean).join(' · ');
return `<div class="hw-acard${cls}" style="--ac:${color}">
<div class="hw-acard-icon" style="background:${color}1a;color:${color}"><i data-lucide="${icon}" style="width:20px;height:20px"></i></div>
<div class="hw-acard-body">
<div class="hw-acard-title">${esc(a.title)}</div>
<div class="hw-acard-meta">${meta ? `<span>${meta}</span>` : ''}${deadlineChip(a)}</div>
</div>
<div class="hw-acard-right">${actionFor(a)}</div>
</div>`;
}
function renderActiveAssignments() {
const wrap = document.getElementById('hw-active-wrap');
const list = document.getElementById('hw-active-list');
if (!wrap || !list) return;
const active = _assignments.filter(a => !asgnDone(a)).sort((x, y) => urgencyScore(x) - urgencyScore(y));
if (!active.length) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
document.getElementById('hw-active-count').textContent = active.length;
list.innerHTML = active.map(activeCardHtml).join('');
lucide.createIcons();
}
// Наполнить выпадашку «Задание» при загрузке работы — по ВСЕМ классам ученика.
function populateAssignmentSelect(list) {
const sel = document.getElementById('hw-assignment-sel');
if (!sel) return;
sel.querySelectorAll('option[data-asgn]').forEach(o => o.remove());
list.filter(a => !a.session_id && !a.textbook_id).forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.title + (a.class_name && a.class_name !== 'Личное задание' ? ' · ' + a.class_name : '');
opt.dataset.asgn = '1';
if (a.class_id) opt.dataset.class = a.class_id;
sel.appendChild(opt);
});
}
// Начать/продолжить тест-задание (как на дашборде).
async function startAsgn(e, id, mode) {
const btn = e.currentTarget;
const orig = btn.textContent;
btn.disabled = true; btn.textContent = '…';
try {
const r = await LS.startAssignment(id);
if (r.error && r.max_attempts) {
LS.toast(`Исчерпан лимит попыток (${r.attempts_used}/${r.max_attempts})`, 'warn');
btn.disabled = false; btn.textContent = orig; return;
}
const aMode = r.assignment_mode || mode || 'exam';
if (r.status === 'completed' && aMode !== 'repeat') location.href = `/test-result?session=${r.session_id}`;
else location.href = `/test-run?session=${r.session_id}&assignment_mode=${aMode}`;
} catch (err) {
const isLimit = err.message && (err.message.includes('лимит') || err.message.includes('Исчерпан'));
LS.toast(isLimit ? err.message : ('Ошибка: ' + err.message), isLimit ? 'warn' : 'error');
btn.disabled = false; btn.textContent = orig;
}
}
// «Сдать» из карточки → прокрутить к области загрузки и преднабрать задание.
function sdatNow(assignId) {
const wrap = document.getElementById('hw-upload-wrap');
if (!wrap || wrap.style.display === 'none') {
LS.toast('Загрузка работ доступна участникам класса', 'warn'); return;
}
const sel = document.getElementById('hw-assignment-sel');
if (sel && [...sel.options].some(o => o.value == assignId)) sel.value = String(assignId);
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
const area = document.getElementById('hw-upload-area');
if (area) { area.classList.add('dragover'); setTimeout(() => area.classList.remove('dragover'), 1200); }
}
/* ── TEACHER VIEW ── */
async function initTeacher() {
document.getElementById('hw-sub').textContent = 'Проверяйте работы учеников и ставьте оценки';