let currentEntries = []; let seenEntryIds = new Set(); let highlightNewIds = new Set(); let highlightClearTimer = null; let editId = null; let editGroups = []; let editModules = []; let moduleNames = new Map(); let moduleFilterId = ''; let moduleSearchTimer = null; let editModuleSearchTimer = null; let modulesLoaded = false; let groupsLoaded = false; let studentsLoaded = false; const PAGE_SIZE = 20; let page = 1; let totalEntries = 0; let searchTimer = null; let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list'; let aiCorrecting = false; let editDeletePhoto = false; let editAiSuggestion = null; let linkSettings = null; async function loadLinkSettings() { if (!linkSettings) { const res = await fetch(`${API}/api/settings`, { headers: hdr() }); linkSettings = await res.json().catch(() => ({})); } return linkSettings; } function isImageFile(name) { return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || ''); } function mainPhotoPath(e) { if (e.photo_path) return e.photo_path; if (e.photos && e.photos.length) return e.photos[0].photo_path; return null; } function filesHTML(files) { if (!files || !files.length) return ''; return `
${files.map(f => { const link = isImageFile(f.name) ? `${esc(f.name)}` : `${esc(f.name)}`; return `${link}`; }).join('')}
`; } const AI_STATUS_LABELS = { pending: 'ИИ: в очереди', processing: 'ИИ: проверяет…', done: 'ИИ: проверено', skipped: 'ИИ: пропущено', error: 'ИИ: ошибка', reverted: 'ИИ: возвращён оригинал', }; const AI_STATUS_ICONS = { pending: 'clock', processing: 'loader', done: 'circle-check', skipped: 'skip-forward', error: 'circle-alert', reverted: 'undo', }; function aiBadge(e) { if (!e || !e.ai_status) return ''; const st = e.ai_status; const label = AI_STATUS_LABELS[st] || `ИИ: ${st}`; const icon = AI_STATUS_ICONS[st] || 'sparkles'; return ``; } function findFile(id) { for (const e of currentEntries) { const f = (e.files || []).find(x => x.id === id); if (f) return f; } return null; } async function detachFile(id) { const f = findFile(id); if (!f) return; if (!confirm(`Открепить файл «${f.name}» от записи? Он переместится в «Файлы → Откреплённые».`)) return; const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() }); if (res.ok) { showToast('Файл откреплён'); if (f.entry_id) refreshEntryCard(f.entry_id); else loadEntries(); } else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); } } function gridEntryHTML(e) { const mp = mainPhotoPath(e); return `
${mp ? `` : ''}
${esc(e.student_name)}
${esc(e.group_name)}${e.module_name ? ' · ' + esc(e.module_name) : ''}
${esc(e.description)}${aiBadge(e)}
${filesHTML(e.files)}
${new Date(e.created_at).toLocaleString('ru')}
${mp ? `` : ''}
`; } function cardEntryHTML(e) { const mp = mainPhotoPath(e); return `
${mp ? `` : `
`} ${e.group_name ? `${esc(e.group_name)}` : ''} ${e.module_name ? `${esc(e.module_name)}` : ''}
${esc(e.student_name)}
${esc(e.description)}${aiBadge(e)}
${filesHTML(e.files)}
${new Date(e.created_at).toLocaleString('ru')} ${mp ? `` : ''}
`; } function renderGrid() { const grid = document.getElementById('grid'); if (!currentEntries.length) { const fmt = d => d ? d.split('-').reverse().join('.') : ''; const from = fmt(document.getElementById('dateFrom').value); const to = fmt(document.getElementById('dateTo').value); let range = ''; if (from && to) range = ` за ${from} – ${to}`; else if (from) range = ` с ${from}`; else if (to) range = ` по ${to}`; grid.className = 'grid'; grid.innerHTML = `
Ничего не найдено${range}
`; return; } if (view === 'cards') { grid.className = 'journal-cards'; grid.innerHTML = currentEntries.map(cardEntryHTML).join(''); } else { grid.className = 'grid'; grid.innerHTML = currentEntries.map(gridEntryHTML).join(''); } const items = Array.from(grid.children); items.forEach((el, i) => { el.style.animationDelay = `${Math.min(i * 45, 500)}ms`; }); renderIcons(); } function refreshEntryCardDom(e) { const btn = document.querySelector(`[data-edit="${e.id}"]`); const card = btn ? btn.closest('.card, .journal-card') : null; if (!card) return; const tmp = document.createElement('div'); tmp.innerHTML = (view === 'cards' ? cardEntryHTML(e) : gridEntryHTML(e)).trim(); const fresh = tmp.firstElementChild; fresh.classList.remove('entry-enter', 'entry-appear'); fresh.style.animationDelay = ''; card.replaceWith(fresh); renderIcons(); } async function refreshEntryCard(id) { const res = await fetch(`${API}/api/entries/${id}`, { headers: hdr() }); if (!res.ok) return false; const e = await res.json(); const idx = currentEntries.findIndex(x => x.id === e.id); if (idx !== -1) currentEntries[idx] = e; refreshEntryCardDom(e); return true; } function setView(v) { view = v === 'cards' ? 'cards' : 'list'; localStorage.setItem('journalView', view); document.querySelectorAll('#viewToggle button').forEach(b => { b.classList.toggle('active', b.dataset.view === view); }); renderGrid(); } function setDefaultDates() { const today = new Date().toISOString().slice(0, 10); document.getElementById('dateFrom').value = today; document.getElementById('dateTo').value = today; } async function loadGroupOptions() { if (groupsLoaded) return; const sel = document.getElementById('groupFilter'); while (sel.options.length > 1) sel.remove(1); const res = await fetch(`${API}/api/groups`, { headers: hdr() }); const groups = await res.json(); groups.forEach(g => { const o = document.createElement('option'); o.value = g.id; o.textContent = g.name; sel.appendChild(o); }); groupsLoaded = true; } function renderModuleOptions(datalistId, list) { list.forEach(m => moduleNames.set(m.name, m.id)); const dl = document.getElementById(datalistId); if (!dl) return; dl.innerHTML = ''; list.forEach(m => { const opt = document.createElement('option'); opt.value = m.name; dl.appendChild(opt); }); } async function fetchModules(q, limit) { const p = new URLSearchParams(); if (q) p.set('search', q); p.set('limit', String(limit)); p.set('active', '1'); const res = await fetch(`${API}/api/modules?${p}`, { headers: hdr() }); if (!res.ok) return []; const data = await res.json().catch(() => ({})); return data.modules || []; } async function resolveModuleId(name) { const n = (name || '').trim(); if (!n) return ''; if (moduleNames.has(n)) return String(moduleNames.get(n)); const list = await fetchModules(n, 10); const lower = n.toLowerCase(); const exact = list.find(m => m.name.toLowerCase() === lower); return exact ? String(exact.id) : null; } async function loadModuleOptions() { editModules = await fetchModules('', 50); modulesLoaded = true; renderModuleOptions('moduleFilterList', editModules); } async function applyModuleFilter() { const inp = document.getElementById('moduleFilter'); const resolved = await resolveModuleId(inp.value); if (resolved === null) { inp.value = ''; moduleFilterId = ''; showToast('Модуль не найден — фильтр сброшен'); } else { moduleFilterId = resolved; } page = 1; loadEntries(); } function fillEditModuleInput(entry) { const inp = document.getElementById('editModule'); if (!inp) return; if (entry.module_name && entry.module_id) moduleNames.set(entry.module_name, entry.module_id); inp.value = entry.module_name || ''; renderModuleOptions('editModuleList', editModules); } async function loadStudentOptions() { if (studentsLoaded) return; const dl = document.getElementById('studentFilterList'); const res = await fetch(`${API}/api/students/names`, { headers: hdr() }); const names = await res.json(); dl.innerHTML = names.map(n => ``).join(''); } sel.value = e.group_id; if (!modulesLoaded) await loadModuleOptions(); fillEditModuleInput(e); renderEditPhotos(e.photos || [], e.photo_path); document.getElementById('editModal').classList.add('open'); } function renderEditPhotos(photos, mainPath) { const preview = document.getElementById('editPhotoPreview'); preview.innerHTML = ''; if (!photos.length) return; photos.forEach((p) => { const isMain = p.photo_path === mainPath; const div = document.createElement('div'); div.className = 'file-preview-item'; div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem'; div.dataset.photoId = p.id; const applyStyle = isMain ? 'background:#16a34a;border:none;color:#fff;border-radius:6px;padding:4px 10px;font-size:.75rem;cursor:default;white-space:nowrap' : 'background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 10px;font-size:.75rem;cursor:pointer;white-space:nowrap'; div.innerHTML = ` ${esc(p.photo_path.split('/').pop())} `; preview.appendChild(div); const thumb = div.querySelector('img'); const showMissing = () => { const ph = document.createElement('span'); ph.textContent = 'нет файла'; ph.style.cssText = 'width:48px;height:48px;display:flex;align-items:center;justify-content:center;background:var(--bg);border:1px dashed var(--border);border-radius:4px;font-size:.6rem;color:var(--muted);text-align:center;line-height:1.1;flex:0 0 auto'; thumb.replaceWith(ph); }; thumb.addEventListener('error', showMissing); if (thumb.complete && thumb.naturalWidth === 0) showMissing(); div.querySelector('.delete-entry-photo').addEventListener('click', async () => { if (!confirm('Удалить это фото?')) return; const res = await fetch(`${API}/api/entries/${editId}/photos/${p.id}`, { method: 'DELETE', headers: hdr() }); if (res.ok) { div.remove(); showToast('Фото удалено'); refreshEntryCard(editId); } else { alert('Ошибка удаления'); } }); }); } async function applyMainPhoto(btn) { const div = btn.closest('.file-preview-item'); const photoId = div?.dataset.photoId; if (!photoId) return; btn.disabled = true; const res = await fetch(`${API}/api/entries/${editId}/photos/${photoId}/main`, { method: 'PUT', headers: hdr() }); if (!res.ok) { btn.disabled = false; const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; } document.querySelectorAll('#editPhotoPreview .apply-photo-btn').forEach(b => { b.disabled = false; b.textContent = 'Применить'; b.title = 'Сделать главным фото записи'; b.style.background = 'var(--card)'; b.style.color = 'var(--text)'; }); btn.disabled = true; btn.textContent = 'Применено ✓'; btn.title = 'Главное фото записи'; btn.style.background = '#16a34a'; btn.style.color = '#fff'; showToast('Главное фото обновлено'); refreshEntryCard(editId); } function closeEdit() { document.getElementById('editModal').classList.remove('open'); document.getElementById('editFiles').value = ''; document.getElementById('editFilesPreview').innerHTML = ''; document.getElementById('editPhoto').value = ''; document.getElementById('editPhotoPreview').innerHTML = ''; document.getElementById('editAiStatus').style.display = 'none'; editId = null; editDeletePhoto = false; } let enhanceEntryId = null; let enhanceSourceImg = null; let enhanceTimer = null; let enhanceSaving = false; let enhanceEngine = 'auto'; let enhanceEngineLoaded = false; let photoAiEnabled = false; let photoAiFaceMode = 'off'; let photoAiFaceModel = 'gfpgan'; let photoAiDevice = ''; let photoAiPollTimer = null; let enhanceAiResultPath = null; let enhanceAiJobId = null; let enhanceClipPos = 50; function setEnhanceClip(p) { enhanceClipPos = Math.min(100, Math.max(0, p)); document.getElementById('enhanceTop').style.clipPath = `inset(0 0 0 ${enhanceClipPos}%)`; document.getElementById('enhanceDivider').style.left = enhanceClipPos + '%'; } function showEnhanceResult(show) { document.getElementById('enhanceTop').style.display = show ? '' : 'none'; document.getElementById('enhanceLoading').style.display = show ? 'none' : ''; } function setEnhanceLoadingText(text) { const el = document.getElementById('enhanceLoading'); el.style.display = ''; el.textContent = text; } let enhanceZoom = 1; let enhancePanX = 0; let enhancePanY = 0; function applyEnhanceTransform() { const t = `translate(${enhancePanX}px, ${enhancePanY}px) scale(${enhanceZoom})`; document.getElementById('enhanceStage').style.transform = t; document.getElementById('enhanceInner').style.transform = t; const area = document.getElementById('enhanceCompare'); area.classList.toggle('zoomed', enhanceZoom > 1); if (enhanceZoom <= 1) area.classList.remove('panning'); } function clampEnhancePan() { const rect = document.getElementById('enhanceCompare').getBoundingClientRect(); const minX = rect.width * (1 - enhanceZoom); const minY = rect.height * (1 - enhanceZoom); enhancePanX = Math.min(0, Math.max(minX, enhancePanX)); enhancePanY = Math.min(0, Math.max(minY, enhancePanY)); } function resetEnhanceZoom() { enhanceZoom = 1; enhancePanX = 0; enhancePanY = 0; applyEnhanceTransform(); } (function initEnhanceDrag() { const area = document.getElementById('enhanceCompare'); let dragging = false; let dividerDrag = false; let lastX = 0; let lastY = 0; const move = ev => { const rect = area.getBoundingClientRect(); setEnhanceClip(((ev.clientX - rect.left) / rect.width) * 100); }; area.addEventListener('pointerdown', ev => { dividerDrag = !!ev.target.closest('#enhanceDivider'); dragging = true; lastX = ev.clientX; lastY = ev.clientY; area.setPointerCapture(ev.pointerId); if (!dividerDrag && enhanceZoom === 1) move(ev); }); area.addEventListener('pointermove', ev => { if (!dragging) return; if (dividerDrag || enhanceZoom === 1) { move(ev); } else { area.classList.add('panning'); enhancePanX += ev.clientX - lastX; enhancePanY += ev.clientY - lastY; lastX = ev.clientX; lastY = ev.clientY; clampEnhancePan(); applyEnhanceTransform(); } }); const end = () => { dragging = false; area.classList.remove('panning'); }; area.addEventListener('pointerup', end); area.addEventListener('pointercancel', end); area.addEventListener('wheel', ev => { ev.preventDefault(); const rect = area.getBoundingClientRect(); const cx = ev.clientX - rect.left; const cy = ev.clientY - rect.top; const prev = enhanceZoom; const next = Math.min(5, Math.max(1, enhanceZoom * (ev.deltaY < 0 ? 1.15 : 1 / 1.15))); if (next === prev) return; enhanceZoom = next; enhancePanX = cx - (cx - enhancePanX) * (next / prev); enhancePanY = cy - (cy - enhancePanY) * (next / prev); clampEnhancePan(); applyEnhanceTransform(); }, { passive: false }); area.addEventListener('dblclick', resetEnhanceZoom); })(); const PHOTO_AI_MODES = { x2: { model: 'x2plus', face: 'off', face_model: null, title: 'Универсально (x2)' }, x4: { model: 'general-x4v3', face: 'off', face_model: null, title: 'Быстро (x4)' }, faces: { model: 'x2plus', face: 'face', face_model: null, title: 'Лица (восстановление)' }, facesbg: { model: 'x2plus', face: 'all', face_model: null, title: 'Лица + фон' }, }; function photoAiMode() { const sel = document.getElementById('enhanceAiMode'); const key = sel && PHOTO_AI_MODES[sel.value] ? sel.value : 'x2'; const m = { ...PHOTO_AI_MODES[key] }; m.face_model = m.face === 'off' ? null : photoAiFaceModel; return m; } function defaultPhotoAiModeKey() { if (photoAiFaceMode === 'all') return 'facesbg'; if (photoAiFaceMode === 'face') return 'faces'; return 'x2'; } function updateAiHint() { const hint = document.getElementById('enhanceAiHint'); if (!hint) return; const cpu = photoAiDevice.startsWith('cpu'); hint.textContent = cpu ? 'Фото-ИИ считает на CPU — лицевые режимы могут занять несколько минут' : ''; hint.style.display = photoAiEnabled && cpu ? '' : 'none'; } async function loadPhotoAiDevice() { if (!isAdmin()) return; try { const r = await fetch(`${API}/api/photo-ai/health`, { headers: hdr() }); if (!r.ok) return; const h = await r.json(); if (h && h.device) photoAiDevice = String(h.device); updateAiHint(); } catch (e) {} } async function loadEnhanceEngine() { if (enhanceEngineLoaded) return; enhanceEngineLoaded = true; try { const r = await fetch(`${API}/api/public-settings`); const f = await r.json(); if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine; photoAiEnabled = f.photo_ai_enabled === 'true'; if (['off', 'face', 'all'].includes(f.photo_ai_face_mode)) photoAiFaceMode = f.photo_ai_face_mode; if (['gfpgan', 'codeformer'].includes(f.photo_ai_face_model)) photoAiFaceModel = f.photo_ai_face_model; const sel = document.getElementById('enhanceAiMode'); if (sel) sel.value = defaultPhotoAiModeKey(); loadPhotoAiDevice(); } catch (e) {} } async function runPhotoAi() { if (!enhanceEntryId || enhanceSaving) return; const entryId = enhanceEntryId; const mode = photoAiMode(); const faceNote = mode.face === 'off' ? 'Может занять до минуты на CPU.' : `Восстановление лиц (${mode.face_model}), на CPU может занять несколько минут.`; if (!confirm(`ИИ-обработка фото: ${mode.title}. ${faceNote} Продолжить?`)) return; enhanceSaving = true; const btn = document.getElementById('enhanceAiBtn'); btn.disabled = true; try { const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance-ai`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ model: mode.model, face: mode.face, face_model: mode.face_model }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Ошибка запуска ИИ'); } const { jobId } = await res.json(); enhanceAiJobId = jobId; showEnhanceResult(false); setEnhanceLoadingText(mode.face === 'off' ? 'ИИ обрабатывает фото... (может занять до минуты)' : 'ИИ обрабатывает фото и восстанавливает лица...'); btn.textContent = '⏳ ИИ...'; const result = await new Promise((resolve, reject) => { photoAiPollTimer = setInterval(async () => { try { const r = await fetch(`${API}/api/entries/${entryId}/photo/enhance-ai/${jobId}`, { headers: hdr() }); if (!r.ok) throw new Error('Ошибка опроса статуса'); const s = await r.json(); if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); } else if (s.status === 'error') { clearInterval(photoAiPollTimer); reject(new Error(s.error || 'Ошибка ИИ')); } } catch (e) { clearInterval(photoAiPollTimer); reject(e); } }, 2000); }); showToast('ИИ результат готов'); enhanceAiResultPath = result.photo_path; const aiImg = new Image(); aiImg.onload = () => { enhanceSourceImg = aiImg; ['Brightness', 'Contrast', 'Saturate'].forEach(k => { document.getElementById('enhance' + k).value = 100; document.getElementById('enhance' + k + 'Val').textContent = '100%'; }); document.getElementById('enhanceSharp').value = 0; document.getElementById('enhanceSharpVal').textContent = '0%'; document.getElementById('enhanceDenoise').value = 0; document.getElementById('enhanceDenoiseVal').textContent = '0%'; renderEnhancePreview(); }; aiImg.onerror = () => { showToast('Не удалось загрузить результат ИИ'); }; aiImg.src = `${API}${result.photo_path}`; } catch (e) { showEnhanceResult(true); alert(e.message); } finally { enhanceSaving = false; btn.disabled = false; btn.textContent = '🤖 ИИ'; } } async function restoreOriginal() { if (!enhanceEntryId || enhanceSaving) return; if (!confirm('Вернуть исходную фотографию? Текущая улучшенная версия будет удалена.')) return; enhanceSaving = true; const btn = document.getElementById('enhanceRestoreBtn'); btn.disabled = true; try { const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/restore-original`, { method: 'POST', headers: hdr() }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Ошибка восстановления'); } const savedId = enhanceEntryId; showToast('Оригинал возвращён'); closeEnhance(); refreshEntryCard(savedId); } catch (e) { alert(e.message); } finally { enhanceSaving = false; btn.disabled = false; } } function openEnhance(id) { const e = currentEntries.find(x => x.id === id); if (!e) return; const mp = mainPhotoPath(e); if (!mp) { showToast('У записи нет фото'); return; } enhanceEntryId = id; enhanceSourceImg = null; enhanceAiResultPath = null; enhanceAiJobId = null; ['Brightness', 'Contrast', 'Saturate'].forEach(k => { const el = document.getElementById('enhance' + k); el.value = 100; document.getElementById('enhance' + k + 'Val').textContent = '100%'; }); const sharp = document.getElementById('enhanceSharp'); sharp.value = 0; document.getElementById('enhanceSharpVal').textContent = '0%'; const orig = document.getElementById('enhanceOrig'); orig.src = `${API}${mp}`; document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none'; loadEnhanceEngine().then(() => { const show = photoAiEnabled ? '' : 'none'; document.getElementById('enhanceAiBtn').style.display = show; document.getElementById('enhanceAiMode').style.display = show; updateAiHint(); }); const canvas = document.getElementById('enhanceCanvas'); setEnhanceClip(50); resetEnhanceZoom(); showEnhanceResult(false); setEnhanceLoadingText('Загрузка...'); const img = new Image(); img.onload = () => { enhanceSourceImg = img; renderEnhancePreview(); }; img.onerror = () => { setEnhanceLoadingText('Не удалось загрузить фото'); }; img.src = `${API}${mp}`; document.getElementById('enhanceModal').classList.add('open'); } function isDefaultEnhanceParams() { const p = enhanceParams(); return p.brightness === 100 && p.contrast === 100 && p.saturate === 100 && p.denoise === 0 && p.sharp === 0; } async function applyAiResult() { if (!enhanceAiJobId || !enhanceEntryId) return false; const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${enhanceAiJobId}/apply`, { method: 'POST', headers: hdr(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'Ошибка применения ИИ-результата'); enhanceAiResultPath = null; enhanceAiJobId = null; return true; } function cleanupAiPreview() { if (!enhanceEntryId) return; if (enhanceAiJobId) { fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${enhanceAiJobId}/reject`, { method: 'POST', headers: hdr(), }).catch(() => {}); } else if (enhanceAiResultPath) { fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai/preview`, { method: 'DELETE', headers: hdrJson(), body: JSON.stringify({ path: enhanceAiResultPath }), }).catch(() => {}); } enhanceAiResultPath = null; enhanceAiJobId = null; } function closeEnhance() { cleanupAiPreview(); document.getElementById('enhanceModal').classList.remove('open'); document.getElementById('photoHistoryModal').classList.remove('open'); enhanceEntryId = null; enhanceSourceImg = null; enhanceAiResultPath = null; } function photoJobThumb(p) { if (!p) return ''; if (p.startsWith('/uploads/.originals/')) return `${API}${p}`; return `${API}/uploads/thumb/${p.split('/').pop()}`; } const PHOTO_JOB_ACTION_LABELS = { ai: 'ИИ-обработка', enhance: 'Улучшение', restore: 'Возврат оригинала', rollback: 'Откат' }; const PHOTO_JOB_STATUS_LABELS = { pending: 'В очереди', processing: 'Обрабатывается', done: 'Готово', error: 'Ошибка', rejected: 'Отклонено' }; async function loadPhotoHistory() { if (!enhanceEntryId) return; const list = document.getElementById('photoHistoryList'); list.innerHTML = '
Загрузка...
'; try { const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs`, { headers: hdr() }); if (!res.ok) throw new Error('Ошибка загрузки истории'); const rows = await res.json(); if (!rows.length) { list.innerHTML = '
Изменений фотографии пока не было.
'; return; } list.innerHTML = rows.map(r => { const before = r.before_path ? `было` : '—'; const after = r.after_path ? `стало` : '—'; const canApply = r.status === 'done' && r.applied === false && r.action === 'ai' && !!r.after_path; const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback' && !canApply; const err = r.error ? `
${esc(r.error)}
` : ''; const time = r.finished_at || r.created_at; return `
${before} было
→
${after} стало
${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)} ${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(new Date(time).toLocaleString('ru'))}
${err}
${canRollback ? `` : ''} ${canApply ? `` : ''}
`; }).join(''); } catch (e) { list.innerHTML = `
${esc(e.message)}
`; } } function openPhotoHistory() { document.getElementById('photoHistoryTitle').textContent = `История изменений фотографии — запись #${enhanceEntryId}`; document.getElementById('photoHistoryModal').classList.add('open'); loadPhotoHistory(); } async function rollbackPhotoJob(jobId) { if (!enhanceEntryId || enhanceSaving) return; if (!confirm('Откатить фотографию к сохранённой версии? Текущая версия сохранится в истории.')) return; enhanceSaving = true; try { const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${jobId}/rollback`, { method: 'POST', headers: hdr() }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'Ошибка отката'); showToast('Фотография отката'); document.getElementById('photoHistoryModal').classList.remove('open'); const mp = data.photo_path; document.getElementById('enhanceOrig').src = `${API}${mp}`; showEnhanceResult(false); setEnhanceLoadingText('Загрузка...'); const img = new Image(); img.onload = () => { enhanceSourceImg = img; renderEnhancePreview(); }; img.onerror = () => setEnhanceLoadingText('Не удалось загрузить фото'); img.src = `${API}${mp}`; refreshEntryCard(enhanceEntryId); } catch (e) { alert(e.message); } finally { enhanceSaving = false; } } function medianFilterPass(ctx, w, h) { const data = ctx.getImageData(0, 0, w, h); const src = data.data; const out = new Uint8ClampedArray(src); const row = w * 4; const win = new Array(9); for (let y = 1; y < h - 1; y++) { for (let x = 1; x < w - 1; x++) { const i = y * row + x * 4; for (let ch = 0; ch < 3; ch++) { win[0] = src[i - row - 4 + ch]; win[1] = src[i - row + ch]; win[2] = src[i - row + 4 + ch]; win[3] = src[i - 4 + ch]; win[4] = src[i + ch]; win[5] = src[i + 4 + ch]; win[6] = src[i + row - 4 + ch]; win[7] = src[i + row + ch]; win[8] = src[i + row + 4 + ch]; win.sort((a, b) => a - b); out[i + ch] = win[4]; } } } data.data.set(out); ctx.putImageData(data, 0, 0); } function sharpenPass(ctx, w, h, sharp) { const data = ctx.getImageData(0, 0, w, h); const src = data.data; const out = new Uint8ClampedArray(src); const row = w * 4; const a = (sharp / 100) * 1.5; for (let y = 1; y < h - 1; y++) { for (let x = 1; x < w - 1; x++) { const i = y * row + x * 4; for (let ch = 0; ch < 3; ch++) { const v = (1 + 4 * a) * src[i + ch] - a * src[i - 4 + ch] - a * src[i + 4 + ch] - a * src[i - row + ch] - a * src[i + row + ch]; out[i + ch] = v; } } } data.data.set(out); ctx.putImageData(data, 0, 0); } function drawEnhanced(ctx, img, w, h) { const b = document.getElementById('enhanceBrightness').value; const c = document.getElementById('enhanceContrast').value; const s = document.getElementById('enhanceSaturate').value; const dn = parseInt(document.getElementById('enhanceDenoise').value, 10); const sp = parseInt(document.getElementById('enhanceSharp').value, 10); ctx.filter = `brightness(${b}%) contrast(${c}%) saturate(${s}%)`; ctx.drawImage(img, 0, 0, w, h); ctx.filter = 'none'; if (dn > 0) { medianFilterPass(ctx, w, h); if (dn > 70) medianFilterPass(ctx, w, h); } if (sp > 0) sharpenPass(ctx, w, h, sp); } function renderEnhancePreview() { if (!enhanceSourceImg) return; const img = enhanceSourceImg; const scale = Math.min(1, 1600 / img.naturalWidth); const w = Math.max(1, Math.round(img.naturalWidth * scale)); const h = Math.max(1, Math.round(img.naturalHeight * scale)); const canvas = document.getElementById('enhanceCanvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); drawEnhanced(ctx, img, w, h); showEnhanceResult(true); } function scheduleEnhancePreview() { document.getElementById('enhanceBrightnessVal').textContent = document.getElementById('enhanceBrightness').value + '%'; document.getElementById('enhanceContrastVal').textContent = document.getElementById('enhanceContrast').value + '%'; document.getElementById('enhanceSaturateVal').textContent = document.getElementById('enhanceSaturate').value + '%'; document.getElementById('enhanceDenoiseVal').textContent = document.getElementById('enhanceDenoise').value + '%'; document.getElementById('enhanceSharpVal').textContent = document.getElementById('enhanceSharp').value + '%'; if (enhanceTimer) clearTimeout(enhanceTimer); enhanceTimer = setTimeout(renderEnhancePreview, 120); } function enhanceParams() { return { brightness: parseInt(document.getElementById('enhanceBrightness').value, 10), contrast: parseInt(document.getElementById('enhanceContrast').value, 10), saturate: parseInt(document.getElementById('enhanceSaturate').value, 10), denoise: parseInt(document.getElementById('enhanceDenoise').value, 10), sharp: parseInt(document.getElementById('enhanceSharp').value, 10), }; } function estimateNoise(img) { const aw = 160; const ah = Math.max(1, Math.round(img.naturalHeight * aw / img.naturalWidth)); const ac = document.createElement('canvas'); ac.width = aw; ac.height = ah; const actx = ac.getContext('2d'); actx.drawImage(img, 0, 0, aw, ah); const d = actx.getImageData(0, 0, aw, ah).data; let sum = 0; let n = 0; for (let y = 0; y < ah - 1; y++) { for (let x = 0; x < aw - 1; x++) { const i = (y * aw + x) * 4; const l = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]; const lr = 0.299 * d[i + 4] + 0.587 * d[i + 5] + 0.114 * d[i + 6]; const lb = 0.299 * d[i + aw * 4] + 0.587 * d[i + aw * 4 + 1] + 0.114 * d[i + aw * 4 + 2]; sum += Math.abs(l - lr) + Math.abs(l - lb); n += 2; } } return n ? sum / n : 0; } function autoEnhance() { if (!enhanceSourceImg) return; const img = enhanceSourceImg; const aw = 160; const ah = Math.max(1, Math.round(img.naturalHeight * aw / img.naturalWidth)); const ac = document.createElement('canvas'); ac.width = aw; ac.height = ah; const actx = ac.getContext('2d'); actx.drawImage(img, 0, 0, aw, ah); const d = actx.getImageData(0, 0, aw, ah).data; const hist = new Array(256).fill(0); let total = 0; for (let i = 0; i < d.length; i += 4) { if (d[i + 3] < 16) continue; hist[Math.round(0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2])]++; total++; } const pct = p => { let acc = 0; const target = total * p; for (let v = 0; v < 256; v++) { acc += hist[v]; if (acc >= target) return v; } return 255; }; let lo = pct(0.005); let hi = pct(0.995); if (hi - lo < 30) { lo = Math.max(0, lo - 15); hi = Math.min(255, hi + 15); } const contrast = Math.round(Math.min(150, Math.max(100, 25500 / (hi - lo)))); const brightness = Math.round(Math.min(120, Math.max(85, 12800 / Math.max(1, (lo + hi) / 2)))); const noise = estimateNoise(img); let denoise = 0; let sharp = 30; if (noise > 6) { denoise = Math.min(50, Math.round(noise * 6)); sharp = 15; } const saturate = 106; document.getElementById('enhanceBrightness').value = brightness; document.getElementById('enhanceContrast').value = contrast; document.getElementById('enhanceSaturate').value = saturate; document.getElementById('enhanceDenoise').value = denoise; document.getElementById('enhanceSharp').value = sharp; scheduleEnhancePreview(); } async function saveEnhance() { if (!enhanceEntryId || !enhanceSourceImg || enhanceSaving) return; await loadEnhanceEngine(); const btn = document.getElementById('enhanceSaveBtn'); enhanceSaving = true; btn.disabled = true; btn.textContent = 'Сохранение...'; const entryId = enhanceEntryId; try { let done = false; let engineLabel = 'клиент'; if (enhanceAiResultPath && isDefaultEnhanceParams()) { await applyAiResult(); engineLabel = 'ИИ'; done = true; } else if (enhanceEngine !== 'client' && !enhanceAiResultPath) { try { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 8000); const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance`, { method: 'PUT', headers: hdrJson(), body: JSON.stringify({ params: enhanceParams() }), signal: ctrl.signal, }); clearTimeout(timer); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Ошибка сервера'); } engineLabel = 'сервер'; done = true; } catch (e) { if (enhanceEngine === 'server') throw e; } } if (!done) { const img = enhanceSourceImg; const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d'); drawEnhanced(ctx, img, canvas.width, canvas.height); const blob = await new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/jpeg', 0.92)); const fd = new FormData(); fd.append('photo', blob, 'enhanced.jpg'); const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance`, { method: 'PUT', headers: hdr(), body: fd }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Ошибка сохранения'); } } showToast(`Фото улучшено (${engineLabel}) и сохранено`); closeEnhance(); refreshEntryCard(entryId); } catch (e) { alert(e.message); } finally { enhanceSaving = false; btn.disabled = false; btn.textContent = 'Применить'; } } async function aiCorrect() { if (aiCorrecting) return; const textarea = document.getElementById('editDesc'); const text = textarea.value.trim(); if (!text) { showToast('Нет текста для исправления'); return; } const btn = document.getElementById('aiCorrectBtn'); const modal = document.getElementById('aiCorrectModal'); const suggestionEl = document.getElementById('aiSuggestion'); aiCorrecting = true; btn.disabled = true; btn.textContent = '⏳'; suggestionEl.textContent = 'Обработка...'; modal.classList.add('open'); try { const res = await fetch(`${API}/api/ai/correct`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ text }) }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Ошибка ИИ'); } const data = await res.json(); suggestionEl.textContent = data.suggestion || text; } catch (e) { suggestionEl.textContent = 'Ошибка: ' + e.message; showToast('ИИ недоступен: ' + e.message); } finally { aiCorrecting = false; btn.disabled = false; btn.textContent = '✨'; } } function closeAiCorrect() { document.getElementById('aiCorrectModal').classList.remove('open'); } function acceptAiCorrect() { const suggestion = document.getElementById('aiSuggestion').textContent; if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) { document.getElementById('editDesc').value = suggestion; editAiSuggestion = suggestion.trim(); } closeAiCorrect(); } function renderEditAiStatus(e) { const el = document.getElementById('editAiStatus'); if (!e || !e.ai_status) { el.style.display = 'none'; el.innerHTML = ''; return; } el.style.display = 'block'; const changed = e.description_ai && e.description_original && e.description_ai !== e.description_original; const parts = [AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`]; if (e.ai_checked_at) parts.push(new Date(e.ai_checked_at).toLocaleString('ru')); let html = `${esc(parts.join(' · '))}`; if (changed) html += ` `; html += ` `; el.innerHTML = html; const rb = document.getElementById('aiRevertBtn'); if (rb) rb.addEventListener('click', () => revertAi(e.id)); const cb = document.getElementById('aiRecheckBtn'); if (cb) cb.addEventListener('click', () => recheckAi(e.id)); } async function revertAi(id) { if (!confirm('Вернуть исходный текст записи (до ИИ-проверки)?')) return; const res = await fetch(`${API}/api/entries/${id}/ai/revert`, { method: 'POST', headers: hdr() }); if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; } const e = currentEntries.find(x => x.id === id); if (e) { if (e.description_original) e.description = e.description_original; e.description_ai = null; e.ai_status = 'reverted'; e.ai_error = null; } showToast('Оригинал восстановлен'); closeEdit(); refreshEntryCard(id); } async function recheckAi(id) { const res = await fetch(`${API}/api/entries/${id}/ai/recheck`, { method: 'POST', headers: hdr() }); if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; } const e = currentEntries.find(x => x.id === id); if (e) { e.ai_status = 'pending'; e.ai_error = null; } showToast('Отправлено на ИИ-проверку'); closeEdit(); refreshEntryCard(id); } function editSourceFor(description) { if (!editAiSuggestion) return 'manual'; return description === editAiSuggestion ? 'ai' : 'ai_manual'; } async function saveEdit() { if (!editId) return; const name = document.getElementById('editName').value.trim(); const group_id = document.getElementById('editGroup').value; const description = document.getElementById('editDesc').value.trim(); if (!name || !group_id || !description) { alert('Заполните все поля'); return; } const moduleId = await resolveModuleId(document.getElementById('editModule').value); if (moduleId === null) { alert('Тема модуля не найдена. Выберите из списка подсказок.'); return; } const formData = new FormData(); formData.append('student_name', name); formData.append('group_id', group_id); formData.append('description', description); formData.append('module_id', moduleId); formData.append('edit_source', editSourceFor(description)); const photoFiles = document.getElementById('editPhoto').files; for (const f of photoFiles) formData.append('photo', f); const res = await fetch(`${API}/api/entries/${editId}`, { method: 'PUT', headers: hdr(), body: formData }); if (!res.ok) { const err = await res.json(); alert(err.error); return; } const files = document.getElementById('editFiles').files; if (files.length) { const filesFormData = new FormData(); for (const f of files) filesFormData.append('files', f); const uploadRes = await fetch(`${API}/api/entries/${editId}/files`, { method: 'POST', headers: hdr(), body: filesFormData }); if (!uploadRes.ok) { const err = await uploadRes.json().catch(() => ({})); alert(err.error || 'Ошибка загрузки файлов'); return; } } const id = editId; closeEdit(); const refreshed = await refreshEntryCard(id); if (!refreshed) loadEntries(); showToast('Запись обновлена'); } async function delEntry(id) { if (!confirm('Переместить запись в корзину? Файлы и фото сохранятся.')) return; await fetch(`${API}/api/entries/${id}`, { method: 'DELETE', headers: hdr() }); showToast('Запись перемещена в корзину'); loadEntries(); } document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadEntries(); }); document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); }); document.getElementById('moduleFilter').addEventListener('change', applyModuleFilter); document.getElementById('moduleFilter').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); applyModuleFilter(); } }); document.getElementById('moduleFilter').addEventListener('input', () => { clearTimeout(moduleSearchTimer); const q = document.getElementById('moduleFilter').value.trim(); moduleSearchTimer = setTimeout(async () => { renderModuleOptions('moduleFilterList', await fetchModules(q, 20)); }, 250); }); document.getElementById('editModule').addEventListener('input', () => { clearTimeout(editModuleSearchTimer); const q = document.getElementById('editModule').value.trim(); editModuleSearchTimer = setTimeout(async () => { renderModuleOptions('editModuleList', await fetchModules(q, 20)); }, 250); }); document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); }); document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); }); document.getElementById('searchInput').addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { page = 1; loadEntries(); }, 300); }); document.getElementById('editForm').addEventListener('submit', (e) => { e.preventDefault(); saveEdit(); }); document.getElementById('editFiles').addEventListener('change', (e) => { const preview = document.getElementById('editFilesPreview'); preview.innerHTML = ''; Array.from(e.target.files).forEach((f, i) => { const div = document.createElement('div'); div.className = 'file-preview-item'; div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem'; div.innerHTML = `${esc(f.name)} ${(f.size/1024).toFixed(1)} KB `; preview.appendChild(div); }); }); document.getElementById('editPhoto').addEventListener('change', (e) => { const preview = document.getElementById('editPhotoPreview'); Array.from(e.target.files).forEach(f => { const div = document.createElement('div'); div.className = 'file-preview-item'; div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem'; const url = URL.createObjectURL(f); div.innerHTML = ` ${esc(f.name)} ${(f.size/1024).toFixed(1)} KB `; preview.appendChild(div); }); }); function removeEditFile(index) { const input = document.getElementById('editFiles'); const dt = new DataTransfer(); Array.from(input.files).forEach((f, i) => { if (i !== index) dt.items.add(f); }); input.files = dt.files; input.dispatchEvent(new Event('change')); } async function exportCSV() { const p = new URLSearchParams(); const q = document.getElementById('searchInput').value; const sn = document.getElementById('studentFilter').value; const gid = document.getElementById('groupFilter').value; const df = document.getElementById('dateFrom').value; const dt = document.getElementById('dateTo').value; if (q) p.set('search', q); if (sn) p.set('student_name', sn); if (gid) p.set('group_id', gid); if (df) p.set('date_from', df); if (dt) p.set('date_to', dt); if (moduleFilterId) p.set('module_id', moduleFilterId); const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() }); const entries = (await res.json()).entries; if (!entries.length) { alert('Нет данных для экспорта'); return; } const rows = [['Фамилия и имя', 'Группа', 'Тема модуля', 'Описание', 'Дата', 'Фото']]; entries.forEach(e => { rows.push([ e.student_name, e.group_name, e.module_name || '', e.description.replace(/\n/g, ' '), new Date(e.created_at).toLocaleString('ru'), e.photo_path ? `${API}${e.photo_path}` : '' ]); }); const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n'); const bom = '\uFEFF'; const blob = new Blob([bom + csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const dateLabel = df && dt ? `${df}_${dt}` : 'all'; a.download = `journal_${dateLabel}.csv`; a.click(); URL.revokeObjectURL(url); } let linkGroups = []; let linkStudentsLoaded = false; let createdLinkUrl = ''; function fmtD(d) { return d ? d.split('-').reverse().join('.') : 'весь период'; } async function openLinkCreate() { const gid = document.getElementById('groupFilter').value; if (!gid) { alert('Сначала выберите группу в фильтре'); return; } const df = document.getElementById('dateFrom').value; const dt = document.getElementById('dateTo').value; if (!linkGroups.length) { const res = await fetch(`${API}/api/groups`, { headers: hdr() }); linkGroups = await res.json(); } if (!linkStudentsLoaded) { const res = await fetch(`${API}/api/students/names`, { headers: hdr() }); const names = await res.json(); const dl = document.getElementById('linkStudentList'); dl.innerHTML = names.map(n => `