feat(materials): Фаза 3 (часть 1) — универсальный буфер + источник «Экзамен»

- /js/material-save.js — общий модуль: MaterialSave.note/link/image поверх LS.saveMaterial/uploadFile.
- exam-prep/task-card.js: кнопка «В мои материалы» на карточке задачи (вариант/тренажёр/тема) —
  сохраняет условие+ответ+решение как заметку (sourceTitle = название экзамена). В пробнике скрыта.
- Подключён material-save.js на 4 страницах экзамена.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 12:13:44 +03:00
parent 9c95dc8bff
commit 61e30bedf9
6 changed files with 89 additions and 8 deletions
+1
View File
@@ -56,6 +56,7 @@
<script src="/js/exam-prep/api.js"></script>
<script src="/js/exam-prep/katex.js"></script>
<script src="/js/exam-prep/answer-check.js"></script>
<script src="/js/material-save.js"></script>
<script src="/js/exam-prep/task-card.js"></script>
<script src="/js/exam-prep/mock.js"></script>
</body>
+1
View File
@@ -56,6 +56,7 @@
<script src="/js/exam-prep/api.js"></script>
<script src="/js/exam-prep/katex.js"></script>
<script src="/js/exam-prep/answer-check.js"></script>
<script src="/js/material-save.js"></script>
<script src="/js/exam-prep/task-card.js"></script>
<script src="/js/exam-prep/practice.js"></script>
</body>
+1
View File
@@ -56,6 +56,7 @@
<script src="/js/exam-prep/api.js"></script>
<script src="/js/exam-prep/katex.js"></script>
<script src="/js/exam-prep/answer-check.js"></script>
<script src="/js/material-save.js"></script>
<script src="/js/exam-prep/task-card.js"></script>
<script src="/js/exam-prep/topics.js"></script>
</body>
+1
View File
@@ -72,6 +72,7 @@
<script src="/js/exam-prep/api.js"></script>
<script src="/js/exam-prep/katex.js"></script>
<script src="/js/exam-prep/answer-check.js"></script>
<script src="/js/material-save.js"></script>
<script src="/js/exam-prep/task-card.js"></script>
<script src="/js/exam-prep/variants.js"></script>
</body>
+30 -8
View File
@@ -33,6 +33,10 @@
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}
/* HTML → readable plain text (keeps $…$ math source) for saving to materials. */
function stripHtml(s) {
return String(s || '').replace(/<svg[\s\S]*?<\/svg>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
/* topic_ref → "Учить тему" deep-link to the textbook chapter/paragraph.
ref = { slug, paragraph, title }. Paragraph is null for hub links. */
@@ -120,14 +124,18 @@
}
const refLink = buildRefLink(task.topic_ref);
const solBlock = (showSol && task.solution) ? `
<div class="tc-sol-wrap">
<div class="tc-sol-row">
<button class="tc-sol-btn" data-tc-sol>${ICONS.chev}<span>Показать решение</span></button>
${refLink}
</div>
<div class="tc-sol-panel" data-tc-sol-panel>${task.solution}</div>
</div>` : (refLink ? `<div class="tc-sol-wrap"><div class="tc-sol-row">${refLink}</div></div>` : '');
const allowSave = mode !== 'mock';
const saveMatBtn = allowSave
? `<button class="tc-savemat" data-tc-savemat title="Сохранить задание в «Мои материалы»" style="margin-left:auto;background:none;border:1px solid var(--border,#e2e8f0);border-radius:8px;height:30px;padding:0 10px;cursor:pointer;color:var(--text-3,#64748b);display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:.78rem;font-weight:600">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>В мои материалы</button>`
: '';
const solToggle = (showSol && task.solution)
? `<button class="tc-sol-btn" data-tc-sol>${ICONS.chev}<span>Показать решение</span></button>` : '';
const solPanelHtml = (showSol && task.solution)
? `<div class="tc-sol-panel" data-tc-sol-panel>${task.solution}</div>` : '';
const solBlock = (solToggle || refLink || saveMatBtn)
? `<div class="tc-sol-wrap"><div class="tc-sol-row">${solToggle}${refLink}${saveMatBtn}</div>${solPanelHtml}</div>`
: '';
card.innerHTML = `
<div class="tc-head">
@@ -147,6 +155,20 @@
// ── KaTeX render
EP.katex?.run(card);
// ── Save task to «Мои материалы» (universal buffer)
const saveMatEl = card.querySelector('[data-tc-savemat]');
if (saveMatEl) {
saveMatEl.addEventListener('click', () => {
if (!window.MaterialSave) return;
const examTitle = (window.EP && EP.info && EP.info.track && EP.info.track.title) || 'Экзамен';
const loc = (task.variant != null ? 'Вариант ' + task.variant + ', ' : '') + '№' + (task.idx != null ? task.idx : numbering);
const parts = [stripHtml(task.text)];
if (task.answer) parts.push('Ответ: ' + task.answer);
if (task.solution) parts.push('Решение: ' + stripHtml(task.solution));
MaterialSave.note({ title: examTitle + ' · ' + loc, body: parts.join('\n\n'), sourceTitle: examTitle }, saveMatEl);
});
}
// ── State
let startedAt = Date.now();
let solutionLogged = false;
+55
View File
@@ -0,0 +1,55 @@
'use strict';
/* material-save.js — универсальная кнопка «В мои материалы».
* Тонкий слой поверх LS.saveMaterial / LS.uploadFile: сохраняет заметку,
* ссылку или изображение в личную коллекцию ученика из любой части платформы
* (учебник, экзамен, лаборатория, чат и т.п.).
*
* MaterialSave.note({ title, body, sourceTitle }, btn?)
* MaterialSave.link({ title, url, sourceTitle }, btn?)
* MaterialSave.image({ title, url|blob, name?, sourceTitle }, btn?)
*
* Требует window.LS (api.js). Показывает тост; кнопку (если передана) блокирует на время.
*/
(function () {
function ok() { if (window.LS && LS.toast) LS.toast('Сохранено в «Мои материалы»', 'success'); }
function err(e) { if (window.LS && LS.toast) LS.toast((e && e.message) || 'Ошибка сохранения', 'error'); }
async function note(o, btn) {
o = o || {};
if (!String(o.body || '').trim() && !String(o.title || '').trim()) { err({ message: 'Пустая заметка' }); return; }
if (btn) btn.disabled = true;
try {
await LS.saveMaterial({ kind: 'note', title: o.title || '', body: o.body || '', sourceTitle: o.sourceTitle || null });
ok();
} catch (e) { err(e); } finally { if (btn) btn.disabled = false; }
}
async function link(o, btn) {
o = o || {};
if (!o.url) { err({ message: 'Нет ссылки' }); return; }
if (btn) btn.disabled = true;
try {
await LS.saveMaterial({ kind: 'link', title: o.title || '', url: o.url, sourceTitle: o.sourceTitle || null });
ok();
} catch (e) { err(e); } finally { if (btn) btn.disabled = false; }
}
async function image(o, btn) {
o = o || {};
if (btn) btn.disabled = true;
try {
let url = o.url;
if (o.blob) {
const fd = new FormData();
fd.append('file', o.blob, o.name || 'image.png');
const up = await LS.uploadFile(fd);
url = LS.downloadFileUrl(up.id);
}
if (!url) throw new Error('Нет изображения');
await LS.saveMaterial({ kind: 'image', title: o.title || '', url: url, sourceTitle: o.sourceTitle || null });
ok();
} catch (e) { err(e); } finally { if (btn) btn.disabled = false; }
}
window.MaterialSave = { note: note, link: link, image: image };
})();