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:
+250
-22
@@ -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 = 'Проверяйте работы учеников и ставьте оценки';
|
||||
|
||||
Reference in New Issue
Block a user