testingwebapp fixes, weeek tasks 2948-2958
This commit is contained in:
@@ -81,6 +81,11 @@ h3 {
|
||||
/* Кабинетный UI (класс body.ui-legacy на корне). */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Высота ряда шапки: лого 2rem или блок кнопки (.btn padding + текст) — для fixed-тостов под header */
|
||||
body.ui-legacy {
|
||||
--cabinet-header-row-h: max(2rem, calc(0.9375rem * 1.38 + 1.1rem + 3px));
|
||||
}
|
||||
|
||||
body.ui-legacy .max-w-2xl {
|
||||
max-width: 42rem !important;
|
||||
}
|
||||
@@ -733,12 +738,60 @@ body.ui-legacy .test-detail-subsection__title {
|
||||
padding: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.editor-generation-panel__status {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0;
|
||||
/* Уведомления ИИ: справа под шапкой; зазор под шапкой = боковой отступ (0.65rem) */
|
||||
.editor-gen-toast {
|
||||
--editor-toast-inset: max(0.65rem, env(safe-area-inset-right, 0px));
|
||||
position: fixed;
|
||||
right: var(--editor-toast-inset);
|
||||
left: auto;
|
||||
/* Низ шапки: padding-top + ряд + padding-bottom + border; затем зазор 0.65rem как у боковых inset тоста */
|
||||
top: calc(
|
||||
max(0.75rem, env(safe-area-inset-top, 0px)) + var(--cabinet-header-row-h) + 0.75rem + 1px + 0.65rem
|
||||
);
|
||||
z-index: 10060;
|
||||
box-sizing: border-box;
|
||||
max-width: min(28rem, calc(100vw - 1.5rem));
|
||||
min-height: 2.85rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.35;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 3px 18px rgba(15, 23, 42, 0.16);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transform: translateX(calc(100% + 1.25rem));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.35s ease;
|
||||
}
|
||||
.editor-generation-panel__status:empty {
|
||||
display: none;
|
||||
.editor-gen-toast.editor-gen-toast--open {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.editor-gen-toast[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.editor-gen-toast[data-variant='info'] {
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--ink-200, #e5e7eb);
|
||||
color: var(--ink-800, #1f2937);
|
||||
}
|
||||
.editor-gen-toast[data-variant='ok'] {
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #6ee7b7;
|
||||
color: #065f46;
|
||||
}
|
||||
.editor-gen-toast[data-variant='err'] {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* ─── Option row alignment ───────────────────────────────────────── */
|
||||
@@ -751,10 +804,19 @@ body.ui-legacy .test-detail-subsection__title {
|
||||
.opt-text {
|
||||
line-height: 1.55;
|
||||
}
|
||||
.opt-delete {
|
||||
.opt-delete,
|
||||
.opt-ai {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.opt-text-wrap {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
.opt-item--ai-busy .opt-correct {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ─── Question AI overlay ────────────────────────────────────────── */
|
||||
.q-ai-overlay {
|
||||
transition: opacity 0.15s;
|
||||
@@ -783,7 +845,7 @@ body.ui-legacy .test-detail-subsection__title {
|
||||
border-style: solid;
|
||||
border-color: var(--primary, #007168) !important;
|
||||
background-color: color-mix(in srgb, var(--primary, #007168) 6%, transparent) !important;
|
||||
pointer-events: none;
|
||||
/* не pointer-events:none — иначе нельзя снова открыть выбор файла тем же кликом по зоне */
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
|
||||
+434
-126
@@ -25,7 +25,10 @@
|
||||
const questionsEl = $('#questions');
|
||||
const qCountEl = $('#q-count');
|
||||
const saveStatusEl = $('#save-status');
|
||||
const aiStatusEl = $('#ai-status');
|
||||
const aiKeepTitleEl = $('#ai-keep-title');
|
||||
const aiImproveFocusEl = $('#ai-improve-focus');
|
||||
const aiImportClearBtn = $('#ai-import-clear');
|
||||
const addQuestionAiBtn = $('#add-question-ai');
|
||||
const chainActiveEl = { checked: true, _val: true }; // display-only — реальный toggle в блоке «Показ в каталоге»
|
||||
const chainActiveDisplay = $('#chain-active-display');
|
||||
const aiTopicEl = $('#ai-topic');
|
||||
@@ -34,7 +37,6 @@
|
||||
const templateGlobalMultiEl = $('#template-global-multi');
|
||||
const templateMinCorrectEl = $('#template-min-correct');
|
||||
const templateMaxCorrectEl = $('#template-max-correct');
|
||||
const docProgressEl = $('#doc-progress');
|
||||
const introUpdatedEl = $('#intro-updated');
|
||||
const introForkBannerEl = $('#intro-fork-banner');
|
||||
const versionsListEl = $('#versions-list');
|
||||
@@ -167,6 +169,16 @@
|
||||
if (qNode) updateOptionsCounter(qNode);
|
||||
scheduleDirtyCheck();
|
||||
});
|
||||
const optAiBtn = $('.opt-ai', node);
|
||||
if (optAiBtn && qNode) {
|
||||
optAiBtn.addEventListener('click', async (ev) => {
|
||||
ev.preventDefault();
|
||||
const opts = $$('.opt-item', qNode);
|
||||
const idx = opts.indexOf(node);
|
||||
if (idx < 0) return;
|
||||
await improveSingleOption(qNode, idx);
|
||||
});
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -476,6 +488,92 @@
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Тост справа под шапкой: выезд справа, variant ok/err/info, авто-уезд по durationMs. */
|
||||
function hideToastAnimated(el) {
|
||||
if (!el) return;
|
||||
clearTimeout(showAiToast._timer);
|
||||
clearTimeout(showAiToast._fallbackHide);
|
||||
el.classList.remove('editor-gen-toast--open');
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(showAiToast._fallbackHide);
|
||||
el.removeEventListener('transitionend', onEnd);
|
||||
el.hidden = true;
|
||||
el.textContent = '';
|
||||
el.removeAttribute('data-variant');
|
||||
};
|
||||
const onEnd = (ev) => {
|
||||
if (ev.propertyName !== 'transform') return;
|
||||
finish();
|
||||
};
|
||||
el.addEventListener('transitionend', onEnd);
|
||||
showAiToast._fallbackHide = setTimeout(finish, 500);
|
||||
}
|
||||
|
||||
function showAiToast(message, variant = 'info', durationMs = 4800) {
|
||||
const el = document.getElementById('editor-gen-toast');
|
||||
if (!el) return;
|
||||
clearTimeout(showAiToast._timer);
|
||||
clearTimeout(showAiToast._fallbackHide);
|
||||
if (!message) {
|
||||
if (!el.hidden) hideToastAnimated(el);
|
||||
return;
|
||||
}
|
||||
if (el.classList.contains('editor-gen-toast--open') && !el.hidden) {
|
||||
el.textContent = message;
|
||||
el.dataset.variant = variant;
|
||||
if (durationMs != null && durationMs > 0) {
|
||||
showAiToast._timer = setTimeout(() => hideToastAnimated(el), durationMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
el.textContent = message;
|
||||
el.dataset.variant = variant;
|
||||
el.hidden = false;
|
||||
el.classList.remove('editor-gen-toast--open');
|
||||
void el.offsetWidth;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
el.classList.add('editor-gen-toast--open');
|
||||
});
|
||||
});
|
||||
if (durationMs != null && durationMs > 0) {
|
||||
showAiToast._timer = setTimeout(() => hideToastAnimated(el), durationMs);
|
||||
}
|
||||
}
|
||||
|
||||
function formatEtaSeconds(sec) {
|
||||
const s = Math.min(7200, Math.max(5, Math.round(sec)));
|
||||
if (s < 60) return `примерно ${s} с`;
|
||||
const m = Math.ceil(s / 60);
|
||||
if (m === 1) return 'примерно 1 мин';
|
||||
return `примерно ${m} мин`;
|
||||
}
|
||||
|
||||
function etaFullGenerateSeconds(nQ) {
|
||||
return Math.min(900, Math.max(25, 20 + nQ * 12));
|
||||
}
|
||||
|
||||
async function runHintsProgressFromServer(startMissing) {
|
||||
const total = Math.max(0, Number(startMissing) || 0);
|
||||
if (!total) return;
|
||||
let iter = 0;
|
||||
const maxIter = Math.min(500, Math.max(total * 3, total + 15));
|
||||
while (iter < maxIter) {
|
||||
iter += 1;
|
||||
const line = `Подсказки ИИ: шаг ${iter} из ~${total}`;
|
||||
showAiToast(line, 'info', 0);
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate-next`, { method: 'POST' });
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(d.error || 'Не удалось сгенерировать подсказку.');
|
||||
if (d.done || d.remaining === 0) break;
|
||||
if (d.generated) continue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── collect ───────────────────────────────────────────────────────
|
||||
|
||||
function collectPayload() {
|
||||
@@ -668,26 +766,22 @@
|
||||
+ 'Сгенерировать недостающие подсказки через ИИ?',
|
||||
);
|
||||
if (okGen) {
|
||||
saveStatusEl.textContent = `Создаём ИИ-подсказки (${st.missing} из ${st.total})…`;
|
||||
const gr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
|
||||
const gd = await gr.json().catch(() => ({}));
|
||||
if (!gr.ok) {
|
||||
try {
|
||||
await runHintsProgressFromServer(st.missing);
|
||||
if (saveMsg) saveMsg.textContent = `${msg} Подсказки обновлены через ИИ.`;
|
||||
try {
|
||||
await refreshHintsInForm();
|
||||
} catch (_) {
|
||||
/* не блокируем успех сохранения */
|
||||
}
|
||||
showAiToast('Подсказки сгенерированы.', 'ok');
|
||||
} catch (he) {
|
||||
saveStatusEl.textContent = '';
|
||||
alert(gd.error || 'Не удалось сгенерировать подсказки.');
|
||||
if (saveMsg) saveMsg.textContent = msg + ' (подсказки не созданы)';
|
||||
alert(he.message || 'Не удалось сгенерировать подсказки.');
|
||||
if (saveMsg) saveMsg.textContent = `${msg} (подсказки не созданы)`;
|
||||
if (saveModal) saveModal.showModal();
|
||||
return;
|
||||
}
|
||||
const skipped = Number(gd.skipped || 0);
|
||||
const tail = gd.failed
|
||||
? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось${skipped ? `, пропущено ${skipped}` : ''}.`
|
||||
: ` Подсказки созданы (${gd.generated})${skipped ? `, пропущено ${skipped}` : ''}.`;
|
||||
if (saveMsg) saveMsg.textContent = msg + tail;
|
||||
try {
|
||||
await refreshHintsInForm();
|
||||
} catch (_) {
|
||||
/* не блокируем успех сохранения */
|
||||
}
|
||||
} else if (saveMsg) {
|
||||
saveMsg.textContent = msg;
|
||||
}
|
||||
@@ -716,14 +810,15 @@
|
||||
alert('Укажите тему.');
|
||||
return;
|
||||
}
|
||||
// Предупреждение, если в тесте уже есть вопросы или заполненное название/описание
|
||||
const keepTitle = !!(aiKeepTitleEl && aiKeepTitleEl.checked);
|
||||
const hasContent = questionsEl.children.length > 0
|
||||
|| titleEl.value.trim()
|
||||
|| descEl.value.trim();
|
||||
if (hasContent) {
|
||||
const ok = confirm(
|
||||
'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?'
|
||||
);
|
||||
const warn = keepTitle
|
||||
? 'Полная генерация заменит описание и все вопросы. Название в редакторе не будет заменено, если включено «Не менять название».\n\nПродолжить?'
|
||||
: 'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?';
|
||||
const ok = confirm(warn);
|
||||
if (!ok) return;
|
||||
}
|
||||
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
|
||||
@@ -736,7 +831,11 @@
|
||||
minCorrect: globalMulti ? globalRange.minCorrect : 1,
|
||||
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
|
||||
}));
|
||||
aiStatusEl.textContent = 'Генерируем структуру и вопросы…';
|
||||
showAiToast(
|
||||
`Генерация ${nQ} вопросов · ~${formatEtaSeconds(etaFullGenerateSeconds(nQ))}`,
|
||||
'info',
|
||||
0,
|
||||
);
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
|
||||
method: 'POST',
|
||||
@@ -750,44 +849,48 @@
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
||||
const draft = data.draft;
|
||||
if (draft.title) {
|
||||
if (draft.title && !keepTitle) {
|
||||
titleEl.value = draft.title;
|
||||
if (aiTopicEl) aiTopicEl.value = draft.title;
|
||||
} else if (aiTopicEl) {
|
||||
aiTopicEl.value = topic;
|
||||
}
|
||||
if (draft.description) descEl.value = draft.description;
|
||||
questionsEl.innerHTML = '';
|
||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
|
||||
const hintsEl = document.getElementById('test-hints-enabled');
|
||||
const modeEl = document.querySelector('input[name="result-mode"]:checked');
|
||||
if (hintsEl && hintsEl.checked && modeEl && modeEl.value === 'immediate') {
|
||||
aiStatusEl.textContent = 'Сохраняем черновик…';
|
||||
showAiToast('Сохраняем черновик перед подсказками…', 'info', 0);
|
||||
try {
|
||||
await saveCurrentDraftQuietly();
|
||||
aiStatusEl.textContent = 'Генерируем вопросы… затем подсказки…';
|
||||
const hr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
|
||||
const hd = await hr.json().catch(() => ({}));
|
||||
if (hr.ok) {
|
||||
try {
|
||||
await refreshHintsInForm();
|
||||
} catch (_) {
|
||||
// Не блокируем успех генерации вопросов.
|
||||
}
|
||||
const skipped = Number(hd.skipped || 0);
|
||||
aiStatusEl.textContent = skipped
|
||||
? `Готово: вопросы + подсказки (${hd.generated}, пропущено ${skipped}).`
|
||||
: `Готово: вопросы + подсказки (${hd.generated}).`;
|
||||
const hs = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`).then((x) => x.json());
|
||||
const miss = Number(hs.missing || 0);
|
||||
if (miss > 0) {
|
||||
await runHintsProgressFromServer(miss);
|
||||
}
|
||||
try {
|
||||
await refreshHintsInForm();
|
||||
} catch (_) {
|
||||
/* не блокируем успех */
|
||||
}
|
||||
showAiToast(`Готово: ${draft.questions?.length || 0} вопросов и подсказки`, 'ok');
|
||||
} catch (_) {
|
||||
// Оставляем базовый статус готовности вопросов.
|
||||
showAiToast(
|
||||
`Вопросы готовы (${draft.questions?.length || 0}); подсказки не созданы`,
|
||||
'err',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showAiToast(`Готово: ${draft.questions?.length || 0} вопросов`, 'ok');
|
||||
}
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
alert(e.message || 'AI: ошибка.');
|
||||
showAiToast('', 'info');
|
||||
const msg = e.message || 'AI: ошибка.';
|
||||
showAiToast(msg, 'err', 7000);
|
||||
alert(msg);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -804,6 +907,8 @@
|
||||
|
||||
let _extractedText = '';
|
||||
let _extractedFileName = '';
|
||||
/** Имена всех загруженных файлов (несколько выборов из разных папок склеиваются). */
|
||||
let _importFileNames = [];
|
||||
|
||||
/** HTML карточки вопроса в модалке предпросмотра импорта (как в разборе: текст, подсказка, все варианты). */
|
||||
function buildImportPreviewQuestionHtml(q, index) {
|
||||
@@ -858,31 +963,110 @@
|
||||
importModal.showModal();
|
||||
}
|
||||
|
||||
// Фаза 1: выбрать файл → извлечь текст, обновить метку дропзоны
|
||||
async function handleImportFile(file) {
|
||||
if (!file) return;
|
||||
aiStatusEl.textContent = `Загружаем «${file.name}»…`;
|
||||
importDropzone.classList.add('import-dropzone--loading');
|
||||
function clearImportState() {
|
||||
_extractedText = '';
|
||||
_extractedFileName = '';
|
||||
_importFileNames = [];
|
||||
if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файлы сюда или нажмите';
|
||||
importDropzone?.classList.remove('import-dropzone--done', 'import-dropzone--loading');
|
||||
if (docUserHint) docUserHint.value = '';
|
||||
}
|
||||
|
||||
if (aiImportClearBtn) {
|
||||
aiImportClearBtn.addEventListener('click', () => {
|
||||
clearImportState();
|
||||
showAiToast('Загрузка сброшена.', 'info');
|
||||
});
|
||||
}
|
||||
|
||||
// Фаза 1: выбрать файл(ы) → извлечь текст, обновить метку дропзоны
|
||||
async function handleImportFiles(fileList) {
|
||||
const files = Array.from(fileList || []).filter(Boolean);
|
||||
if (!files.length) return;
|
||||
if (files.length > 5) {
|
||||
showAiToast('Не более 5 файлов за раз.', 'err');
|
||||
return;
|
||||
}
|
||||
const allowed = ['.pdf', '.docx', '.txt', '.md'];
|
||||
for (const file of files) {
|
||||
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
|
||||
if (!allowed.includes(ext)) {
|
||||
showAiToast(`Формат «${ext}» не поддерживается.`, 'err');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const appendHint = _extractedText.trim() ? ' · добавляем к уже загруженным' : '';
|
||||
showAiToast(
|
||||
files.length > 1
|
||||
? `Загружаем ${files.length} файла…${appendHint}`
|
||||
: `Загружаем «${files[0].name}»…${appendHint}`,
|
||||
'info',
|
||||
0,
|
||||
);
|
||||
importDropzone?.classList.add('import-dropzone--loading');
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd });
|
||||
const data = await r.json();
|
||||
files.forEach((f) => fd.append('files', f));
|
||||
const r = await fetch('/api/tests/import/document', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
let data;
|
||||
const raw = await r.text();
|
||||
try {
|
||||
data = raw.trim() ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
if (r.status === 413) throw new Error('Файл слишком большой для сервера или прокси.');
|
||||
if (r.status === 401) throw new Error('Сессия истекла — войдите снова и повторите загрузку.');
|
||||
throw new Error(
|
||||
`Сервер вернул не JSON (код ${r.status}). Обновите страницу или войдите снова.`,
|
||||
);
|
||||
}
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить файл.');
|
||||
_extractedText = data.extractedText || '';
|
||||
_extractedFileName = file.name;
|
||||
aiStatusEl.textContent = `Файл загружен: «${file.name}» · ${data.textLength ?? 0} символов`;
|
||||
if (importDropzoneLabel) importDropzoneLabel.textContent = `✓ ${file.name}`;
|
||||
importDropzone.classList.add('import-dropzone--done');
|
||||
const batchText = (data.extractedText || '').trim();
|
||||
const names = Array.isArray(data.originalNames) ? data.originalNames : [data.originalName || ''];
|
||||
const batchNames = names.filter(Boolean);
|
||||
const hadExisting = !!_extractedText.trim();
|
||||
const batchLen = Number(data.textLength) || batchText.length;
|
||||
|
||||
if (batchText) {
|
||||
if (hadExisting) {
|
||||
_extractedText = `${_extractedText.trimEnd()}\n\n---\n\n${batchText}`;
|
||||
} else {
|
||||
_extractedText = batchText;
|
||||
}
|
||||
batchNames.forEach((n) => _importFileNames.push(n));
|
||||
_extractedFileName = _importFileNames.join(', ');
|
||||
if (importDropzoneLabel) {
|
||||
const n = _importFileNames.length;
|
||||
importDropzoneLabel.textContent =
|
||||
n <= 1
|
||||
? `✓ ${_importFileNames[0] || files[0]?.name || 'файл'} · можно добавить ещё`
|
||||
: `✓ ${n} файлов · можно добавить ещё`;
|
||||
}
|
||||
importDropzone?.classList.add('import-dropzone--done');
|
||||
const totalLen = _extractedText.length;
|
||||
if (hadExisting) {
|
||||
showAiToast(`Добавлено · ${batchLen} симв. · всего ${totalLen}`, 'ok');
|
||||
} else {
|
||||
showAiToast(`Загружено · ${totalLen} символов`, 'ok');
|
||||
}
|
||||
} else if (hadExisting) {
|
||||
showAiToast('Текст из этих файлов пуст — уже загруженное не меняли.', 'info', 4500);
|
||||
} else {
|
||||
_extractedFileName = '';
|
||||
showAiToast('Текст из файлов не извлечён.', 'info', 4500);
|
||||
}
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
showAiToast('', 'info');
|
||||
openImportModal(
|
||||
'Ошибка загрузки',
|
||||
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось загрузить файл.')}</p></div>`,
|
||||
[{ label: 'Закрыть', onClick: () => importModal.close() }],
|
||||
);
|
||||
} finally {
|
||||
importDropzone.classList.remove('import-dropzone--loading');
|
||||
importDropzone?.classList.remove('import-dropzone--loading');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -892,8 +1076,12 @@
|
||||
const userHint = docUserHint ? docUserHint.value.trim() : '';
|
||||
docGenerateBtn.disabled = true;
|
||||
docGenerateBtn.textContent = 'Генерируем…';
|
||||
aiStatusEl.textContent = 'Генерируем тест из документа…';
|
||||
if (docProgressEl) docProgressEl.textContent = 'Шаг 1/3: подготовка шаблона…';
|
||||
const nQDoc = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
|
||||
showAiToast(
|
||||
`Документ: шаг 1/3 · подготовка · ~${formatEtaSeconds(etaFullGenerateSeconds(nQDoc))}`,
|
||||
'info',
|
||||
0,
|
||||
);
|
||||
try {
|
||||
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
|
||||
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
|
||||
@@ -905,7 +1093,7 @@
|
||||
minCorrect: globalMulti ? globalRange.minCorrect : 1,
|
||||
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
|
||||
}));
|
||||
if (docProgressEl) docProgressEl.textContent = 'Шаг 2/3: генерация вопросов…';
|
||||
showAiToast('Документ: шаг 2/3 · генерация вопросов…', 'info', 0);
|
||||
const r = await fetch('/api/tests/generate-from-extracted', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -914,8 +1102,7 @@
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Ошибка генерации.');
|
||||
const g = data.generation || {};
|
||||
aiStatusEl.textContent = '';
|
||||
if (docProgressEl) docProgressEl.textContent = 'Шаг 3/3: подготовка к применению…';
|
||||
showAiToast('Документ: шаг 3/3 · готово к предпросмотру', 'ok', 3200);
|
||||
|
||||
if (!g.available) {
|
||||
openImportModal(
|
||||
@@ -953,22 +1140,15 @@
|
||||
qs.forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
aiStatusEl.textContent = `Применено: ${qs.length} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
// Сброс зоны загрузки
|
||||
_extractedText = '';
|
||||
_extractedFileName = '';
|
||||
if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файл сюда или нажмите';
|
||||
importDropzone.classList.remove('import-dropzone--done');
|
||||
if (docUserHint) docUserHint.value = '';
|
||||
aiStatusEl.textContent = '';
|
||||
showAiToast(`Применено: ${qs.length} вопросов`, 'ok');
|
||||
clearImportState();
|
||||
},
|
||||
},
|
||||
{ label: 'Отмена', onClick: () => importModal.close() },
|
||||
],
|
||||
);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
showAiToast(e.message || 'Ошибка генерации', 'err', 7000);
|
||||
openImportModal(
|
||||
'Ошибка генерации',
|
||||
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось сгенерировать тест.')}</p></div>`,
|
||||
@@ -979,7 +1159,6 @@
|
||||
docGenerateBtn.disabled = false;
|
||||
docGenerateBtn.innerHTML = '<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span> Сгенерировать из документа';
|
||||
}
|
||||
if (docProgressEl) setTimeout(() => { docProgressEl.textContent = ''; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,9 +1169,9 @@
|
||||
if (fileInput) {
|
||||
const onchange = async (ev) => {
|
||||
fileInput.removeEventListener('change', onchange);
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
const picked = ev.target.files ? Array.from(ev.target.files) : [];
|
||||
ev.target.value = '';
|
||||
await handleImportFile(f);
|
||||
await handleImportFiles(picked);
|
||||
if (_extractedText) handleGenerateFromDoc();
|
||||
};
|
||||
fileInput.addEventListener('change', onchange);
|
||||
@@ -1004,9 +1183,9 @@
|
||||
});
|
||||
|
||||
$('#ai-import-file').addEventListener('change', (ev) => {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
const picked = ev.target.files ? Array.from(ev.target.files) : [];
|
||||
ev.target.value = '';
|
||||
handleImportFile(file);
|
||||
handleImportFiles(picked);
|
||||
});
|
||||
|
||||
// Drag-and-drop на зону загрузки
|
||||
@@ -1027,33 +1206,42 @@
|
||||
importDropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
importDropzone.classList.remove('import-dropzone--over');
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
const allowed = ['.pdf', '.docx', '.txt', '.md'];
|
||||
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
|
||||
if (!allowed.includes(ext)) {
|
||||
aiStatusEl.textContent = `Формат «${ext}» не поддерживается.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 3000);
|
||||
const dropped = Array.from(e.dataTransfer?.files || []);
|
||||
const batch = dropped.filter((f) => {
|
||||
const ext = ('.' + f.name.split('.').pop()).toLowerCase();
|
||||
return allowed.includes(ext);
|
||||
}).slice(0, 5);
|
||||
if (!batch.length) {
|
||||
if (dropped.length) {
|
||||
showAiToast('Поддерживаются только PDF, DOCX, TXT, MD.', 'err');
|
||||
}
|
||||
return;
|
||||
}
|
||||
handleImportFile(file);
|
||||
handleImportFiles(batch);
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-and-drop на всю страницу (когда перетаскивают извне браузера)
|
||||
document.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||
document.addEventListener('drop', (e) => {
|
||||
if (importDropzone && importDropzone.contains(e.target)) return; // уже обработано
|
||||
if (importDropzone && importDropzone.contains(e.target)) return;
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
const allowed = ['.pdf', '.docx', '.txt', '.md'];
|
||||
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
|
||||
if (!allowed.includes(ext)) return;
|
||||
// Подсвечиваем зону и обрабатываем
|
||||
const dropped = Array.from(e.dataTransfer?.files || []);
|
||||
const batch = dropped.filter((f) => {
|
||||
const ext = ('.' + f.name.split('.').pop()).toLowerCase();
|
||||
return allowed.includes(ext);
|
||||
}).slice(0, 5);
|
||||
if (!batch.length) {
|
||||
if (dropped.length) {
|
||||
showAiToast('Поддерживаются только PDF, DOCX, TXT, MD.', 'err');
|
||||
}
|
||||
return;
|
||||
}
|
||||
importDropzone?.classList.add('import-dropzone--over');
|
||||
setTimeout(() => importDropzone?.classList.remove('import-dropzone--over'), 600);
|
||||
handleImportFile(file);
|
||||
handleImportFiles(batch);
|
||||
});
|
||||
|
||||
// ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
|
||||
@@ -1120,7 +1308,11 @@
|
||||
const nORaw = prompt('Сколько вариантов в каждом вопросе?', '4');
|
||||
if (nORaw == null) return;
|
||||
const nO = Math.max(2, Math.min(12, parseInt(nORaw, 10) || 4));
|
||||
aiStatusEl.textContent = 'Генерируем по названию…';
|
||||
showAiToast(
|
||||
`Генерация по названию · ~${formatEtaSeconds(etaFullGenerateSeconds(nQ))}`,
|
||||
'info',
|
||||
0,
|
||||
);
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-by-title`, {
|
||||
method: 'POST',
|
||||
@@ -1134,16 +1326,17 @@
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
aiStatusEl.textContent = '';
|
||||
showAiToast('', 'info');
|
||||
return aiAlert(data);
|
||||
}
|
||||
const draft = data.draft;
|
||||
showAiToast('Черновик готов — подтвердите в диалоге', 'ok');
|
||||
const ok = confirm(
|
||||
`Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` +
|
||||
'Применить как черновик? Текущие вопросы будут заменены.',
|
||||
);
|
||||
if (!ok) {
|
||||
aiStatusEl.textContent = '';
|
||||
showAiToast('', 'info');
|
||||
return;
|
||||
}
|
||||
if (draft.title) titleEl.value = draft.title;
|
||||
@@ -1152,10 +1345,9 @@
|
||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
showAiToast(`Применено: ${draft.questions.length} вопросов`, 'ok');
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
showAiToast('', 'info');
|
||||
aiAlert(null, e.message);
|
||||
}
|
||||
});
|
||||
@@ -1167,7 +1359,7 @@
|
||||
alert('В тесте нет вопросов — нечего проверять.');
|
||||
return;
|
||||
}
|
||||
aiStatusEl.textContent = 'Анализируем…';
|
||||
showAiToast('Проверка теста…', 'info', 0);
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/check`, {
|
||||
method: 'POST',
|
||||
@@ -1179,8 +1371,9 @@
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
aiStatusEl.textContent = '';
|
||||
showAiToast('', 'info');
|
||||
if (!r.ok) return aiAlert(data);
|
||||
showAiToast('Проверка готова — см. окно', 'ok');
|
||||
const rev = data.review || {};
|
||||
const verdict = rev.verdict || 'warn';
|
||||
const verdictMap = {
|
||||
@@ -1208,7 +1401,7 @@
|
||||
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm' },
|
||||
]);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
showAiToast('', 'info');
|
||||
aiAlert(null, e.message);
|
||||
}
|
||||
});
|
||||
@@ -1220,7 +1413,7 @@
|
||||
alert('В тесте нет вопросов — нечего улучшать.');
|
||||
return;
|
||||
}
|
||||
aiStatusEl.textContent = 'Улучшаем…';
|
||||
showAiToast('Подготовка улучшений…', 'info', 0);
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve`, {
|
||||
method: 'POST',
|
||||
@@ -1229,11 +1422,13 @@
|
||||
testTitle: titleEl.value,
|
||||
testDescription: descEl.value,
|
||||
questions: payload.questions,
|
||||
focus: (aiImproveFocusEl && aiImproveFocusEl.value) || 'all',
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
aiStatusEl.textContent = '';
|
||||
showAiToast('', 'info');
|
||||
if (!r.ok) return aiAlert(data);
|
||||
showAiToast('Выберите в окне, что применить', 'ok');
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
openModal('Улучшение теста', '<p>Нечего улучшать.</p>', [
|
||||
@@ -1297,21 +1492,71 @@
|
||||
$('.q-multi', node).checked = !!it.suggested.hasMultipleAnswers;
|
||||
const optsEl = $('.q-options', node);
|
||||
optsEl.innerHTML = '';
|
||||
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o, node)));
|
||||
});
|
||||
$$('#questions .q-item').forEach((node) => {
|
||||
if (!node.classList.contains('q-removed')) syncOptionInputTypes(node);
|
||||
});
|
||||
renumber();
|
||||
modal.close();
|
||||
scheduleDirtyCheck();
|
||||
aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.';
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 5000);
|
||||
showAiToast('Изменения применены — сохраните тест', 'ok');
|
||||
},
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
showAiToast('', 'info');
|
||||
aiAlert(null, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function improveSingleOption(qNode, optIndex) {
|
||||
const rows = $$('.opt-item', qNode);
|
||||
const row = rows[optIndex];
|
||||
if (!row) return;
|
||||
const overlay = $('.opt-ai-overlay', row);
|
||||
const ta = $('.opt-text', row);
|
||||
const aiBtn = $('.opt-ai', row);
|
||||
|
||||
const setLocalBusy = (on) => {
|
||||
if (overlay) overlay.classList.toggle('hidden', !on);
|
||||
if (ta) ta.toggleAttribute('readonly', !!on);
|
||||
if (aiBtn) aiBtn.disabled = !!on;
|
||||
row.classList.toggle('opt-item--ai-busy', !!on);
|
||||
};
|
||||
|
||||
setLocalBusy(true);
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve-option`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testTitle: titleEl.value,
|
||||
testDescription: descEl.value,
|
||||
questionText: $('.q-text', qNode).value,
|
||||
optionIndex: optIndex,
|
||||
options: rows.map((op) => ({
|
||||
text: $('.opt-text', op).value.trim(),
|
||||
isCorrect: $('.opt-correct', op).checked,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
setLocalBusy(false);
|
||||
return aiAlert(data);
|
||||
}
|
||||
const t = $('.opt-text', row);
|
||||
t.value = (data.text != null ? data.text : t.value) || '';
|
||||
autoResize(t);
|
||||
scheduleDirtyCheck();
|
||||
setLocalBusy(false);
|
||||
} catch (e) {
|
||||
setLocalBusy(false);
|
||||
aiAlert(null, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Перемешивает строки вариантов в DOM; чекбоксы «верный» остаются при своих полях. */
|
||||
function shuffleQuestionOptionsDom(qNode) {
|
||||
const optsEl = $('.q-options', qNode);
|
||||
@@ -1325,6 +1570,44 @@
|
||||
rows.forEach((el) => optsEl.appendChild(el));
|
||||
}
|
||||
|
||||
function pickQuestionAiMode(canDistractors) {
|
||||
const dlg = document.getElementById('dlg-q-ai-mode');
|
||||
const distBtn = document.getElementById('q-ai-mode-distractors');
|
||||
const qBtn = document.getElementById('q-ai-mode-question');
|
||||
const optBtn = document.getElementById('q-ai-mode-options');
|
||||
const cancelBtn = document.getElementById('q-ai-mode-cancel');
|
||||
if (!dlg || typeof dlg.showModal !== 'function') {
|
||||
return Promise.resolve('rephrase');
|
||||
}
|
||||
if (distBtn) {
|
||||
distBtn.disabled = !canDistractors;
|
||||
distBtn.style.opacity = canDistractors ? '' : '0.45';
|
||||
distBtn.title = canDistractors ? '' : 'Нет пустых полей для дистракторов';
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const safeResolve = (val) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(val);
|
||||
};
|
||||
const onClose = () => {
|
||||
safeResolve(null);
|
||||
};
|
||||
dlg.addEventListener('close', onClose, { once: true });
|
||||
const pick = (mode) => () => {
|
||||
dlg.removeEventListener('close', onClose);
|
||||
safeResolve(mode);
|
||||
dlg.close();
|
||||
};
|
||||
distBtn?.addEventListener('click', pick('distractors'), { once: true });
|
||||
qBtn?.addEventListener('click', pick('rephrase'), { once: true });
|
||||
optBtn?.addEventListener('click', pick('improve_options'), { once: true });
|
||||
cancelBtn?.addEventListener('click', pick(null), { once: true });
|
||||
dlg.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
async function aiGenerateQuestion(node) {
|
||||
const qTextEl = $('.q-text', node);
|
||||
const qText = qTextEl.value.trim();
|
||||
@@ -1333,32 +1616,25 @@
|
||||
const multi = $('.q-multi', node).checked;
|
||||
const overlay = $('.q-ai-overlay', node);
|
||||
|
||||
// Показываем оверлей
|
||||
overlay?.classList.remove('hidden');
|
||||
node.style.pointerEvents = 'none';
|
||||
|
||||
try {
|
||||
// Собираем варианты с их состоянием
|
||||
const existingOptions = existingOpts.map((op) => ({
|
||||
text: $('.opt-text', op).value.trim(),
|
||||
isCorrect: $('.opt-correct', op).checked,
|
||||
}));
|
||||
const emptySlots = existingOptions.filter((o) => !o.text).length;
|
||||
const filledSlots = existingOptions.filter((o) => o.text).length;
|
||||
|
||||
// Выбираем режим:
|
||||
// - нет текста вопроса → full
|
||||
// - есть вопрос + есть пустые варианты (и хоть один заполнен) → distractors
|
||||
// - есть вопрос, все варианты заполнены или вариантов нет → rephrase
|
||||
let requestMode;
|
||||
if (!qText) {
|
||||
requestMode = 'full';
|
||||
} else if (emptySlots > 0 && filledSlots > 0) {
|
||||
requestMode = 'distractors';
|
||||
} else {
|
||||
requestMode = 'rephrase';
|
||||
const choice = await pickQuestionAiMode(emptySlots > 0);
|
||||
if (!choice) return;
|
||||
requestMode = choice;
|
||||
}
|
||||
|
||||
overlay?.classList.remove('hidden');
|
||||
node.style.pointerEvents = 'none';
|
||||
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1375,20 +1651,17 @@
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
||||
|
||||
// Обновляем текст вопроса (кроме режима дистракторов — текст не меняем)
|
||||
if (data.mode !== 'distractors') {
|
||||
if (data.mode === 'rephrase' || data.mode === 'full') {
|
||||
qTextEl.value = data.text || qText;
|
||||
autoResize(qTextEl);
|
||||
}
|
||||
|
||||
const optsEl = $('.q-options', node);
|
||||
if (data.mode === 'full' && Array.isArray(data.options) && data.options.length) {
|
||||
// Полная замена вариантов
|
||||
optsEl.innerHTML = '';
|
||||
data.options.forEach((o) => optsEl.appendChild(renderOption(o, node)));
|
||||
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
|
||||
} else if (data.mode === 'distractors' && Array.isArray(data.options) && data.options.length) {
|
||||
// Заполняем только пустые слоты
|
||||
let dIdx = 0;
|
||||
existingOpts.forEach((op) => {
|
||||
const t = $('.opt-text', op);
|
||||
@@ -1399,6 +1672,15 @@
|
||||
}
|
||||
});
|
||||
shuffleQuestionOptionsDom(node);
|
||||
} else if (data.mode === 'improve_options' && Array.isArray(data.options) && data.options.length) {
|
||||
const rows = $$('.opt-item', node);
|
||||
data.options.forEach((o, i) => {
|
||||
if (!rows[i]) return;
|
||||
const ta = $('.opt-text', rows[i]);
|
||||
ta.value = o.text != null ? o.text : ta.value;
|
||||
$('.opt-correct', rows[i]).checked = !!o.isCorrect;
|
||||
autoResize(ta);
|
||||
});
|
||||
}
|
||||
|
||||
syncOptionInputTypes(node);
|
||||
@@ -1406,14 +1688,40 @@
|
||||
updateAiButtonLabel(node);
|
||||
scheduleDirtyCheck();
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
alert(e.message || 'AI: ошибка.');
|
||||
const msg = e.message || 'AI: ошибка.';
|
||||
showAiToast(msg, 'err', 7000);
|
||||
alert(msg);
|
||||
} finally {
|
||||
overlay?.classList.add('hidden');
|
||||
node.style.pointerEvents = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (addQuestionAiBtn) {
|
||||
addQuestionAiBtn.addEventListener('click', async () => {
|
||||
const node = renderQuestion({
|
||||
text: '',
|
||||
hasMultipleAnswers: false,
|
||||
options: [
|
||||
{ text: '', isCorrect: true },
|
||||
{ text: '', isCorrect: false },
|
||||
{ text: '', isCorrect: false },
|
||||
{ text: '', isCorrect: false },
|
||||
],
|
||||
});
|
||||
questionsEl.appendChild(node);
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
try {
|
||||
node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} catch (_) {
|
||||
/* ok */
|
||||
}
|
||||
showAiToast('Добавлен блок — запускаем ИИ для одного вопроса…', 'info');
|
||||
await aiGenerateQuestion(node);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── chain active state (грузим summary, чтобы знать стартовое значение) ───
|
||||
|
||||
function updateChainActiveDisplay(active) {
|
||||
|
||||
Reference in New Issue
Block a user