- user_settings allowlist: feedback_models (up to 10 personal model configs) - normalizeFeedbackModels() validation, UI block "Мои модели" in #fbPromptModal - GET /api/ai/profiles/options for tutors (no api_key), used by feedback/shorts - feedbacks columns ai_prompt/ai_model/ai_duration_ms + backup/restore support - "ИИ-запрос" popover on feedback card with status, model, timing and prompt - audit: feedback.update / feedback.ai.run / feedback.ai.error labels
688 lines
32 KiB
JavaScript
688 lines
32 KiB
JavaScript
(async function() {
|
||
const API = location.origin;
|
||
const PAGE_SIZE = 20;
|
||
let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = '', userSettings = { settings: {}, defaults: {} }, aiActive = 'native', myModels = [], modelEditIdx = null;
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||
|
||
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 del = f.deleted_at ? ' style="opacity:.5"' : '';
|
||
const day = fmtDay(f.feedback_date);
|
||
const hasAiInfo = f.ai_status !== 'none' || !!f.ai_prompt;
|
||
return `<div class="lesson-item feedback-item" data-id="${f.id}"${del}>
|
||
<div class="lesson-item-head">
|
||
<span class="lesson-author">${escapeHtml(f.group_name||'')}</span>
|
||
<span class="lesson-when">${fmtDate(f.feedback_date)}${day ? ` · ${day}` : ''}</span>
|
||
${f.deleted_at ? '<span class="lesson-badge">Удалён</span>' : ''}
|
||
${aiBadge(f.ai_status)}
|
||
${f.sent_at ? '<span class="ai-badge ai-badge-sent">Отправлено</span>' : ''}
|
||
<div class="lesson-actions">
|
||
${hasAiInfo ? `<button type="button" class="btn-link" data-aiinfo="${f.id}">ИИ-запрос</button>` : ''}
|
||
${f.ai_status === 'error' ? `<button type="button" class="btn-link" data-retry="${f.id}">Повторить</button>` : ''}
|
||
${f.deleted_at ? '' : `<button type="button" class="btn-link" data-sent="${f.id}">${f.sent_at ? 'Вернуть' : 'Отправлено'}</button>`}
|
||
<button type="button" class="btn-link" data-edit="${f.id}">Изменить</button>
|
||
<button type="button" class="btn-link danger" data-del="${f.id}">${f.deleted_at ? 'Удалить навсегда' : 'Удалить'}</button>
|
||
${f.deleted_at ? `<button type="button" class="btn-link" data-restore="${f.id}">Восстановить</button>` : ''}
|
||
</div>
|
||
</div>
|
||
${f.resident ? `<div class="lesson-group">${escapeHtml(f.resident)}</div>` : ''}
|
||
${f.topic ? `<div class="lesson-topic">${escapeHtml(f.topic)}</div>` : ''}
|
||
${text ? `<div class="lesson-text">${escapeHtml(text)}</div>` : ''}
|
||
<div class="fb-ai-pop" id="fbAiPop-${f.id}" hidden>${fbAiInfoHtml(f)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function fmtDurMs(ms) {
|
||
if (!Number.isFinite(ms) || ms < 0) return '';
|
||
return ms >= 1000 ? `${(ms / 1000).toFixed(1)} с` : `${Math.round(ms)} мс`;
|
||
}
|
||
|
||
function fmtTs(v) {
|
||
if (!v) return '';
|
||
try { if (typeof fmtFull === 'function') return fmtFull(v) || String(v); } catch (e) {}
|
||
return String(v);
|
||
}
|
||
|
||
function fbAiInfoHtml(f) {
|
||
let html = `<div class="fb-ai-row"><span>Статус</span>${aiBadge(f.ai_status)}</div>`;
|
||
html += `<div class="fb-ai-row"><span>Модель</span><b>${escapeHtml(f.ai_model || '—')}</b></div>`;
|
||
const dur = fmtDurMs(f.ai_duration_ms);
|
||
html += `<div class="fb-ai-row"><span>Время ответа</span><b>${escapeHtml(dur || '—')}</b></div>`;
|
||
if (f.ai_checked_at) html += `<div class="fb-ai-row"><span>Проверено</span><b>${escapeHtml(fmtTs(f.ai_checked_at))}</b></div>`;
|
||
if (f.ai_error) html += `<div class="fb-ai-err"><b>Ошибка:</b> ${escapeHtml(f.ai_error)}</div>`;
|
||
if (f.ai_prompt) {
|
||
html += `<div class="fb-ai-label">Промпт, отправленный в модель</div><pre>${escapeHtml(f.ai_prompt)}</pre>`;
|
||
} else {
|
||
html += '<div class="fb-ai-empty">Нет данных о запуске ИИ</div>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function closeAiPops() {
|
||
document.querySelectorAll('.fb-ai-pop').forEach(p => { p.hidden = true; });
|
||
}
|
||
|
||
document.addEventListener('click', e => {
|
||
if (e.target.closest('[data-aiinfo]') || e.target.closest('.fb-ai-pop')) return;
|
||
closeAiPops();
|
||
});
|
||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAiPops(); });
|
||
|
||
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');
|
||
closePromptModal();
|
||
}
|
||
|
||
function closePromptModal() {
|
||
const m = document.getElementById('fbPromptModal');
|
||
if (m) m.classList.remove('open');
|
||
}
|
||
|
||
function updatePromptCount() {
|
||
const el = document.getElementById('fbPromptCount');
|
||
const ta = document.getElementById('fbPromptText');
|
||
if (el && ta) el.textContent = `${ta.value.length} / 8000`;
|
||
}
|
||
|
||
async function loadUserSettings() {
|
||
try {
|
||
const r = await fetch(`${API}/api/user-settings`, { headers: hdr() });
|
||
if (!r.ok) return;
|
||
const j = await r.json();
|
||
userSettings.settings = j.settings || {};
|
||
userSettings.defaults = j.defaults || {};
|
||
parseMyModels();
|
||
} catch (e) {}
|
||
}
|
||
|
||
function parseMyModels() {
|
||
try { myModels = JSON.parse(userSettings.settings.feedback_models || '[]'); } catch (e) { myModels = []; }
|
||
if (!Array.isArray(myModels)) myModels = [];
|
||
}
|
||
|
||
function renderModelsList() {
|
||
const el = document.getElementById('fbModelsList');
|
||
if (!el) return;
|
||
const cnt = document.getElementById('fbModelsCount');
|
||
if (cnt) cnt.textContent = myModels.length ? ` (${myModels.length})` : '';
|
||
if (!myModels.length) {
|
||
el.innerHTML = '<div class="hint">Своих моделей нет — используются системные</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = myModels.map((m, i) => `
|
||
<div style="display:flex;justify-content:space-between;gap:8px;align-items:center;padding:4px 0">
|
||
<span>${escapeHtml(m.name)} — <span class="muted">${escapeHtml(m.model)}</span></span>
|
||
<span style="display:flex;gap:6px">
|
||
<button type="button" class="btn-xs" data-medit="${i}">Изменить</button>
|
||
<button type="button" class="btn-xs" data-mdel="${i}">Удалить</button>
|
||
</span>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function showModelForm(idx) {
|
||
modelEditIdx = idx;
|
||
const m = idx == null ? null : myModels[idx];
|
||
document.getElementById('fbMName').value = m ? m.name : '';
|
||
document.getElementById('fbMModel').value = m ? m.model : '';
|
||
document.getElementById('fbMUrl').value = m ? m.base_url : '';
|
||
document.getElementById('fbMKey').value = m ? (m.api_key || '') : '';
|
||
document.getElementById('fbMMaxTok').value = m && m.max_tokens ? m.max_tokens : '';
|
||
document.getElementById('fbModelEdit').hidden = false;
|
||
document.getElementById('fbMName').focus();
|
||
}
|
||
|
||
function hideModelForm() {
|
||
const el = document.getElementById('fbModelEdit');
|
||
if (el) el.hidden = true;
|
||
modelEditIdx = null;
|
||
}
|
||
|
||
function saveModelForm() {
|
||
const name = document.getElementById('fbMName').value.trim();
|
||
const model = document.getElementById('fbMModel').value.trim();
|
||
const base = document.getElementById('fbMUrl').value.trim().replace(/\/+$/, '');
|
||
const key = document.getElementById('fbMKey').value.trim();
|
||
const tok = document.getElementById('fbMMaxTok').value.trim();
|
||
if (!name) return alert('Укажите название модели');
|
||
if (!model) return alert('Укажите имя модели');
|
||
try {
|
||
const u = new URL(base);
|
||
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad');
|
||
} catch (e) { return alert('Base URL должен начинаться с http:// или https://'); }
|
||
let maxTokens = null;
|
||
if (tok) {
|
||
maxTokens = parseInt(tok, 10);
|
||
if (!Number.isFinite(maxTokens) || maxTokens < 16 || maxTokens > 131072) return alert('max_tokens: 16–131072');
|
||
}
|
||
if (modelEditIdx == null && myModels.length >= 10) return alert('Не больше 10 моделей');
|
||
const entry = {
|
||
id: modelEditIdx == null ? `me-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` : myModels[modelEditIdx].id,
|
||
name, model, base_url: base, api_key: key, max_tokens: maxTokens
|
||
};
|
||
if (modelEditIdx == null) myModels.push(entry); else myModels[modelEditIdx] = entry;
|
||
hideModelForm();
|
||
renderModelsList();
|
||
refreshModelSelects();
|
||
}
|
||
|
||
function deleteModel(i) {
|
||
const m = myModels[i];
|
||
if (!m) return;
|
||
if (!confirm(`Удалить модель «${m.name}»?`)) return;
|
||
myModels.splice(i, 1);
|
||
const dst = document.getElementById('fbDefaultModel');
|
||
if (dst && dst.value === m.id) dst.value = '';
|
||
renderModelsList();
|
||
refreshModelSelects();
|
||
}
|
||
|
||
function fillDefaultModelOptions(preserve) {
|
||
const src = document.getElementById('fbModel');
|
||
const dst = document.getElementById('fbDefaultModel');
|
||
if (!src || !dst) return;
|
||
dst.innerHTML = '<option value="">Активная модель по умолчанию</option>' + src.innerHTML;
|
||
dst.value = preserve || '';
|
||
if (preserve && dst.value !== preserve) {
|
||
const o = document.createElement('option');
|
||
o.value = preserve;
|
||
o.textContent = `${preserve} (недоступна)`;
|
||
dst.appendChild(o);
|
||
dst.value = preserve;
|
||
}
|
||
}
|
||
|
||
async function refreshModelSelects() {
|
||
const keep = document.getElementById('fbDefaultModel').value;
|
||
await loadAiProfiles();
|
||
fillDefaultModelOptions(keep);
|
||
}
|
||
|
||
function openPromptModal() {
|
||
fillDefaultModelOptions(userSettings.settings.feedback_profile);
|
||
const ta = document.getElementById('fbPromptText');
|
||
ta.value = userSettings.settings.feedback_prompt || '';
|
||
ta.placeholder = userSettings.defaults.feedback_prompt || 'Системный шаблон';
|
||
document.getElementById('fbPromptStatus').hidden = true;
|
||
hideModelForm();
|
||
renderModelsList();
|
||
updatePromptCount();
|
||
document.getElementById('fbPromptModal').classList.add('open');
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
async function savePromptSettings() {
|
||
const st = document.getElementById('fbPromptStatus');
|
||
if (!document.getElementById('fbModel').options.length) {
|
||
st.hidden = false;
|
||
st.innerHTML = '<span class="ai-badge error">Список моделей не загружен — сохранение отменено</span>';
|
||
return;
|
||
}
|
||
const body = {
|
||
settings: {
|
||
feedback_prompt: document.getElementById('fbPromptText').value.trim(),
|
||
feedback_profile: document.getElementById('fbDefaultModel').value || '',
|
||
feedback_models: myModels.length ? JSON.stringify(myModels) : '',
|
||
}
|
||
};
|
||
try {
|
||
const r = await fetch(`${API}/api/user-settings`, { method: 'PUT', headers: {...hdr(), 'Content-Type': 'application/json'}, body: JSON.stringify(body) });
|
||
const j = await r.json().catch(() => ({}));
|
||
if (!r.ok) {
|
||
st.hidden = false;
|
||
st.innerHTML = `<span class="ai-badge error">${escapeHtml(j.error || 'Не удалось сохранить')}</span>`;
|
||
return;
|
||
}
|
||
userSettings.settings = j.settings || {};
|
||
userSettings.defaults = j.defaults || userSettings.defaults;
|
||
parseMyModels();
|
||
renderModelsList();
|
||
await loadAiProfiles();
|
||
st.hidden = false;
|
||
st.innerHTML = '<span class="ai-badge done">Сохранено</span>';
|
||
setTimeout(closePromptModal, 700);
|
||
} catch (e) {
|
||
st.hidden = false;
|
||
st.innerHTML = '<span class="ai-badge error">Не удалось сохранить</span>';
|
||
}
|
||
}
|
||
|
||
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/options`, { headers: hdr() });
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
const sel = document.getElementById('fbModel');
|
||
if (!sel) return;
|
||
const keep = sel.value;
|
||
sel.innerHTML = '';
|
||
if (myModels.length) {
|
||
const mine = document.createElement('optgroup');
|
||
mine.label = 'Мои модели';
|
||
myModels.forEach(m => {
|
||
const o = document.createElement('option');
|
||
o.value = m.id;
|
||
o.textContent = m.name;
|
||
mine.appendChild(o);
|
||
});
|
||
sel.appendChild(mine);
|
||
}
|
||
const sys = document.createElement('optgroup');
|
||
sys.label = 'Системные';
|
||
const nativeOpt = document.createElement('option'); nativeOpt.value = 'native'; nativeOpt.textContent = 'Нативная (llama.cpp)'; sys.appendChild(nativeOpt);
|
||
(d.profiles||[]).forEach(p => { const o = document.createElement('option'); o.value = p.id; o.textContent = p.name; sys.appendChild(o); });
|
||
sel.appendChild(sys);
|
||
aiActive = d.active || 'native';
|
||
if (keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
|
||
else sel.value = aiActive;
|
||
}
|
||
} 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&used=0`, { headers: hdr() });
|
||
if (r.ok) {
|
||
const j = await r.json();
|
||
shortsListCache = j.items || []; renderShorts(shortsListCache);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function loadLastPastReview() {
|
||
const resident = document.getElementById('fbResident').value.trim();
|
||
if (!resident) return;
|
||
try {
|
||
const r = await fetch(`${API}/api/feedbacks/last?resident=${encodeURIComponent(resident)}`, { headers: hdr() });
|
||
if (r.ok) {
|
||
const row = await r.json();
|
||
if (row && row.text) document.getElementById('fbPastReview').value = row.text;
|
||
}
|
||
} 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;
|
||
const ms = document.getElementById('fbModel');
|
||
const pref = userSettings.settings.feedback_profile;
|
||
if (pref && [...ms.options].some(o => o.value === pref)) ms.value = pref;
|
||
else if (aiActive && [...ms.options].some(o => o.value === aiActive)) ms.value = aiActive;
|
||
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;
|
||
const fms = document.getElementById('fbModel');
|
||
if (f.ai_profile && [...fms.options].some(o => o.value === f.ai_profile)) fms.value = f.ai_profile;
|
||
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 loadUserSettings();
|
||
await loadAiProfiles();
|
||
await loadResidentNames();
|
||
loadTopics();
|
||
loadFeedbacks();
|
||
connectFeedbackLive();
|
||
document.getElementById('addFeedbackBtn').addEventListener('click', () => openModal({}));
|
||
document.getElementById('fbCancel').addEventListener('click', closeModal);
|
||
document.getElementById('fbPromptBtn').addEventListener('click', openPromptModal);
|
||
document.getElementById('fbPromptClose').addEventListener('click', closePromptModal);
|
||
document.getElementById('fbPromptModal').addEventListener('click', e => { if (e.target.id === 'fbPromptModal') closePromptModal(); });
|
||
document.getElementById('fbPromptText').addEventListener('input', updatePromptCount);
|
||
document.getElementById('fbPromptReset').addEventListener('click', () => {
|
||
document.getElementById('fbPromptText').value = '';
|
||
document.getElementById('fbDefaultModel').value = '';
|
||
document.getElementById('fbPromptStatus').hidden = true;
|
||
updatePromptCount();
|
||
});
|
||
document.getElementById('fbPromptSave').addEventListener('click', savePromptSettings);
|
||
document.getElementById('fbModelAdd').addEventListener('click', () => showModelForm(null));
|
||
document.getElementById('fbMCancel').addEventListener('click', hideModelForm);
|
||
document.getElementById('fbMSave').addEventListener('click', saveModelForm);
|
||
document.getElementById('fbModelsList').addEventListener('click', e => {
|
||
const ed = e.target.closest('[data-medit]');
|
||
if (ed) { showModelForm(parseInt(ed.dataset.medit, 10)); return; }
|
||
const del = e.target.closest('[data-mdel]');
|
||
if (del) deleteModel(parseInt(del.dataset.mdel, 10));
|
||
});
|
||
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';
|
||
if (e.target.checked) loadLastPastReview();
|
||
});
|
||
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 aiinfo = e.target.closest('[data-aiinfo]'); if (aiinfo) {
|
||
const pop = document.getElementById(`fbAiPop-${aiinfo.dataset.aiinfo}`);
|
||
if (pop) { const wasOpen = !pop.hidden; closeAiPops(); pop.hidden = wasOpen; }
|
||
return;
|
||
}
|
||
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;
|
||
const row = feedbacks.find(x => x.id === id);
|
||
const perm = !!(row && row.deleted_at);
|
||
if (!confirm(perm ? 'Удалить отзыв навсегда? Это действие нельзя отменить.' : 'Удалить отзыв?')) return;
|
||
const url = perm ? `${API}/api/feedbacks/${id}/permanent` : `${API}/api/feedbacks/${id}`;
|
||
try {
|
||
const r = await fetch(url, { method:'DELETE', headers: hdr() });
|
||
if (r.ok) { loadFeedbacks(); return; }
|
||
const err = await r.json().catch(() => null);
|
||
alert((err && err.error) || `Ошибка ${r.status}`);
|
||
} catch (err) { alert('Ошибка сети'); }
|
||
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(); return; }
|
||
const err = await r.json().catch(() => null);
|
||
alert((err && err.error) || `Ошибка ${r.status}`);
|
||
} catch (err) { alert('Ошибка сети'); }
|
||
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','fbDeletedToggle'].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); });
|
||
});
|
||
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();
|
||
}
|
||
})();
|