Files
WhatIDo/public/js/feedback.js
T
devandopenhands 98f24faaaf Add feedback system functionality
- Implement feedback management system in admin interface
- Add feedback processing and AI-based features
- Update user interface with new feedback components
- Modify server-side API endpoints for feedback handling
- Enhance worker.js for background feedback processing
- Update deployment configuration for feedback components

Co-authored-by: openhands <openhands@all-hands.dev>
2026-10-06 13:10:14 +03:00

399 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(async function() {
const API = location.origin;
const PAGE_SIZE = 20;
let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = '';
const topicReportCache = new Map();
function hdr() { const t = sessionStorage.getItem('authToken'); return t ? { 'X-Auth-Token': t } : {}; }
function fmtDate(d) { if (!d) return ''; if (window.datetime && datetime.fmtShortIso) return datetime.fmtShortIso(d); return d; }
function fmtDay(d) {
if (!d) return '';
try { const dt = new Date(d + 'T12:00:00'); return dt.toLocaleDateString('ru-RU', { weekday: 'short' }); } catch (e) { return ''; }
}
function escapeHtml(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;'); }
function aiBadge(s) {
switch (s) {
case 'pending': return '<span class="ai-badge pending">Ожидает</span>';
case 'processing': return '<span class="ai-badge processing">В обработке</span>';
case 'done': return '<span class="ai-badge done">Готово</span>';
case 'error': return '<span class="ai-badge error">Ошибка</span>';
case 'skipped': return '<span class="ai-badge skipped">Пропущен</span>';
default: return '<span class="ai-badge none">Без ИИ</span>';
}
}
function fbItemHtml(f) {
const text = (f.text||'').trim();
const short = text.length > 100 ? text.slice(0,97)+'…' : text;
const del = f.deleted_at ? ' style="opacity:.5"' : '';
return `<div class="feedback-item" data-id="${f.id}"${del}>
<div class="col">${fmtDate(f.feedback_date)}</div>
<div class="col">${escapeHtml(f.topic||'')}</div>
<div class="col">${escapeHtml(f.resident||'')}</div>
<div class="col">${fmtDay(f.feedback_date)}</div>
<div class="col">${escapeHtml(f.group_name||'')}</div>
<div class="col text">${escapeHtml(short)}</div>
<div class="actions">
${aiBadge(f.ai_status)}
${f.sent_at ? '<span class="ai-badge ai-badge-sent">Отправлено</span>' : ''}
${f.ai_status === 'error' ? `<button class="btn-xs" data-retry="${f.id}">Повторить</button>` : ''}
${f.deleted_at ? '' : `<button class="btn-xs ${f.sent_at ? 'sent-on' : ''}" data-sent="${f.id}">${f.sent_at ? 'Вернуть' : 'Отправлено'}</button>`}
<button class="btn-xs" data-edit="${f.id}">Изменить</button>
<button class="btn-xs danger" data-del="${f.id}">${f.deleted_at ? 'Удалить навсегда' : 'Удалить'}</button>
${f.deleted_at ? `<button class="btn-xs" data-restore="${f.id}">Восстановить</button>` : ''}
</div>
</div>`;
}
function ensureModal() {
let m = document.getElementById('feedbackModal');
if (m) return m;
m = document.createElement('div');
m.className = 'modal-overlay';
m.id = 'feedbackModal';
document.body.appendChild(m);
return m;
}
function openModal({id}) {
const m = document.getElementById('feedbackModal');
if (!m) return;
m.classList.add('open');
resetForm();
if (id) loadFeedback(id);
}
function closeModal() {
const m = document.getElementById('feedbackModal');
if (m) m.classList.remove('open');
}
function todayIso() {
try { if (window.datetime && datetime.todayIso) return datetime.todayIso(); } catch (e) {}
const d = new Date(); const y=d.getFullYear(), mo=String(d.getMonth()+1).padStart(2,'0'), da=String(d.getDate()).padStart(2,'0'); return `${y}-${mo}-${da}`;
}
let residentNamesLoaded = false;
const residentGroups = new Map();
const normName = s => String(s || '').trim().replace(/\s+/g, ' ').toLowerCase();
async function loadResidentNames() {
if (residentNamesLoaded) return;
try {
const r = await fetch(`${API}/api/students/names-with-groups`, { headers: hdr() });
if (!r.ok) return;
const rows = await r.json();
residentGroups.clear();
(rows||[]).forEach(x => {
const k = normName(x.name);
if (k && !residentGroups.has(k)) residentGroups.set(k, x.group_id);
});
const dl = document.getElementById('fbResidentList');
if (dl) dl.innerHTML = (rows||[]).map(x => `<option value="${escapeHtml(x.name)}">`).join('');
residentNamesLoaded = true;
} catch (e) {}
}
function applyResidentGroup() {
const k = normName(document.getElementById('fbResident').value);
if (!k || !residentGroups.has(k)) return;
const gid = residentGroups.get(k);
const sel = document.getElementById('fbGroup');
if (!gid || !sel) return;
if (![...sel.options].some(o => o.value === String(gid))) return;
sel.value = String(gid);
}
let topicsLoaded = false;
async function loadTopics() {
if (topicsLoaded) return;
try {
const r = await fetch(`${API}/api/lesson-reports/topics`, { headers: hdr() });
if (!r.ok) return;
const topics = await r.json();
const dl = document.getElementById('fbTopicList');
if (dl) dl.innerHTML = (topics||[]).map(t => `<option value="${escapeHtml(t)}">`).join('');
topicsLoaded = true;
} catch (e) {}
}
async function applyTopicText() {
const topicEl = document.getElementById('fbTopic');
const textEl = document.getElementById('fbText');
const hint = document.getElementById('fbTextHint');
const topic = topicEl.value.trim();
if (!topic) return;
let rep = topicReportCache.get(topic);
if (rep === undefined) {
try {
const r = await fetch(`${API}/api/lesson-reports/by-topic?topic=${encodeURIComponent(topic)}`, { headers: hdr() });
rep = r.ok ? (await r.json()).report : null;
} catch (e) { return; }
topicReportCache.set(topic, rep);
}
const next = (rep && rep.text || '').trim();
if (!next) return;
const cur = textEl.value;
if (cur.trim() && cur !== lastFbAutoText) return;
textEl.value = next;
lastFbAutoText = next;
hint.textContent = `${next.length} / 5000`;
}
async function loadGroups() {
if (groupsLoaded) return;
try {
const r = await fetch(`${API}/api/groups`, { headers: hdr() });
if (r.ok) {
const list = await r.json();
const sel = document.getElementById('fbGroup');
const fsel = document.getElementById('fbGroupFilter');
if (sel) sel.innerHTML = '<option value="">-- выберите --</option>';
if (fsel) fsel.innerHTML = '<option value="">Все группы</option>';
(list||[]).filter(x=>!x.deleted_at).forEach(g => {
const opt = document.createElement('option'); opt.value = g.id; opt.textContent = g.name; sel && sel.appendChild(opt.cloneNode(true)); fsel && fsel.appendChild(opt);
});
groupsLoaded = true;
}
} catch (e) {}
}
async function loadAiProfiles() {
try {
const r = await fetch(`${API}/api/ai/profiles`, { headers: hdr() });
if (r.ok) {
const d = await r.json();
const sel = document.getElementById('fbModel');
if (!sel) return;
sel.innerHTML = '';
const nativeOpt = document.createElement('option'); nativeOpt.value = 'native'; nativeOpt.textContent = 'Нативная (llama.cpp)'; sel.appendChild(nativeOpt);
(d.profiles||[]).forEach(p => { const o = document.createElement('option'); o.value = p.id; o.textContent = p.name; sel.appendChild(o); });
if (d.active) sel.value = d.active;
}
} catch (e) {}
}
async function fetchShorts(q) {
if (!q || q.length < 2) { shortsListCache = []; renderShorts([]); return; }
try {
const r = await fetch(`${API}/api/shorts?search=${encodeURIComponent(q)}&limit=50&deleted=0`, { headers: hdr() });
if (r.ok) {
const j = await r.json();
shortsListCache = j.items || []; renderShorts(shortsListCache);
}
} catch (e) {}
}
function renderShorts(items) {
const wrap = document.getElementById('fbShortsWrap');
const list = document.getElementById('fbShortsList');
if (!list || !wrap) return;
if (!items.length) { wrap.style.display = 'none'; list.innerHTML = ''; return; }
wrap.style.display = '';
list.innerHTML = items.map(s => {
const text = (s.message||'').slice(0,120);
const topic = s.topic || '-';
return `<div class="item"><input type="checkbox" name="fbShort" value="${s.id}" checked><label>${escapeHtml(s.lesson_date||'')} – ${escapeHtml(topic)} – ${escapeHtml(text)}</label></div>`;
}).join('');
}
function resetForm() {
document.getElementById('fbId').value = '';
document.getElementById('fbDate').value = todayIso();
document.getElementById('fbGroup').value = '';
document.getElementById('fbResident').value = '';
document.getElementById('fbTopic').value = '';
document.getElementById('fbPastCheck').checked = false;
document.getElementById('fbPastReviewWrap').style.display = 'none';
document.getElementById('fbPastReview').value = '';
document.getElementById('fbShortsWrap').style.display = 'none';
document.getElementById('fbShortsList').innerHTML = '';
document.getElementById('fbText').value = '';
lastFbAutoText = '';
document.getElementById('fbTextHint').textContent = '0 / 5000';
document.getElementById('fbAiCheck').checked = true;
document.getElementById('fbAiStatus').hidden = true;
document.getElementById('fbModalTitle').textContent = 'Добавить отзыв';
document.getElementById('fbSubmit').textContent = 'Отправить в очередь';
}
async function loadFeedback(id) {
try {
const r = await fetch(`${API}/api/feedbacks/${id}`, { headers: hdr() });
if (r.ok) {
const f = await r.json();
document.getElementById('fbId').value = f.id;
document.getElementById('fbDate').value = f.feedback_date || todayIso();
document.getElementById('fbGroup').value = f.group_id || '';
document.getElementById('fbResident').value = f.resident || '';
document.getElementById('fbTopic').value = f.topic || '';
if (f.past_review) {
document.getElementById('fbPastCheck').checked = true;
document.getElementById('fbPastReviewWrap').style.display = '';
document.getElementById('fbPastReview').value = f.past_review;
}
document.getElementById('fbText').value = f.text || '';
lastFbAutoText = '';
document.getElementById('fbTextHint').textContent = `${(f.text||'').length} / 5000`;
document.getElementById('fbAiCheck').checked = (f.ai_status === 'pending' || f.ai_status === 'processing' || f.ai_status === 'none') ? true : false;
document.getElementById('fbModalTitle').textContent = 'Редактировать отзыв';
document.getElementById('fbSubmit').textContent = 'Сохранить';
if (f.short_message_ids && f.short_message_ids.length) {
try {
const list = [];
for (const sid of f.short_message_ids.slice(0,20)) {
const sr = await fetch(`${API}/api/shorts/${sid}`, { headers: hdr() });
if (sr.ok) list.push(await sr.json());
}
renderShorts(list);
} catch (e) {}
}
}
} catch (e) {}
}
async function loadFeedbacks() {
const el = document.getElementById('feedbackList');
const st = document.getElementById('feedbackState');
if (!el) return;
el.innerHTML = '<div class="muted">Загрузка…</div>';
const q = new URLSearchParams();
q.set('limit', PAGE_SIZE); q.set('offset', (page-1)*PAGE_SIZE);
const s = document.getElementById('fbSearch').value.trim(); if (s) q.set('search', s);
const g = document.getElementById('fbGroupFilter').value; if (g) q.set('group_id', g);
const stv = document.getElementById('fbStatusFilter').value; if (stv) q.set('status', stv);
const df = document.getElementById('fbDateFrom').value; if (df) q.set('date_from', df);
const dt = document.getElementById('fbDateTo').value; if (dt) q.set('date_to', dt);
if (document.getElementById('fbDeletedToggle').checked) q.set('deleted', '1');
try {
const r = await fetch(`${API}/api/feedbacks?${q.toString()}`, { headers: hdr() });
if (r.ok) {
const j = await r.json();
feedbacks = j.items || [];
total = j.total || 0;
el.innerHTML = feedbacks.length ? feedbacks.map(fbItemHtml).join('') : '<div class="muted">Список пуст</div>';
if (st) st.textContent = `Всего: ${total}`;
renderPager(total, page, PAGE_SIZE, p => { page = p; loadFeedbacks(); });
} else { el.innerHTML = '<div class="muted">Ошибка загрузки</div>'; }
} catch (e) { el.innerHTML = '<div class="muted">Ошибка</div>'; }
if (window.lucide) lucide.createIcons();
}
function renderPager(total, cur, size, onChange) {
const el = document.getElementById('pager');
if (!el) return;
const pages = Math.max(1, Math.ceil(total/size));
if (pages <= 1) { el.innerHTML = ''; return; }
let html = '';
for (let i=1;i<=pages;i++) {
html += `<button class="btn-xs ${i===cur?'primary':''}" data-page="${i}">${i}</button>`;
}
el.innerHTML = html;
el.querySelectorAll('[data-page]').forEach(b => b.addEventListener('click', () => onChange(parseInt(b.dataset.page,10))));
}
document.addEventListener('DOMContentLoaded', async () => {
await checkAuth().catch(()=>{});
buildSidebar('feedback');
await loadGroups();
await loadAiProfiles();
await loadResidentNames();
loadTopics();
loadFeedbacks();
connectFeedbackLive();
document.getElementById('addFeedbackBtn').addEventListener('click', () => openModal({}));
document.getElementById('fbCancel').addEventListener('click', closeModal);
document.getElementById('feedbackModal').addEventListener('click', e => { if (e.target.id === 'feedbackModal') closeModal(); });
document.getElementById('fbForm').addEventListener('submit', async e => {
e.preventDefault();
const id = document.getElementById('fbId').value;
const body = {
feedback_date: document.getElementById('fbDate').value,
group_id: parseInt(document.getElementById('fbGroup').value,10),
resident: document.getElementById('fbResident').value.trim(),
topic: document.getElementById('fbTopic').value.trim(),
past_review: document.getElementById('fbPastCheck').checked ? document.getElementById('fbPastReview').value.trim() : null,
text: document.getElementById('fbText').value.trim(),
ai_check: document.getElementById('fbAiCheck').checked,
ai_profile: document.getElementById('fbModel').value || null,
};
const shortIds = [];
document.querySelectorAll('input[name="fbShort"]:checked').forEach(c => shortIds.push(parseInt(c.value,10)));
body.short_message_ids = shortIds;
const url = id ? `${API}/api/feedbacks/${id}` : `${API}/api/feedbacks`;
const method = id ? 'PUT' : 'POST';
try {
const r = await fetch(url, { method, headers: {...hdr(), 'Content-Type':'application/json'}, body: JSON.stringify(body) });
if (r.ok) { closeModal(); loadFeedbacks(); }
} catch (e) {}
});
document.getElementById('fbResident').addEventListener('input', () => {
clearTimeout(shortsFetchTimer);
const v = document.getElementById('fbResident').value.trim();
applyResidentGroup();
shortsFetchTimer = setTimeout(() => fetchShorts(v), 400);
});
document.getElementById('fbResident').addEventListener('change', applyResidentGroup);
document.getElementById('fbPastCheck').addEventListener('change', e => {
document.getElementById('fbPastReviewWrap').style.display = e.target.checked ? '' : 'none';
});
document.getElementById('fbText').addEventListener('input', () => {
document.getElementById('fbTextHint').textContent = `${document.getElementById('fbText').value.length} / 5000`;
});
document.getElementById('fbTopic').addEventListener('change', applyTopicText);
document.getElementById('feedbackList').addEventListener('click', async e => {
const sentBtn = e.target.closest('[data-sent]'); if (sentBtn) {
const id = parseInt(sentBtn.dataset.sent, 10);
if (!id) return;
const f = feedbacks.find(x => x.id === id);
const next = !(f && f.sent_at);
try { const r = await fetch(`${API}/api/feedbacks/${id}/sent`, { method:'PUT', headers: {...hdr(), 'Content-Type':'application/json'}, body: JSON.stringify({ sent: next }) }); if (r.ok) loadFeedbacks(); } catch (e) {}
return;
}
const edit = e.target.closest('[data-edit]'); if (edit) { openModal({id: parseInt(edit.dataset.edit,10)}); return; }
const del = e.target.closest('[data-del]'); if (del) {
const id = parseInt(del.dataset.id||del.dataset.del,10);
if (!id) return;
if (!confirm('Удалить отзыв?')) return;
try { const r = await fetch(`${API}/api/feedbacks/${id}`, { method:'DELETE', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {}
return;
}
const ret = e.target.closest('[data-restore]'); if (ret) {
const id = parseInt(ret.dataset.id||ret.dataset.restore,10);
if (!id) return;
try { const r = await fetch(`${API}/api/feedbacks/${id}/restore`, { method:'PUT', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {}
return;
}
const retry = e.target.closest('[data-retry]'); if (retry) {
const id = parseInt(retry.dataset.id||retry.dataset.retry,10);
if (!id) return;
try { const r = await fetch(`${API}/api/feedbacks/${id}/ai/retry`, { method:'POST', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {}
return;
}
});
['fbSearch','fbGroupFilter','fbStatusFilter','fbDateFrom','fbDateTo'].forEach(id => {
document.getElementById(id).addEventListener('change', () => { page = 1; loadFeedbacks(); });
if (id === 'fbSearch') document.getElementById(id).addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { page = 1; loadFeedbacks(); }, 350); });
});
document.getElementById('fbDeletedToggle').addEventListener('change', () => { page = 1; loadFeedbacks(); });
if (window.lucide) lucide.createIcons();
});
let liveSource = null, liveReconnectTimer = null;
function connectFeedbackLive() {
if (liveSource) { liveSource.close(); liveSource = null; }
const connect = () => {
const t = sessionStorage.getItem('authToken');
if (!t) return;
liveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`);
liveSource.addEventListener('feedbacks_changed', () => { page = 1; loadFeedbacks(); });
liveSource.addEventListener('feedback_status', () => { loadFeedbacks(); });
liveSource.onerror = () => {
if (liveSource) { liveSource.close(); liveSource = null; }
if (liveReconnectTimer) clearTimeout(liveReconnectTimer);
liveReconnectTimer = setTimeout(connect, 5000);
};
};
connect();
}
})();