Add AI auto-check worker, share link message/link fields, and update journal/settings UI
This commit is contained in:
+187
-11
@@ -9,11 +9,18 @@ let totalEntries = 0;
|
||||
let searchTimer = null;
|
||||
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
||||
let aiCorrecting = false;
|
||||
let editDeletePhoto = false;
|
||||
|
||||
function isImageFile(name) {
|
||||
return /\.(jpe?g|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 `<div class="entry-files">${files.map(f => {
|
||||
@@ -24,6 +31,21 @@ function filesHTML(files) {
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
const AI_STATUS_LABELS = {
|
||||
pending: 'ИИ: в очереди',
|
||||
processing: 'ИИ: проверяет…',
|
||||
done: 'ИИ: проверено',
|
||||
skipped: 'ИИ: пропущено',
|
||||
error: 'ИИ: ошибка',
|
||||
reverted: 'ИИ: возвращён оригинал',
|
||||
};
|
||||
|
||||
function aiBadge(e) {
|
||||
if (!e || !e.ai_status) return '';
|
||||
const label = AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`;
|
||||
return `<span class="ai-badge ai-badge-${esc(e.ai_status)}" title="${esc(label)}">${esc(label)}</span>`;
|
||||
}
|
||||
|
||||
function findFile(id) {
|
||||
for (const e of currentEntries) {
|
||||
const f = (e.files || []).find(x => x.id === id);
|
||||
@@ -47,11 +69,12 @@ async function detachFile(id) {
|
||||
}
|
||||
|
||||
function gridEntryHTML(e) {
|
||||
const mp = mainPhotoPath(e);
|
||||
return `
|
||||
<div class="card">
|
||||
${e.photo_path ? `<img src="${API}${e.photo_path}" data-img="${API}${e.photo_path}" alt="">` : ''}
|
||||
${mp ? `<img src="${API}${mp}" data-img="${API}${mp}" alt="">` : ''}
|
||||
<div class="info">
|
||||
<div class="name">${esc(e.student_name)}</div>
|
||||
<div class="name">${esc(e.student_name)}</div>${aiBadge(e)}
|
||||
<div class="group">${esc(e.group_name)}</div>
|
||||
<div class="desc">${esc(e.description)}</div>
|
||||
${filesHTML(e.files)}
|
||||
@@ -64,13 +87,14 @@ function gridEntryHTML(e) {
|
||||
}
|
||||
|
||||
function cardEntryHTML(e) {
|
||||
const mp = mainPhotoPath(e);
|
||||
return `
|
||||
<div class="journal-card">
|
||||
${e.photo_path
|
||||
? `<img src="${API}${e.photo_path}" data-img="${API}${e.photo_path}" alt="">`
|
||||
${mp
|
||||
? `<img src="${API}${mp}" data-img="${API}${mp}" alt="">`
|
||||
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
|
||||
<div class="body">
|
||||
<div class="name">${esc(e.student_name)}</div>
|
||||
<div class="name">${esc(e.student_name)}</div>${aiBadge(e)}
|
||||
<div class="group">${esc(e.group_name)}</div>
|
||||
<div class="desc">${esc(e.description)}</div>
|
||||
${filesHTML(e.files)}
|
||||
@@ -171,9 +195,11 @@ async function openEdit(id) {
|
||||
const e = currentEntries.find(x => x.id === id);
|
||||
if (!e) return;
|
||||
editId = id;
|
||||
editDeletePhoto = false;
|
||||
fillAutocomplete();
|
||||
document.getElementById('editName').value = e.student_name;
|
||||
document.getElementById('editDesc').value = e.description;
|
||||
renderEditAiStatus(e);
|
||||
const sel = document.getElementById('editGroup');
|
||||
if (!editGroups.length) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
@@ -181,14 +207,85 @@ async function openEdit(id) {
|
||||
sel.innerHTML = editGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
sel.value = e.group_id;
|
||||
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 = `<img src="${API}${esc(p.photo_path)}" style="width:48px;height:48px;object-fit:cover;border-radius:4px"> <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(p.photo_path.split('/').pop())}</span> <button type="button" class="apply-photo-btn" style="${applyStyle}" title="${isMain ? 'Главное фото записи' : 'Сделать главным фото записи'}" ${isMain ? 'disabled' : ''}>${isMain ? 'Применено ✓' : 'Применить'}</button> <button type="button" class="delete-entry-photo" style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.1rem;line-height:1" title="Удалить фото">✕</button>`;
|
||||
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('Фото удалено');
|
||||
loadEntries();
|
||||
} 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('Главное фото обновлено');
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function aiCorrect() {
|
||||
@@ -238,27 +335,70 @@ function acceptAiCorrect() {
|
||||
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 = `<span>${esc(parts.join(' · '))}</span>`;
|
||||
if (changed) html += ` <button type="button" id="aiRevertBtn" class="ai-btn" style="font-size:.75rem" title="Вернуть исходный текст (до ИИ)">↩ Оригинал</button>`;
|
||||
html += ` <button type="button" id="aiRecheckBtn" class="ai-btn" style="font-size:.75rem" title="Проверить заново">⟳ Проверить</button>`;
|
||||
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; }
|
||||
showToast('Оригинал восстановлен');
|
||||
closeEdit();
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
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; }
|
||||
showToast('Отправлено на ИИ-проверку');
|
||||
closeEdit();
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
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 formData = new FormData();
|
||||
formData.append('student_name', name);
|
||||
formData.append('group_id', group_id);
|
||||
formData.append('description', 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: hdrJson(),
|
||||
body: JSON.stringify({ student_name: name, group_id, description })
|
||||
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 formData = new FormData();
|
||||
for (const f of files) formData.append('files', f);
|
||||
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: formData
|
||||
body: filesFormData
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
const err = await uploadRes.json().catch(() => ({}));
|
||||
@@ -303,6 +443,18 @@ document.getElementById('editFiles').addEventListener('change', (e) => {
|
||||
});
|
||||
});
|
||||
|
||||
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 = `<img src="${url}" style="width:48px;height:48px;object-fit:cover;border-radius:4px"> <span>${esc(f.name)}</span> <span style="color:var(--muted)">${(f.size/1024).toFixed(1)} KB</span> <button type="button" data-remove-new-photo="${f.name}" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;line-height:1">✕</button>`;
|
||||
preview.appendChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
function removeEditFile(index) {
|
||||
const input = document.getElementById('editFiles');
|
||||
const dt = new DataTransfer();
|
||||
@@ -381,6 +533,8 @@ async function openLinkCreate() {
|
||||
document.getElementById('linkExpiresDays').value = '7';
|
||||
document.getElementById('linkAnonymizeNames').checked = false;
|
||||
document.getElementById('linkPassword').value = '';
|
||||
document.getElementById('linkMessage').value = '';
|
||||
document.getElementById('linkUrl').value = '';
|
||||
document.getElementById('linkCreateView').style.display = '';
|
||||
document.getElementById('linkResultView').style.display = 'none';
|
||||
document.getElementById('linkModal').classList.add('open');
|
||||
@@ -399,13 +553,15 @@ async function createLink() {
|
||||
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
|
||||
const anonymize_names = document.getElementById('linkAnonymizeNames').checked;
|
||||
const access_password = document.getElementById('linkPassword').value;
|
||||
const message = document.getElementById('linkMessage').value.trim();
|
||||
const link_url = document.getElementById('linkUrl').value.trim();
|
||||
if (!name) { alert('Введите название'); return; }
|
||||
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
|
||||
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const res = await fetch(`${API}/api/links`, {
|
||||
method: 'POST',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null })
|
||||
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null })
|
||||
});
|
||||
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
|
||||
const link = await res.json();
|
||||
@@ -436,6 +592,26 @@ document.getElementById('editFilesPreview').addEventListener('click', e => {
|
||||
if (btn) removeEditFile(parseInt(btn.dataset.removeFile, 10));
|
||||
});
|
||||
|
||||
document.getElementById('editPhotoPreview').addEventListener('click', e => {
|
||||
const removeBtn = e.target.closest('[data-remove-photo]');
|
||||
if (removeBtn) {
|
||||
document.getElementById('editPhoto').value = '';
|
||||
document.getElementById('editPhotoPreview').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const applyBtn = e.target.closest('.apply-photo-btn');
|
||||
if (applyBtn) { applyMainPhoto(applyBtn); return; }
|
||||
const removeNewBtn = e.target.closest('[data-remove-new-photo]');
|
||||
if (removeNewBtn) {
|
||||
const fileName = removeNewBtn.dataset.removeNewPhoto;
|
||||
const input = document.getElementById('editPhoto');
|
||||
const dt = new DataTransfer();
|
||||
Array.from(input.files).forEach(f => { if (f.name !== fileName) dt.items.add(f); });
|
||||
input.files = dt.files;
|
||||
removeNewBtn.closest('.file-preview-item').remove();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
|
||||
document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect);
|
||||
document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect);
|
||||
|
||||
Reference in New Issue
Block a user