@ -34,9 +34,6 @@
const templateGlobalMultiEl = $ ( '#template-global-multi' ) ;
const templateGlobalMultiEl = $ ( '#template-global-multi' ) ;
const templateMinCorrectEl = $ ( '#template-min-correct' ) ;
const templateMinCorrectEl = $ ( '#template-min-correct' ) ;
const templateMaxCorrectEl = $ ( '#template-max-correct' ) ;
const templateMaxCorrectEl = $ ( '#template-max-correct' ) ;
const hintsStatusEl = $ ( '#hints-status' ) ;
const hintsActionsEl = $ ( '#test-hints-actions' ) ;
const generateHintsBtn = $ ( '#btn-generate-hints' ) ;
const docProgressEl = $ ( '#doc-progress' ) ;
const docProgressEl = $ ( '#doc-progress' ) ;
const introUpdatedEl = $ ( '#intro-updated' ) ;
const introUpdatedEl = $ ( '#intro-updated' ) ;
const introForkBannerEl = $ ( '#intro-fork-banner' ) ;
const introForkBannerEl = $ ( '#intro-fork-banner' ) ;
@ -365,14 +362,36 @@
m . textContent = v ;
m . textContent = v ;
}
}
function syncEditorHeroExtra ( ) {
const timeVal = document . getElementById ( 'editor-hero-time-val' ) ;
const resVal = document . getElementById ( 'editor-hero-result-val' ) ;
const hintsVal = document . getElementById ( 'editor-hero-hints-val' ) ;
if ( ! timeVal || ! resVal || ! hintsVal ) return ;
const tlEl = document . getElementById ( 'test-time-limit' ) ;
const raw = tlEl && tlEl . value !== '' ? Number ( tlEl . value ) : 0 ;
timeVal . textContent = ( ! raw || raw <= 0 ) ? 'без ограничения' : ` ${ raw } мин ` ;
const mode = document . querySelector ( 'input[name="result-mode"]:checked' ) ;
const imm = mode && mode . value === 'immediate' ;
resVal . textContent = imm ? 'сразу' : 'в конце' ;
const hintsCheckbox = document . getElementById ( 'test-hints-enabled' ) ;
if ( ! imm ) hintsVal . textContent = 'недоступны' ;
else if ( hintsCheckbox && hintsCheckbox . checked ) hintsVal . textContent = 'вкл' ;
else hintsVal . textContent = 'выкл' ;
}
function syncHeroMetaRow ( ) {
syncThresholdMirror ( ) ;
syncEditorHeroExtra ( ) ;
}
function loadInitial ( ) {
function loadInitial ( ) {
titleEl . value = initial . test . title || '' ;
titleEl . value = initial . test . title || '' ;
descEl . value = initial . test . description || '' ;
descEl . value = initial . test . description || '' ;
autoResize ( titleEl ) ;
autoResize ( titleEl ) ;
autoResize ( descEl ) ;
autoResize ( descEl ) ;
if ( thresholdEl ) {
if ( thresholdEl ) {
thresholdEl . addEventListener ( 'input' , syncThresholdMirror ) ;
thresholdEl . addEventListener ( 'input' , syncHeroMetaRow ) ;
thresholdEl . addEventListener ( 'change' , syncThresholdMirror ) ;
thresholdEl . addEventListener ( 'change' , syncHeroMetaRow ) ;
}
}
if ( titleEl && titleEl . tagName === 'TEXTAREA' ) {
if ( titleEl && titleEl . tagName === 'TEXTAREA' ) {
titleEl . addEventListener ( 'input' , ( ) => {
titleEl . addEventListener ( 'input' , ( ) => {
@ -396,7 +415,7 @@
if ( docUserHint ) docUserHint . addEventListener ( 'input' , ( ) => autoResize ( docUserHint ) ) ;
if ( docUserHint ) docUserHint . addEventListener ( 'input' , ( ) => autoResize ( docUserHint ) ) ;
thresholdEl . value =
thresholdEl . value =
initial . test . passingThreshold == null ? '' : Number ( initial . test . passingThreshold ) ;
initial . test . passingThreshold == null ? '' : Number ( initial . test . passingThreshold ) ;
syncThresholdMirror ( ) ;
syncHeroMetaRow ( ) ;
const timeLimitEl = document . getElementById ( 'test-time-limit' ) ;
const timeLimitEl = document . getElementById ( 'test-time-limit' ) ;
const hintsEl = document . getElementById ( 'test-hints-enabled' ) ;
const hintsEl = document . getElementById ( 'test-hints-enabled' ) ;
@ -405,7 +424,10 @@
if ( timeLimitEl ) {
if ( timeLimitEl ) {
timeLimitEl . value = initial . test . timeLimit == null ? '' : Number ( initial . test . timeLimit ) ;
timeLimitEl . value = initial . test . timeLimit == null ? '' : Number ( initial . test . timeLimit ) ;
timeLimitEl . addEventListener ( 'input' , scheduleDirtyCheck ) ;
timeLimitEl . addEventListener ( 'input' , ( ) => {
scheduleDirtyCheck ( ) ;
syncEditorHeroExtra ( ) ;
} ) ;
}
}
const initMode = ( initial . test . resultMode === 'immediate' ) ? 'immediate' : 'end' ;
const initMode = ( initial . test . resultMode === 'immediate' ) ? 'immediate' : 'end' ;
resultModeRadios . forEach ( ( r ) => {
resultModeRadios . forEach ( ( r ) => {
@ -414,8 +436,8 @@
const mode = document . querySelector ( 'input[name="result-mode"]:checked' ) ;
const mode = document . querySelector ( 'input[name="result-mode"]:checked' ) ;
const isImmediate = mode && mode . value === 'immediate' ;
const isImmediate = mode && mode . value === 'immediate' ;
if ( hintsRow ) hintsRow . style . display = isImmediate ? '' : 'none' ;
if ( hintsRow ) hintsRow . style . display = isImmediate ? '' : 'none' ;
if ( hintsActionsEl ) hintsActionsEl . style . display = ( isImmediate && hintsEl && hintsEl . checked ) ? '' : 'none' ;
if ( hintsEl && ! isImmediate ) hintsEl . checked = false ;
if ( hintsEl && ! isImmediate ) hintsEl . checked = false ;
syncHeroMetaRow ( ) ;
scheduleDirtyCheck ( ) ;
scheduleDirtyCheck ( ) ;
} ) ;
} ) ;
} ) ;
} ) ;
@ -424,12 +446,11 @@
hintsEl . addEventListener ( 'change' , ( ) => {
hintsEl . addEventListener ( 'change' , ( ) => {
const mode = document . querySelector ( 'input[name="result-mode"]:checked' ) ;
const mode = document . querySelector ( 'input[name="result-mode"]:checked' ) ;
const isImmediate = mode && mode . value === 'immediate' ;
const isImmediate = mode && mode . value === 'immediate' ;
if ( hintsActionsEl ) hintsActionsEl . style . display = ( isImmediate && hintsEl . checked ) ? '' : 'none' ;
syncHeroMetaRow ( ) ;
scheduleDirtyCheck ( ) ;
scheduleDirtyCheck ( ) ;
} ) ;
} ) ;
}
}
if ( hintsRow ) hintsRow . style . display = ( initMode === 'immediate' ) ? '' : 'none' ;
if ( hintsRow ) hintsRow . style . display = ( initMode === 'immediate' ) ? '' : 'none' ;
if ( hintsActionsEl ) hintsActionsEl . style . display = ( initMode === 'immediate' && hintsEl && hintsEl . checked ) ? '' : 'none' ;
questionsEl . innerHTML = '' ;
questionsEl . innerHTML = '' ;
( initial . questions || [ ] ) . forEach ( ( q ) => questionsEl . appendChild ( renderQuestion ( q ) ) ) ;
( initial . questions || [ ] ) . forEach ( ( q ) => questionsEl . appendChild ( renderQuestion ( q ) ) ) ;
@ -519,6 +540,60 @@
return data ;
return data ;
}
}
function applyServerTestState ( test ) {
if ( ! test || typeof test !== 'object' ) return ;
if ( typeof test . title === 'string' && titleEl ) titleEl . value = test . title ;
if ( typeof test . description === 'string' && descEl ) descEl . value = test . description ;
if ( thresholdEl && test . passingThreshold != null ) {
thresholdEl . value = Number ( test . passingThreshold ) ;
}
const timeLimitEl = document . getElementById ( 'test-time-limit' ) ;
if ( timeLimitEl ) timeLimitEl . value = test . timeLimit == null ? '' : Number ( test . timeLimit ) ;
const hintsEl = document . getElementById ( 'test-hints-enabled' ) ;
const hintsRow = document . getElementById ( 'test-hints-row' ) ;
const mode = ( test . resultMode === 'immediate' ) ? 'immediate' : 'end' ;
const modeEl = document . querySelector ( ` input[name="result-mode"][value=" ${ mode } "] ` ) ;
if ( modeEl ) modeEl . checked = true ;
if ( hintsRow ) hintsRow . style . display = ( mode === 'immediate' ) ? '' : 'none' ;
if ( hintsEl ) {
hintsEl . checked = ! ! test . hintsEnabled && mode === 'immediate' ;
}
autoResize ( titleEl ) ;
autoResize ( descEl ) ;
syncHeroMetaRow ( ) ;
}
async function refreshMetaAfterSave ( ) {
const [ v , s , e ] = await Promise . all ( [
fetch ( ` /api/tests/ ${ TEST _ID } /versions ` ) . then ( ( r ) => r . json ( ) ) . catch ( ( ) => null ) ,
fetch ( ` /api/tests/ ${ TEST _ID } /summary ` ) . then ( ( r ) => r . json ( ) ) . catch ( ( ) => null ) ,
fetch ( ` /api/tests/ ${ TEST _ID } /editor ` ) . then ( ( r ) => r . json ( ) ) . catch ( ( ) => null ) ,
] ) ;
if ( v && Array . isArray ( v . versions ) ) {
renderVersions ( v . versions ) ;
hasForkRisk = hasForkRisk || ( v . versions . length > 1 ) ;
if ( typeof v . hasAttempts === 'boolean' ) {
hasAnyAttempts = hasAnyAttempts || v . hasAttempts ;
hasForkRisk = hasForkRisk || v . hasAttempts ;
}
}
if ( s && s . test ) {
if ( introUpdatedEl ) introUpdatedEl . textContent = fmtDt ( s . test . updated _at || s . test . updatedAt ) ;
const versionEl = document . getElementById ( 'intro-version' ) ;
if ( versionEl && s . test . version != null ) versionEl . textContent = s . test . version ;
if ( typeof s . test . hasAttempts === 'boolean' ) {
hasAnyAttempts = hasAnyAttempts || s . test . hasAttempts ;
hasForkRisk = hasForkRisk || s . test . hasAttempts ;
}
}
if ( e && e . test ) applyServerTestState ( e . test ) ;
updateForkBanner ( ) ;
}
async function refreshHintsInForm ( ) {
async function refreshHintsInForm ( ) {
const r = await fetch ( ` /api/tests/ ${ TEST _ID } /editor ` ) ;
const r = await fetch ( ` /api/tests/ ${ TEST _ID } /editor ` ) ;
const data = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
const data = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
@ -572,6 +647,7 @@
} ) ;
} ) ;
if ( r2 . ok ) chainActive = chainActiveEl . checked ;
if ( r2 . ok ) chainActive = chainActiveEl . checked ;
}
}
await refreshMetaAfterSave ( ) ;
resetBaselineDraft ( ) ;
resetBaselineDraft ( ) ;
const msg = data . forked
const msg = data . forked
? 'Сохранено. Создана новая версия — у теста есть попытки прохождения.'
? 'Сохранено. Создана новая версия — у теста есть попытки прохождения.'
@ -587,13 +663,18 @@
const sr = await fetch ( ` /api/tests/ ${ TEST _ID } /ai/hints/status ` ) ;
const sr = await fetch ( ` /api/tests/ ${ TEST _ID } /ai/hints/status ` ) ;
const st = await sr . json ( ) . catch ( ( ) => ( { } ) ) ;
const st = await sr . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( sr . ok && Number ( st . missing ) > 0 ) {
if ( sr . ok && Number ( st . missing ) > 0 ) {
const okGen = confirm (
` Подсказок пока нет или заполнены не все: не хватает ${ st . missing } из ${ st . total } . \n \n `
+ 'Сгенерировать недостающие подсказки через ИИ?' ,
) ;
if ( okGen ) {
saveStatusEl . textContent = ` Создаём ИИ-подсказки ( ${ st . missing } из ${ st . total } )… ` ;
saveStatusEl . textContent = ` Создаём ИИ-подсказки ( ${ st . missing } из ${ st . total } )… ` ;
const gr = await fetch ( ` /api/tests/ ${ TEST _ID } /ai/hints/generate ` , { method : 'POST' } ) ;
const gr = await fetch ( ` /api/tests/ ${ TEST _ID } /ai/hints/generate ` , { method : 'POST' } ) ;
const gd = await gr . json ( ) . catch ( ( ) => ( { } ) ) ;
const gd = await gr . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! gr . ok ) {
if ( ! gr . ok ) {
saveStatusEl . textContent = '' ;
saveStatusEl . textContent = '' ;
alert ( gd . error || 'Не удалось сгенерировать подсказки.' ) ;
alert ( gd . error || 'Не удалось сгенерировать подсказки.' ) ;
if ( saveMsg ) saveMsg . textContent = msg + ' (часть подсказок не создана )' ;
if ( saveMsg ) saveMsg . textContent = msg + ' (подсказки не созданы )' ;
if ( saveModal ) saveModal . showModal ( ) ;
if ( saveModal ) saveModal . showModal ( ) ;
return ;
return ;
}
}
@ -602,8 +683,16 @@
? ` Подсказки: ${ gd . generated } создано, ${ gd . failed } не удалось ${ skipped ? ` , пропущено ${ skipped } ` : '' } . `
? ` Подсказки: ${ gd . generated } создано, ${ gd . failed } не удалось ${ skipped ? ` , пропущено ${ skipped } ` : '' } . `
: ` Подсказки созданы ( ${ gd . generated } ) ${ skipped ? ` , пропущено ${ skipped } ` : '' } . ` ;
: ` Подсказки созданы ( ${ gd . generated } ) ${ skipped ? ` , пропущено ${ skipped } ` : '' } . ` ;
if ( saveMsg ) saveMsg . textContent = msg + tail ;
if ( saveMsg ) saveMsg . textContent = msg + tail ;
} else {
try {
if ( saveMsg ) saveMsg . textContent = msg ;
await refreshHintsInForm ( ) ;
} catch ( _ ) {
/* не блокируем успех сохранения */
}
} else if ( saveMsg ) {
saveMsg . textContent = msg ;
}
} else if ( saveMsg ) {
saveMsg . textContent = msg ;
}
}
} catch ( err ) {
} catch ( err ) {
if ( saveMsg ) saveMsg . textContent = msg + ' (ИИ-подсказки не созданы)' ;
if ( saveMsg ) saveMsg . textContent = msg + ' (ИИ-подсказки не созданы)' ;
@ -972,18 +1061,6 @@
const cancelBtn = document . getElementById ( 'btn-cancel' ) ;
const cancelBtn = document . getElementById ( 'btn-cancel' ) ;
if ( cancelBtn ) cancelBtn . addEventListener ( 'click' , doCancel ) ;
if ( cancelBtn ) cancelBtn . addEventListener ( 'click' , doCancel ) ;
const cancelBtnInline = document . getElementById ( 'btn-cancel-inline' ) ;
if ( cancelBtnInline ) cancelBtnInline . addEventListener ( 'click' , doCancel ) ;
// Кнопка «Сохранить» под вопросами — дублирует основную
const saveDraftInlineBtn = document . getElementById ( 'save-draft-inline' ) ;
const saveStatusInlineEl = document . getElementById ( 'save-status-inline' ) ;
if ( saveDraftInlineBtn ) {
saveDraftInlineBtn . addEventListener ( 'click' , ( ) => {
document . getElementById ( 'save-draft' ) ? . click ( ) ;
} ) ;
}
function openModal ( title , bodyHtml , actions ) {
function openModal ( title , bodyHtml , actions ) {
modalTitle . textContent = title ;
modalTitle . textContent = title ;
modalBody . innerHTML = bodyHtml ;
modalBody . innerHTML = bodyHtml ;
@ -1205,6 +1282,19 @@
}
}
} ) ;
} ) ;
/** Перемешивает строки вариантов в DOM; чекбоксы «верный» остаются при своих полях. */
function shuffleQuestionOptionsDom ( qNode ) {
const optsEl = $ ( '.q-options' , qNode ) ;
if ( ! optsEl ) return ;
const rows = Array . from ( optsEl . querySelectorAll ( '.opt-item' ) ) ;
if ( rows . length < 2 ) return ;
for ( let i = rows . length - 1 ; i > 0 ; i -= 1 ) {
const j = Math . floor ( Math . random ( ) * ( i + 1 ) ) ;
[ rows [ i ] , rows [ j ] ] = [ rows [ j ] , rows [ i ] ] ;
}
rows . forEach ( ( el ) => optsEl . appendChild ( el ) ) ;
}
async function aiGenerateQuestion ( node ) {
async function aiGenerateQuestion ( node ) {
const qTextEl = $ ( '.q-text' , node ) ;
const qTextEl = $ ( '.q-text' , node ) ;
const qText = qTextEl . value . trim ( ) ;
const qText = qTextEl . value . trim ( ) ;
@ -1278,6 +1368,7 @@
dIdx ++ ;
dIdx ++ ;
}
}
} ) ;
} ) ;
shuffleQuestionOptionsDom ( node ) ;
}
}
syncOptionInputTypes ( node ) ;
syncOptionInputTypes ( node ) ;
@ -1507,22 +1598,34 @@
} ) ;
} ) ;
}
}
// ─── Создать шаблон ────────────────────────────────────────────
// ─── Автосоздание шаблона ──────────────────────────────────────
const createTemplateBtn = $ ( '#create-template' ) ;
let templateRebuildTimer = null ;
if ( createTemplateBtn ) {
let lastAppliedTemplateKey = '' ;
createTemplateBtn . addEventListener ( 'click' , ( ) => {
const qCount = Math . min ( 30 , Math . max ( 1 , parseInt ( $ ( '#ai-q-count' ) . value || '7' , 10 ) ) ) ;
function hasMeaningfulQuestions ( ) {
const oCount = Math . min ( MAX _OPTIONS , Math . max ( 2 , parseInt ( $ ( '#ai-o-count' ) . value || '3' , 10 ) ) ) ;
return $$ ( '#questions .q-item' ) . some ( ( node ) => {
const qText = ( $ ( '.q-text' , node ) ? . value || '' ) . trim ( ) ;
if ( qText ) return true ;
return $$ ( '.opt-text' , node ) . some ( ( o ) => ( o . value || '' ) . trim ( ) ) ;
} ) ;
}
function buildTemplateFromControls ( { askConfirm } = { askConfirm : true } ) {
const qCount = Math . min ( 30 , Math . max ( 1 , parseInt ( aiQCountEl ? . value || '7' , 10 ) ) ) ;
const oCount = Math . min ( MAX _OPTIONS , Math . max ( 2 , parseInt ( aiOCountEl ? . value || '3' , 10 ) ) ) ;
const globalMulti = ! ! ( templateGlobalMultiEl && templateGlobalMultiEl . checked ) ;
const globalMulti = ! ! ( templateGlobalMultiEl && templateGlobalMultiEl . checked ) ;
const range = getTemplateCorrectRange ( oCount , globalMulti ) ;
const range = getTemplateCorrectRange ( oCount , globalMulti ) ;
const existing = $$ ( '#questions .q-item' ) . length ;
const key = JSON . stringify ( { qCount , oCount , globalMulti , min : range . minCorrect , max : range . maxCorrect } ) ;
if ( existing > 0 ) {
if ( key === lastAppliedTemplateKey ) return ;
if ( askConfirm && hasMeaningfulQuestions ( ) ) {
const ok = confirm (
const ok = confirm (
` Создать шаблон: ${ qCount } вопросов × ${ oCount } вариантов? \n ` +
` Обнови ть шаблон: ${ qCount } вопросов × ${ oCount } вариантов? \n ` +
'Текущие вопросы будут заменены.'
'Текущие вопросы будут заменены.'
) ;
) ;
if ( ! ok ) return ;
if ( ! ok ) return ;
}
}
questionsEl . innerHTML = '' ;
questionsEl . innerHTML = '' ;
for ( let qi = 0 ; qi < qCount ; qi ++ ) {
for ( let qi = 0 ; qi < qCount ; qi ++ ) {
const opts = [ ] ;
const opts = [ ] ;
@ -1533,47 +1636,31 @@
}
}
renumber ( ) ;
renumber ( ) ;
scheduleDirtyCheck ( ) ;
scheduleDirtyCheck ( ) ;
// Прокручиваем к первому вопросу
lastAppliedTemplateKey = key ;
questionsEl . firstElementChild ? . scrollIntoView ( { behavior : 'smooth' , block : 'start' } ) ;
} ) ;
}
async function generateHintsForCurrentTest ( ) {
if ( ! generateHintsBtn ) return ;
generateHintsBtn . disabled = true ;
if ( hintsStatusEl ) hintsStatusEl . textContent = 'Сохраняем текущие изменения…' ;
try {
await saveCurrentDraftQuietly ( ) ;
if ( hintsStatusEl ) hintsStatusEl . textContent = 'Генерируем подсказки…' ;
const r = await fetch ( ` /api/tests/ ${ TEST _ID } /ai/hints/generate ` , { method : 'POST' } ) ;
const data = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! r . ok ) throw new Error ( data . error || 'Не удалось сгенерировать подсказки.' ) ;
try {
await refreshHintsInForm ( ) ;
} catch ( _ ) {
// Статус покажем как успешный; пользователь может перезагрузить страницу.
}
const skipped = Number ( data . skipped || 0 ) ;
if ( hintsStatusEl ) {
hintsStatusEl . textContent = data . failed
? ` Создано ${ data . generated } , ошибок ${ data . failed } ${ skipped ? ` , пропущено ${ skipped } ` : '' } . `
: ` Подсказки созданы: ${ data . generated } ${ skipped ? ` , пропущено ${ skipped } ` : '' } . ` ;
}
} catch ( e ) {
if ( hintsStatusEl ) hintsStatusEl . textContent = e . message || 'Ошибка генерации подсказок.' ;
} finally {
generateHintsBtn . disabled = false ;
}
}
}
if ( generateHintsBtn ) {
function scheduleTemplateRebuild ( ) {
generateHintsBtn . addEventListener ( 'click' , generateHintsForCurrentTest ) ;
if ( templateRebuildTimer ) clearTimeout ( templateRebuildTimer ) ;
templateRebuildTimer = setTimeout ( ( ) => buildTemplateFromControls ( { askConfirm : true } ) , 200 ) ;
}
}
if ( templateGlobalMultiEl ) templateGlobalMultiEl . addEventListener ( 'change' , syncTemplateRangeUi ) ;
if ( templateGlobalMultiEl ) templateGlobalMultiEl . addEventListener ( 'change' , ( ) => {
if ( templateMinCorrectEl ) templateMinCorrectEl . addEventListener ( 'input' , syncTemplateRangeUi ) ;
syncTemplateRangeUi ( ) ;
if ( templateMaxCorrectEl ) templateMaxCorrectEl . addEventListener ( 'input' , syncTemplateRangeUi ) ;
scheduleTemplateRebuild ( ) ;
if ( aiOCountEl ) aiOCountEl . addEventListener ( 'input' , syncTemplateRangeUi ) ;
} ) ;
if ( templateMinCorrectEl ) templateMinCorrectEl . addEventListener ( 'change' , ( ) => {
syncTemplateRangeUi ( ) ;
scheduleTemplateRebuild ( ) ;
} ) ;
if ( templateMaxCorrectEl ) templateMaxCorrectEl . addEventListener ( 'change' , ( ) => {
syncTemplateRangeUi ( ) ;
scheduleTemplateRebuild ( ) ;
} ) ;
if ( aiOCountEl ) aiOCountEl . addEventListener ( 'change' , ( ) => {
syncTemplateRangeUi ( ) ;
scheduleTemplateRebuild ( ) ;
} ) ;
if ( aiQCountEl ) aiQCountEl . addEventListener ( 'change' , scheduleTemplateRebuild ) ;
syncTemplateRangeUi ( ) ;
syncTemplateRangeUi ( ) ;
Promise . all ( [
Promise . all ( [