feat: photo quality improvements
- webcam capture resolution/quality configurable in admin settings (defaults 640x480 / 0.92) - enhance photo modal in journal: original vs preview with sliders (brightness, contrast, saturation, sharpen) and auto-levels button - new endpoint PUT /api/entries/:id/photo/enhance replaces photo, cleans old file and thumb - sharper HEIC conversion (0.92) and webp thumbnails (85) - worker: fail explicitly on empty AI response
This commit is contained in:
@@ -161,6 +161,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="enhanceModal">
|
||||||
|
<div class="edit-modal" style="max-width:960px">
|
||||||
|
<h3>Улучшение фотографии</h3>
|
||||||
|
<div style="display:flex;gap:16px;flex-wrap:wrap">
|
||||||
|
<div style="flex:1;min-width:280px">
|
||||||
|
<div style="font-size:.8rem;color:var(--muted);margin-bottom:6px">Оригинал</div>
|
||||||
|
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;overflow:hidden;min-height:120px;display:flex;align-items:center;justify-content:center">
|
||||||
|
<img id="enhanceOrig" style="max-width:100%;max-height:320px;display:block">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:280px">
|
||||||
|
<div style="font-size:.8rem;color:var(--muted);margin-bottom:6px">Результат</div>
|
||||||
|
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;overflow:hidden;min-height:120px;display:flex;align-items:center;justify-content:center">
|
||||||
|
<canvas id="enhanceCanvas" style="max-width:100%;max-height:320px;display:none"></canvas>
|
||||||
|
<span id="enhanceLoading" style="color:var(--muted);font-size:.8rem;padding:24px">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;margin-top:14px">
|
||||||
|
<div class="settings-field"><label>Яркость <span id="enhanceBrightnessVal">100%</span></label><input type="range" id="enhanceBrightness" min="50" max="150" value="100"></div>
|
||||||
|
<div class="settings-field"><label>Контраст <span id="enhanceContrastVal">100%</span></label><input type="range" id="enhanceContrast" min="50" max="150" value="100"></div>
|
||||||
|
<div class="settings-field"><label>Насыщенность <span id="enhanceSaturateVal">100%</span></label><input type="range" id="enhanceSaturate" min="0" max="200" value="100"></div>
|
||||||
|
<div class="settings-field"><label>Резкость <span id="enhanceSharpVal">0%</span></label><input type="range" id="enhanceSharp" min="0" max="100" value="0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:6px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
||||||
|
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
|
||||||
|
<button type="button" class="btn-primary" id="enhanceSaveBtn">Применить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
|
|||||||
+11
-2
@@ -21,6 +21,9 @@ const sentText = document.getElementById('sentText');
|
|||||||
const sentTimer = document.getElementById('sentTimer');
|
const sentTimer = document.getElementById('sentTimer');
|
||||||
const SENT_KEY = 'whatido_entry_sent_at';
|
const SENT_KEY = 'whatido_entry_sent_at';
|
||||||
let entryCooldownMin = 30;
|
let entryCooldownMin = 30;
|
||||||
|
let captureWidth = 640;
|
||||||
|
let captureHeight = 480;
|
||||||
|
let captureQuality = 0.92;
|
||||||
let sentTimerId = null;
|
let sentTimerId = null;
|
||||||
|
|
||||||
function unlockForm() {
|
function unlockForm() {
|
||||||
@@ -156,6 +159,12 @@ if (params.get('nameInput')) {
|
|||||||
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
|
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
|
||||||
const c = parseInt(f.spam_interval_min, 10);
|
const c = parseInt(f.spam_interval_min, 10);
|
||||||
if (Number.isFinite(c) && c >= 1) entryCooldownMin = c;
|
if (Number.isFinite(c) && c >= 1) entryCooldownMin = c;
|
||||||
|
const w = parseInt(f.photo_capture_width, 10);
|
||||||
|
const h = parseInt(f.photo_capture_height, 10);
|
||||||
|
if (Number.isFinite(w) && w >= 160 && w <= 4096) captureWidth = w;
|
||||||
|
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
||||||
|
const q = parseFloat(f.photo_capture_quality);
|
||||||
|
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
checkPreviousSend();
|
checkPreviousSend();
|
||||||
})();
|
})();
|
||||||
@@ -175,7 +184,7 @@ function removePhoto() {
|
|||||||
|
|
||||||
async function openCam() {
|
async function openCam() {
|
||||||
try {
|
try {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } });
|
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
|
||||||
camVideo.srcObject = stream;
|
camVideo.srcObject = stream;
|
||||||
camModal.classList.add('open');
|
camModal.classList.add('open');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -199,7 +208,7 @@ function capture() {
|
|||||||
capturedBlob = blob;
|
capturedBlob = blob;
|
||||||
showPreview(URL.createObjectURL(blob));
|
showPreview(URL.createObjectURL(blob));
|
||||||
closeCam();
|
closeCam();
|
||||||
}, 'image/jpeg', 0.85);
|
}, 'image/jpeg', captureQuality);
|
||||||
}
|
}
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ function gridEntryHTML(e) {
|
|||||||
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||||||
|
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
||||||
<button class="del" data-del="${e.id}" title="Удалить">×</button>
|
<button class="del" data-del="${e.id}" title="Удалить">×</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -127,6 +128,7 @@ function cardEntryHTML(e) {
|
|||||||
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
|
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
|
||||||
<span>
|
<span>
|
||||||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||||||
|
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
||||||
<button class="del" data-del="${e.id}" title="Удалить">×</button>
|
<button class="del" data-del="${e.id}" title="Удалить">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -416,6 +418,172 @@ function closeEdit() {
|
|||||||
editDeletePhoto = false;
|
editDeletePhoto = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let enhanceEntryId = null;
|
||||||
|
let enhanceSourceImg = null;
|
||||||
|
let enhanceTimer = null;
|
||||||
|
let enhanceSaving = 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;
|
||||||
|
['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}`;
|
||||||
|
const canvas = document.getElementById('enhanceCanvas');
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
const loading = document.getElementById('enhanceLoading');
|
||||||
|
loading.style.display = '';
|
||||||
|
loading.textContent = 'Загрузка...';
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
enhanceSourceImg = img;
|
||||||
|
renderEnhancePreview();
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
loading.textContent = 'Не удалось загрузить фото';
|
||||||
|
};
|
||||||
|
img.src = `${API}${mp}`;
|
||||||
|
document.getElementById('enhanceModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEnhance() {
|
||||||
|
document.getElementById('enhanceModal').classList.remove('open');
|
||||||
|
enhanceEntryId = null;
|
||||||
|
enhanceSourceImg = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEnhancePreview() {
|
||||||
|
if (!enhanceSourceImg) return;
|
||||||
|
const img = enhanceSourceImg;
|
||||||
|
const canvas = document.getElementById('enhanceCanvas');
|
||||||
|
const w = img.naturalWidth;
|
||||||
|
const h = img.naturalHeight;
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const b = document.getElementById('enhanceBrightness').value;
|
||||||
|
const c = document.getElementById('enhanceContrast').value;
|
||||||
|
const s = document.getElementById('enhanceSaturate').value;
|
||||||
|
ctx.filter = `brightness(${b}%) contrast(${c}%) saturate(${s}%)`;
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
ctx.filter = 'none';
|
||||||
|
const sharp = parseInt(document.getElementById('enhanceSharp').value, 10);
|
||||||
|
if (sharp > 0) {
|
||||||
|
const data = ctx.getImageData(0, 0, w, h);
|
||||||
|
const src = data.data;
|
||||||
|
const out = new Uint8ClampedArray(src);
|
||||||
|
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 * w + 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 - w * 4 + ch] - a * src[i + w * 4 + ch];
|
||||||
|
out[i + ch] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.data.set(out);
|
||||||
|
ctx.putImageData(data, 0, 0);
|
||||||
|
}
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
document.getElementById('enhanceLoading').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
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('enhanceSharpVal').textContent = document.getElementById('enhanceSharp').value + '%';
|
||||||
|
if (enhanceTimer) clearTimeout(enhanceTimer);
|
||||||
|
enhanceTimer = setTimeout(renderEnhancePreview, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 saturate = 106;
|
||||||
|
const sharp = 30;
|
||||||
|
document.getElementById('enhanceBrightness').value = brightness;
|
||||||
|
document.getElementById('enhanceContrast').value = contrast;
|
||||||
|
document.getElementById('enhanceSaturate').value = saturate;
|
||||||
|
document.getElementById('enhanceSharp').value = sharp;
|
||||||
|
scheduleEnhancePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEnhance() {
|
||||||
|
if (!enhanceEntryId || !enhanceSourceImg || enhanceSaving) return;
|
||||||
|
const canvas = document.getElementById('enhanceCanvas');
|
||||||
|
enhanceSaving = true;
|
||||||
|
const btn = document.getElementById('enhanceSaveBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Сохранение...';
|
||||||
|
try {
|
||||||
|
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/${enhanceEntryId}/photo/enhance`, { method: 'PUT', headers: hdr(), body: fd });
|
||||||
|
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;
|
||||||
|
btn.textContent = 'Применить';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function aiCorrect() {
|
async function aiCorrect() {
|
||||||
if (aiCorrecting) return;
|
if (aiCorrecting) return;
|
||||||
const textarea = document.getElementById('editDesc');
|
const textarea = document.getElementById('editDesc');
|
||||||
@@ -730,6 +898,8 @@ document.getElementById('grid').addEventListener('click', e => {
|
|||||||
if (det) { detachFile(parseInt(det.dataset.detach, 10)); return; }
|
if (det) { detachFile(parseInt(det.dataset.detach, 10)); return; }
|
||||||
const ed = e.target.closest('[data-edit]');
|
const ed = e.target.closest('[data-edit]');
|
||||||
if (ed) { openEdit(parseInt(ed.dataset.edit, 10)); return; }
|
if (ed) { openEdit(parseInt(ed.dataset.edit, 10)); return; }
|
||||||
|
const en = e.target.closest('[data-enhance]');
|
||||||
|
if (en) { openEnhance(parseInt(en.dataset.enhance, 10)); return; }
|
||||||
const dl = e.target.closest('[data-del]');
|
const dl = e.target.closest('[data-del]');
|
||||||
if (dl) { delEntry(parseInt(dl.dataset.del, 10)); }
|
if (dl) { delEntry(parseInt(dl.dataset.del, 10)); }
|
||||||
});
|
});
|
||||||
@@ -760,6 +930,14 @@ document.getElementById('editPhotoPreview').addEventListener('click', e => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
|
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
|
||||||
|
document.getElementById('enhanceCancelBtn').addEventListener('click', closeEnhance);
|
||||||
|
document.getElementById('enhanceAutoBtn').addEventListener('click', autoEnhance);
|
||||||
|
document.getElementById('enhanceSaveBtn').addEventListener('click', saveEnhance);
|
||||||
|
document.getElementById('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); });
|
||||||
|
document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview);
|
||||||
|
document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview);
|
||||||
|
document.getElementById('enhanceSaturate').addEventListener('input', scheduleEnhancePreview);
|
||||||
|
document.getElementById('enhanceSharp').addEventListener('input', scheduleEnhancePreview);
|
||||||
document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect);
|
document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect);
|
||||||
document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect);
|
document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect);
|
||||||
document.getElementById('aiCorrectModal').addEventListener('click', e => {
|
document.getElementById('aiCorrectModal').addEventListener('click', e => {
|
||||||
|
|||||||
+13
-3
@@ -1,4 +1,4 @@
|
|||||||
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText'];
|
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality'];
|
||||||
const SECTION_IDS = ['sec-system', 'sec-antispam', 'sec-share', 'sec-form', 'sec-ai', 'sec-backup', 'sec-bans'];
|
const SECTION_IDS = ['sec-system', 'sec-antispam', 'sec-share', 'sec-form', 'sec-ai', 'sec-backup', 'sec-bans'];
|
||||||
const SPAM_MAX = 10080;
|
const SPAM_MAX = 10080;
|
||||||
const BAN_REASONS = {
|
const BAN_REASONS = {
|
||||||
@@ -81,7 +81,9 @@ function updateDirty() {
|
|||||||
|
|
||||||
function bindDirty() {
|
function bindDirty() {
|
||||||
DIRTY_FIELDS.forEach(id => {
|
DIRTY_FIELDS.forEach(id => {
|
||||||
document.getElementById(id).addEventListener('input', updateDirty);
|
const el = document.getElementById(id);
|
||||||
|
el.addEventListener('input', updateDirty);
|
||||||
|
el.addEventListener('change', updateDirty);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +153,10 @@ async function loadSettings() {
|
|||||||
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
|
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
|
||||||
document.getElementById('shareShowPhotos').checked = s.share_show_group_photos !== 'false';
|
document.getElementById('shareShowPhotos').checked = s.share_show_group_photos !== 'false';
|
||||||
document.getElementById('cookieNoticeText').value = s.cookie_notice_text || '';
|
document.getElementById('cookieNoticeText').value = s.cookie_notice_text || '';
|
||||||
|
const pr = document.getElementById('photoResolution');
|
||||||
|
if (s.photo_capture_resolution) pr.value = s.photo_capture_resolution;
|
||||||
|
if (!pr.value) pr.value = '640x480';
|
||||||
|
document.getElementById('photoQuality').value = s.photo_capture_quality !== undefined && s.photo_capture_quality !== '' ? s.photo_capture_quality : '0.92';
|
||||||
snapshotDirty();
|
snapshotDirty();
|
||||||
updateSpamUi();
|
updateSpamUi();
|
||||||
loadAiQueue();
|
loadAiQueue();
|
||||||
@@ -274,7 +280,7 @@ async function loadAiProfiles() {
|
|||||||
<div class="ai-profile-item${data.active === p.id ? ' active' : ''}">
|
<div class="ai-profile-item${data.active === p.id ? ' active' : ''}">
|
||||||
<div class="pi-info">
|
<div class="pi-info">
|
||||||
<div class="pi-name">${esc(p.name)}${data.active === p.id ? ' <span class="pi-badge">по умолчанию</span>' : ''}</div>
|
<div class="pi-name">${esc(p.name)}${data.active === p.id ? ' <span class="pi-badge">по умолчанию</span>' : ''}</div>
|
||||||
<div class="pi-meta">${esc(p.model)} · ${esc(p.base_url)}</div>
|
<div class="pi-meta">${esc(p.model)} · ${esc(p.base_url)}${p.max_tokens ? ` · max_tokens: ${esc(p.max_tokens)}` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pi-actions">
|
<div class="pi-actions">
|
||||||
${p.native ? '' : `<button type="button" class="pi-btn" data-edit-profile="${esc(p.id)}">Изменить</button>`}
|
${p.native ? '' : `<button type="button" class="pi-btn" data-edit-profile="${esc(p.id)}">Изменить</button>`}
|
||||||
@@ -295,6 +301,7 @@ function openAiProfileForm(profile) {
|
|||||||
document.getElementById('aiProfileModel').value = profile ? profile.model : '';
|
document.getElementById('aiProfileModel').value = profile ? profile.model : '';
|
||||||
document.getElementById('aiProfileBaseUrl').value = profile ? profile.base_url : '';
|
document.getElementById('aiProfileBaseUrl').value = profile ? profile.base_url : '';
|
||||||
document.getElementById('aiProfileApiKey').value = '';
|
document.getElementById('aiProfileApiKey').value = '';
|
||||||
|
document.getElementById('aiProfileMaxTokens').value = profile && profile.max_tokens ? profile.max_tokens : '';
|
||||||
document.getElementById('aiProfileTestStatus').textContent = '';
|
document.getElementById('aiProfileTestStatus').textContent = '';
|
||||||
document.getElementById('aiProfileName').focus();
|
document.getElementById('aiProfileName').focus();
|
||||||
}
|
}
|
||||||
@@ -310,6 +317,7 @@ function aiProfileFormData() {
|
|||||||
model: document.getElementById('aiProfileModel').value,
|
model: document.getElementById('aiProfileModel').value,
|
||||||
base_url: document.getElementById('aiProfileBaseUrl').value,
|
base_url: document.getElementById('aiProfileBaseUrl').value,
|
||||||
api_key: document.getElementById('aiProfileApiKey').value,
|
api_key: document.getElementById('aiProfileApiKey').value,
|
||||||
|
max_tokens: document.getElementById('aiProfileMaxTokens').value.trim() === '' ? null : document.getElementById('aiProfileMaxTokens').value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +494,8 @@ async function saveSettings() {
|
|||||||
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
|
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
|
||||||
share_show_group_photos: document.getElementById('shareShowPhotos').checked ? 'true' : 'false',
|
share_show_group_photos: document.getElementById('shareShowPhotos').checked ? 'true' : 'false',
|
||||||
cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(),
|
cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(),
|
||||||
|
photo_capture_resolution: document.getElementById('photoResolution').value,
|
||||||
|
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
||||||
} })
|
} })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
|
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
|
||||||
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
||||||
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
||||||
|
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
||||||
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
||||||
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
||||||
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
|
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
|
||||||
@@ -169,6 +170,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-photo">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="camera"></i></div>
|
||||||
|
<h3>Фотография с веб-камеры</h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="photoResolution">Разрешение</label>
|
||||||
|
<select id="photoResolution" class="settings-input">
|
||||||
|
<option value="640x480">640 × 480 (по умолчанию)</option>
|
||||||
|
<option value="1280x720">1280 × 720 (HD)</option>
|
||||||
|
<option value="1920x1080">1920 × 1080 (Full HD)</option>
|
||||||
|
<option value="2560x1440">2560 × 1440 (QHD)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="photoQuality">Качество JPEG</label>
|
||||||
|
<div class="unit-row">
|
||||||
|
<input type="number" id="photoQuality" class="settings-input" min="0.5" max="1" step="0.01" value="0.92">
|
||||||
|
<span class="unit">0.5–1.0</span>
|
||||||
|
</div>
|
||||||
|
<span class="hint">Больше значение — лучше качество и крупнее файл</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card" id="sec-bans">
|
<div class="settings-card" id="sec-bans">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="shield-off"></i></div>
|
<div class="ic"><i data-lucide="shield-off"></i></div>
|
||||||
@@ -209,6 +237,11 @@
|
|||||||
<label for="aiProfileApiKey">API-ключ (если требуется)</label>
|
<label for="aiProfileApiKey">API-ключ (если требуется)</label>
|
||||||
<input type="password" id="aiProfileApiKey" class="settings-input" placeholder="sk-...">
|
<input type="password" id="aiProfileApiKey" class="settings-input" placeholder="sk-...">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="aiProfileMaxTokens">Max tokens (лимит ответа модели)</label>
|
||||||
|
<input type="number" id="aiProfileMaxTokens" class="settings-input" min="16" max="32768" placeholder="Авто (1024–4096)">
|
||||||
|
<span class="hint">Оставьте пустым для авто. Для reasoning-моделей (gpt-oss, deepseek-r1) нужно 1024+ — иначе рассуждения не уместятся и ответ придёт пустым.</span>
|
||||||
|
</div>
|
||||||
<div class="ai-test-status" id="aiProfileTestStatus"></div>
|
<div class="ai-test-status" id="aiProfileTestStatus"></div>
|
||||||
<div class="actions" style="flex-wrap:wrap">
|
<div class="actions" style="flex-wrap:wrap">
|
||||||
<button class="btn-primary" id="aiProfileSaveBtn" type="button">Сохранить</button>
|
<button class="btn-primary" id="aiProfileSaveBtn" type="button">Сохранить</button>
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ async function sendImageThumb(res, fp) {
|
|||||||
try {
|
try {
|
||||||
if (!fs.existsSync(tp)) {
|
if (!fs.existsSync(tp)) {
|
||||||
const tmp = tp + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
|
const tmp = tp + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
|
||||||
await sharp(fp).rotate().resize({ width: THUMB_WIDTH, withoutEnlargement: true }).webp({ quality: 72 }).toFile(tmp);
|
await sharp(fp).rotate().resize({ width: THUMB_WIDTH, withoutEnlargement: true }).webp({ quality: 85 }).toFile(tmp);
|
||||||
fs.renameSync(tmp, tp);
|
fs.renameSync(tmp, tp);
|
||||||
}
|
}
|
||||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
@@ -579,7 +579,7 @@ async function convertPhoto(file) {
|
|||||||
// HEIC/HEIF conversion
|
// HEIC/HEIF conversion
|
||||||
const outName = `${path.basename(file.path, path.extname(file.path))}.jpg`;
|
const outName = `${path.basename(file.path, path.extname(file.path))}.jpg`;
|
||||||
const outPath = path.join(path.dirname(file.path), outName);
|
const outPath = path.join(path.dirname(file.path), outName);
|
||||||
const jpeg = await heicConvert({ buffer: fs.readFileSync(file.path), format: 'JPEG', quality: 0.85 });
|
const jpeg = await heicConvert({ buffer: fs.readFileSync(file.path), format: 'JPEG', quality: 0.92 });
|
||||||
fs.writeFileSync(outPath, jpeg);
|
fs.writeFileSync(outPath, jpeg);
|
||||||
safeUnlink(file.path);
|
safeUnlink(file.path);
|
||||||
file.path = outPath;
|
file.path = outPath;
|
||||||
@@ -673,6 +673,20 @@ async function ensureEntryAiColumns() {
|
|||||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
|
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
|
||||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT`);
|
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT`);
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL`);
|
||||||
|
await pool.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF (TG_OP = 'INSERT') THEN
|
||||||
|
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
|
||||||
|
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
|
||||||
|
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql
|
||||||
|
`);
|
||||||
|
await pool.query(`DROP TRIGGER IF EXISTS trg_entries_notify ON entries`);
|
||||||
|
await pool.query(`CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries FOR EACH ROW EXECUTE FUNCTION notify_entries_changed()`);
|
||||||
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
|
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
|
||||||
const marker = await getSetting('ai_autocheck_migrated', '');
|
const marker = await getSetting('ai_autocheck_migrated', '');
|
||||||
if (marker !== '1') {
|
if (marker !== '1') {
|
||||||
@@ -951,10 +965,20 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
|
|||||||
|
|
||||||
app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
||||||
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
|
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
|
||||||
const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min'];
|
const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality'];
|
||||||
const defaults = { spam_interval_min: '30' };
|
const defaults = { spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92' };
|
||||||
const result = {};
|
const result = {};
|
||||||
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
|
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
|
||||||
|
const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution);
|
||||||
|
if (rm) {
|
||||||
|
result.photo_capture_width = rm[1];
|
||||||
|
result.photo_capture_height = rm[2];
|
||||||
|
} else {
|
||||||
|
result.photo_capture_width = '640';
|
||||||
|
result.photo_capture_height = '480';
|
||||||
|
}
|
||||||
|
const q = parseFloat(result.photo_capture_quality);
|
||||||
|
result.photo_capture_quality = Number.isFinite(q) && q >= 0.5 && q <= 1 ? String(q) : '0.92';
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
res.json(out);
|
res.json(out);
|
||||||
@@ -970,6 +994,17 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'spam_interval_min должен быть целым числом от 1 до 10080 (7 дней)' });
|
return res.status(400).json({ error: 'spam_interval_min должен быть целым числом от 1 до 10080 (7 дней)' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (key === 'photo_capture_resolution') {
|
||||||
|
if (!/^\d{2,5}x\d{2,5}$/.test(String(value))) {
|
||||||
|
return res.status(400).json({ error: 'photo_capture_resolution должен быть в формате ШИРИНАxВЫСОТА, например 640x480' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === 'photo_capture_quality') {
|
||||||
|
const q = parseFloat(String(value));
|
||||||
|
if (!Number.isFinite(q) || q < 0.5 || q > 1) {
|
||||||
|
return res.status(400).json({ error: 'photo_capture_quality должен быть числом от 0.5 до 1' });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -3627,6 +3662,40 @@ app.put('/api/entries/:id/photos/:photoId/main', requireAuth, async (req, res) =
|
|||||||
invalidateEntries();
|
invalidateEntries();
|
||||||
res.json({ ok: true, photo_path: rows[0].photo_path });
|
res.json({ ok: true, photo_path: rows[0].photo_path });
|
||||||
});
|
});
|
||||||
|
app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => { upload.single('photo')(req, res, next); }, async (req, res) => {
|
||||||
|
if (req.user.role !== 'admin') {
|
||||||
|
const acc = await entryAccessible(req.user, req.params.id);
|
||||||
|
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]);
|
||||||
|
if (!cur.length) {
|
||||||
|
if (req.file) safeUnlink(req.file.path);
|
||||||
|
return res.status(404).json({ error: 'Запись не найдена' });
|
||||||
|
}
|
||||||
|
const oldPath = cur[0].photo_path;
|
||||||
|
if (!oldPath) {
|
||||||
|
if (req.file) safeUnlink(req.file.path);
|
||||||
|
return res.status(400).json({ error: 'У записи нет фото' });
|
||||||
|
}
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'Нет файла' });
|
||||||
|
await convertPhoto(req.file);
|
||||||
|
const newPath = `/uploads/${req.file.filename}`;
|
||||||
|
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [newPath, req.params.id]);
|
||||||
|
await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, req.params.id, oldPath]);
|
||||||
|
safeUnlink(oldPath);
|
||||||
|
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
|
||||||
|
safeUnlink(oldThumb);
|
||||||
|
await logAudit(req, 'entry.photo.enhance', { entry_id: req.params.id, old_path: oldPath, new_path: newPath });
|
||||||
|
invalidateEntries();
|
||||||
|
res.json({ ok: true, photo_path: newPath });
|
||||||
|
} catch (e) {
|
||||||
|
if (req.file) safeUnlink(req.file.path);
|
||||||
|
console.error('PUT /api/entries/:id/photo/enhance:', e);
|
||||||
|
res.status(500).json({ error: 'Ошибка замены фото' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/entries/:id', requireAuth, async (req, res) => {
|
app.delete('/api/entries/:id', requireAuth, async (req, res) => {
|
||||||
if (req.user.role !== 'admin') {
|
if (req.user.role !== 'admin') {
|
||||||
@@ -3695,11 +3764,18 @@ function validateProfileBody(body) {
|
|||||||
const base = String(body?.base_url || '').trim().replace(/\/+$/, '');
|
const base = String(body?.base_url || '').trim().replace(/\/+$/, '');
|
||||||
const model = String(body?.model || '').trim();
|
const model = String(body?.model || '').trim();
|
||||||
const apiKey = String(body?.api_key || '').trim();
|
const apiKey = String(body?.api_key || '').trim();
|
||||||
|
let maxTokens = null;
|
||||||
|
if (body?.max_tokens !== null && body?.max_tokens !== undefined && String(body.max_tokens).trim() !== '') {
|
||||||
|
maxTokens = parseInt(String(body.max_tokens), 10);
|
||||||
|
if (!Number.isFinite(maxTokens) || maxTokens < 16 || maxTokens > 32768) {
|
||||||
|
return { error: 'max_tokens должен быть целым числом от 16 до 32768' };
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!name || name.length > 100) return { error: 'Укажите название профиля (до 100 символов)' };
|
if (!name || name.length > 100) return { error: 'Укажите название профиля (до 100 символов)' };
|
||||||
if (!/^https?:\/\//i.test(base) || base.length > 300) return { error: 'Base URL должен быть корректным http(s)://… (до 300 символов)' };
|
if (!/^https?:\/\//i.test(base) || base.length > 300) return { error: 'Base URL должен быть корректным http(s)://… (до 300 символов)' };
|
||||||
if (!model || model.length > 150) return { error: 'Укажите название модели (до 150 символов)' };
|
if (!model || model.length > 150) return { error: 'Укажите название модели (до 150 символов)' };
|
||||||
if (apiKey.length > 300) return { error: 'API-ключ слишком длинный' };
|
if (apiKey.length > 300) return { error: 'API-ключ слишком длинный' };
|
||||||
return { name, base_url: base, model, api_key: apiKey };
|
return { name, base_url: base, model, api_key: apiKey, max_tokens: maxTokens };
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/ai/profiles', requireAdmin, async (_, res) => {
|
app.get('/api/ai/profiles', requireAdmin, async (_, res) => {
|
||||||
|
|||||||
@@ -128,7 +128,12 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return (data.choices?.[0]?.message?.content || '').trim();
|
const choice = data.choices && data.choices[0];
|
||||||
|
const content = (choice && choice.message && choice.message.content || '').trim();
|
||||||
|
if (!content) {
|
||||||
|
throw new Error(`Модель вернула пустой ответ (finish_reason=${choice && choice.finish_reason || 'unknown'})`);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user