v1.8.0: YandexGPT-only NLP, unknown questions system, callback dedup, consent flow fixes, DELETE cascade, UTC+3, auto-detect sidebar menu, tel:/mailto: links
This commit is contained in:
@@ -30,6 +30,8 @@
|
||||
{% if role_permissions.get('bot_consent', True) != False %}<a href="/service/bot-consent" class="{% if active_page == 'bot-consent' %}active{% endif %}">Согласия ФЗ-152</a>{% endif %}
|
||||
{% if role_permissions.get('bot_tickets', True) != False %}<a href="/service/bot-tickets" class="{% if active_page == 'bot-tickets' %}active{% endif %}">Заявки от бота</a>{% endif %}
|
||||
{% if role_permissions.get('bot_kb', True) != False %}<a href="/service/bot-kb" class="{% if active_page == 'bot-kb' %}active{% endif %}">База знаний</a>{% endif %}
|
||||
{% if role_permissions.get('bot_unknown', True) != False %}<a href="/service/bot-unknown" class="{% if active_page == 'bot-unknown' %}active{% endif %}">Неизвестные вопросы</a>{% endif %}
|
||||
{% if role_permissions.get('bot_dialogues', True) != False %}<a href="/service/bot-dialogues" class="{% if active_page == 'bot-dialogues' %}active{% endif %}">Диалоги</a>{% endif %}
|
||||
|
||||
<div class="sidebar-section">Контент</div>
|
||||
{% if role_permissions.get('blog', True) != False %}<a href="/service/blog" class="{% if active_page == 'blog' %}active{% endif %}">Управление блогом</a>{% endif %}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
{% extends "page.html" %}
|
||||
{% block page_content %}
|
||||
<div class="page-header">
|
||||
<h1>Диалоги чат-бота</h1>
|
||||
<div class="header-controls">
|
||||
<input type="text" id="search-input" placeholder="Поиск по ID или имени..." oninput="filterConversations()" class="filter-input">
|
||||
<button onclick="loadConversations()" class="btn btn-primary">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>var USER_ROLE = '{{ user.role }}';</script>
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<select id="status-filter" onchange="filterConversations()" class="filter-select">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="greeting">Приветствие</option>
|
||||
<option value="collecting_name">Сбор имени</option>
|
||||
<option value="collecting_phone">Сбор телефона</option>
|
||||
<option value="collecting_email">Сбор email</option>
|
||||
<option value="ticket_creation">Создание заявки</option>
|
||||
<option value="question">Вопрос</option>
|
||||
<option value="done">Завершён</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="conversations-container">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="dialog-modal" style="display:none;">
|
||||
<div class="modal" style="max-width:800px;width:90%;">
|
||||
<button type="button" class="modal-close" onclick="closeDialog()">✕</button>
|
||||
<h2 id="dialog-title">Диалог</h2>
|
||||
<div style="display:flex;gap:4px;margin-bottom:12px;">
|
||||
<button onclick="showTab('messages')" class="tab-btn active" id="tab-messages-btn">Сообщения</button>
|
||||
<button onclick="showTab('logs')" class="tab-btn" id="tab-logs-btn">Логи обработки</button>
|
||||
</div>
|
||||
<div id="tab-messages" class="tab-content"></div>
|
||||
<div id="tab-logs" class="tab-content" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="delete-modal">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<button type="button" class="modal-close" onclick="closeDeleteModal()">✕</button>
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить диалог?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
Диалог с <strong id="delete-conv-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="modal-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn" onclick="closeDeleteModal()">Отмена</button>
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.conv-card { background:var(--bg-card,#0A1628); border:1px solid var(--border,#253248); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:border-color 0.2s; }
|
||||
.conv-card:hover { border-color:var(--accent,#00ADEF); }
|
||||
.conv-card .conv-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; }
|
||||
.conv-card .conv-user { font-weight:600; color:var(--text-primary,#E8EDF2); }
|
||||
.conv-card .conv-id { font-size:11px; color:var(--text-muted,#6B8299); }
|
||||
.conv-card .conv-meta { display:flex; gap:12px; font-size:12px; color:var(--text-muted,#6B8299); }
|
||||
.conv-card .conv-state { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
|
||||
.conv-state-greeting { background:#1a3a4a; color:#6bc4e8; }
|
||||
.conv-state-collecting { background:#3a2a1a; color:#e8b86b; }
|
||||
.conv-state-question { background:#1a3a2a; color:#6be8a0; }
|
||||
.conv-state-done { background:#2a2a2a; color:#888; }
|
||||
.conv-state-default { background:#2a1a3a; color:#b86be8; }
|
||||
.msg-row { margin-bottom:8px; padding:8px 12px; border-radius:8px; max-width:80%; }
|
||||
.msg-in { background:var(--bg-input,#1e3044); margin-right:auto; border-radius:8px 8px 8px 0; }
|
||||
.msg-out { background:var(--accent,#00ADEF); margin-left:auto; border-radius:8px 8px 0 8px; color:#fff; }
|
||||
.msg-meta { font-size:10px; color:var(--text-muted,#6B8299); margin-top:3px; }
|
||||
.msg-out .msg-meta { color:rgba(255,255,255,0.7); }
|
||||
.log-row { padding:6px 0; border-bottom:1px solid var(--border,#253248); font-size:12px; }
|
||||
.log-step { font-weight:600; color:var(--accent,#00ADEF); }
|
||||
.log-ok { color:#6be8a0; }
|
||||
.log-error { color:#e86b6b; }
|
||||
.log-time { font-size:10px; color:var(--text-muted,#6B8299); }
|
||||
.tab-btn { padding:6px 14px; border:1px solid var(--border,#253248); background:transparent; color:var(--text-primary,#E8EDF2); border-radius:4px; cursor:pointer; font-size:12px; }
|
||||
.tab-btn.active { background:var(--accent,#00ADEF); border-color:var(--accent,#00ADEF); color:#fff; }
|
||||
.page-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
|
||||
.header-controls { display:flex; gap:8px; align-items:center; }
|
||||
.filter-input { padding:6px 10px; border:1px solid var(--border,#253248); border-radius:4px; background:var(--bg-body,#1B2838); color:var(--text-primary,#E8EDF2); font-size:12px; width:200px; }
|
||||
.filter-select { padding:6px 10px; border:1px solid var(--border,#253248); border-radius:4px; background:var(--bg-body,#1B2838); color:var(--text-primary,#E8EDF2); font-size:12px; }
|
||||
.conv-delete { background:none; border:none; cursor:pointer; font-size:14px; padding:2px 6px; border-radius:4px; opacity:0.5; transition:opacity 0.2s; margin-left:auto; }
|
||||
.conv-delete:hover { opacity:1; background:rgba(232,107,107,0.15); }
|
||||
.conv-header { display:flex; align-items:center; gap:8px; }
|
||||
#delete-modal .modal { max-width:420px; }
|
||||
#delete-modal h2 { font-size:16px; margin-bottom:8px; }
|
||||
#delete-modal p { color:var(--text-secondary); font-size:14px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
var conversations = [];
|
||||
var currentConvId = null;
|
||||
|
||||
function loadConversations(){
|
||||
var container = document.getElementById('conversations-container');
|
||||
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||||
fetch('/api/bot/conversations').then(function(r){ return r.json() }).then(function(data){
|
||||
if(!Array.isArray(data)){
|
||||
throw new Error(data && data.error ? data.error : 'Неверный формат ответа');
|
||||
}
|
||||
conversations = data;
|
||||
renderConversations(data);
|
||||
}).catch(function(err){
|
||||
container.innerHTML = '<div class="error-message">Ошибка загрузки: ' + err.message + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderConversations(data){
|
||||
var container = document.getElementById('conversations-container');
|
||||
if(!data || data.length === 0){
|
||||
container.innerHTML = '<div class="empty-state">Нет диалогов</div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
var filter = document.getElementById('search-input').value.toLowerCase();
|
||||
var statusFilter = document.getElementById('status-filter').value;
|
||||
data.forEach(function(c){
|
||||
var userName = c.user_name.toLowerCase();
|
||||
var userId = String(c.user_id);
|
||||
var state = c.state || '';
|
||||
if(filter && !userName.includes(filter) && !userId.includes(filter)) return;
|
||||
if(statusFilter && state !== statusFilter) return;
|
||||
var stateClass = 'conv-state-default';
|
||||
if(state === 'greeting') stateClass = 'conv-state-greeting';
|
||||
else if(state.startsWith('collecting')) stateClass = 'conv-state-collecting';
|
||||
else if(state === 'question') stateClass = 'conv-state-question';
|
||||
else if(state === 'done') stateClass = 'conv-state-done';
|
||||
var stateLabel = state || '—';
|
||||
html += '<div class="conv-card" onclick="openDialog(' + c.id + ')">';
|
||||
html += '<div class="conv-header"><span class="conv-user">' + escapeHtml(c.user_name) + '</span><span class="conv-id">ID: ' + c.id + '</span>';
|
||||
if(USER_ROLE === 'owner'){
|
||||
html += '<button class="conv-delete" onclick="event.stopPropagation();openDeleteModal(' + c.id + ', \'' + escapeHtml(c.user_name).replace(/'/g, "\\'") + '\')" title="Удалить диалог">🗑</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
html += '<div class="conv-meta">';
|
||||
html += '<span class="conv-state ' + stateClass + '">' + escapeHtml(stateLabel) + '</span>';
|
||||
html += '<span>' + c.message_count + ' сообщ.</span>';
|
||||
html += '<span>intent: ' + escapeHtml(c.intent || '—') + '</span>';
|
||||
html += '<span>' + (c.created_at || '').substring(0, 16).replace('T', ' ') + '</span>';
|
||||
html += '</div></div>';
|
||||
});
|
||||
container.innerHTML = html || '<div class="empty-state">Нет диалогов по фильтру</div>';
|
||||
}
|
||||
|
||||
function filterConversations(){
|
||||
renderConversations(conversations);
|
||||
}
|
||||
|
||||
function openDialog(convId){
|
||||
currentConvId = convId;
|
||||
document.getElementById('dialog-modal').style.display = 'flex';
|
||||
var conv = conversations.find(function(c){ return c.id === convId; });
|
||||
document.getElementById('dialog-title').textContent = 'Диалог с ' + (conv ? conv.user_name : '#' + convId);
|
||||
showTab('messages');
|
||||
loadMessages(convId);
|
||||
}
|
||||
|
||||
function closeDialog(){
|
||||
document.getElementById('dialog-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
function showTab(tab){
|
||||
document.querySelectorAll('.tab-content').forEach(function(el){ el.style.display = 'none'; });
|
||||
document.querySelectorAll('.tab-btn').forEach(function(el){ el.classList.remove('active'); });
|
||||
document.getElementById('tab-' + tab).style.display = 'block';
|
||||
document.getElementById('tab-' + tab + '-btn').classList.add('active');
|
||||
if(tab === 'logs' && currentConvId) loadLogs(currentConvId);
|
||||
}
|
||||
|
||||
function loadMessages(convId){
|
||||
var container = document.getElementById('tab-messages');
|
||||
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||||
fetch('/api/bot/conversations/' + convId + '/messages').then(function(r){ return r.json() }).then(function(data){
|
||||
if(!Array.isArray(data)){
|
||||
throw new Error(data && data.error ? data.error : 'Неверный формат ответа');
|
||||
}
|
||||
if(data.length === 0){
|
||||
container.innerHTML = '<div class="empty-state">Нет сообщений</div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
data.forEach(function(m){
|
||||
var dir = m.direction === 'incoming' ? 'msg-in' : 'msg-out';
|
||||
var label = m.direction === 'incoming' ? 'Пользователь' : 'Бот';
|
||||
html += '<div class="msg-row ' + dir + '">';
|
||||
html += '<div>' + escapeHtml(m.text || '') + '</div>';
|
||||
html += '<div class="msg-meta">' + label + ' · ' + (m.created_at || '').substring(0, 19).replace('T', ' ') + '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}).catch(function(err){
|
||||
container.innerHTML = '<div class="error-message">Ошибка: ' + err + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function loadLogs(convId){
|
||||
var container = document.getElementById('tab-logs');
|
||||
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||||
fetch('/api/bot/conversations/' + convId + '/logs').then(function(r){ return r.json() }).then(function(data){
|
||||
if(!Array.isArray(data)){
|
||||
throw new Error(data && data.error ? data.error : 'Неверный формат ответа');
|
||||
}
|
||||
if(data.length === 0){
|
||||
container.innerHTML = '<div class="empty-state">Нет логов</div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
data.forEach(function(l){
|
||||
var statusClass = l.status === 'error' ? 'log-error' : 'log-ok';
|
||||
html += '<div class="log-row">';
|
||||
html += '<span class="log-step">' + escapeHtml(l.step) + '</span> ';
|
||||
html += '<span class="' + statusClass + '">[' + l.status + ']</span> ';
|
||||
html += escapeHtml(l.message || '');
|
||||
if(l.detail) html += '<div style="color:var(--text-muted,#6B8299);font-size:11px;margin-top:2px;">' + escapeHtml(l.detail) + '</div>';
|
||||
html += '<div class="log-time">' + (l.created_at || '').substring(0, 19).replace('T', ' ') + '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}).catch(function(err){
|
||||
container.innerHTML = '<div class="error-message">Ошибка: ' + err + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str){
|
||||
if(!str) return '';
|
||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
var deleteConvId = null;
|
||||
|
||||
function openDeleteModal(convId, convName){
|
||||
deleteConvId = convId;
|
||||
document.getElementById('delete-conv-name').textContent = convName || '#' + convId;
|
||||
document.getElementById('delete-modal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeDeleteModal(){
|
||||
document.getElementById('delete-modal').classList.remove('open');
|
||||
deleteConvId = null;
|
||||
}
|
||||
|
||||
function confirmDelete(){
|
||||
if(!deleteConvId) return;
|
||||
var id = deleteConvId;
|
||||
closeDeleteModal();
|
||||
fetch('/api/bot/conversations/' + id, { method: 'DELETE' })
|
||||
.then(function(r){ return r.json() })
|
||||
.then(function(d){
|
||||
if(d.ok){
|
||||
showNotification('Диалог #' + id + ' удалён', 'success');
|
||||
conversations = conversations.filter(function(c){ return c.id !== id; });
|
||||
renderConversations(conversations);
|
||||
} else {
|
||||
showNotification('Ошибка: ' + (d.error || 'неизвестная'), 'error');
|
||||
}
|
||||
}).catch(function(err){
|
||||
showNotification('Ошибка соединения', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadConversations);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -44,12 +44,12 @@
|
||||
<textarea name="assistant_role" id="assistant_role" placeholder="Описание роли бота..."></textarea>
|
||||
<div class="hint">Будет использована для формирования системного промпта YandexGPT</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group full-width">
|
||||
<label>MAX-бот ID</label>
|
||||
<input type="text" name="max_bot_id" id="max_bot_id" placeholder="Токен бота">
|
||||
<div class="hint">Токен чат-бота из платформы MAX</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group full-width">
|
||||
<label>Email поддержки</label>
|
||||
<input type="email" name="support_email" id="support_email" placeholder="zakaz@aegisone.ru">
|
||||
<div class="hint">На этот email приходят уведомления о новых заявках</div>
|
||||
@@ -60,7 +60,7 @@
|
||||
<div class="hint">Тема email-уведомления об эскалации</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Сохранить настройки</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveSettings()">Сохранить настройки</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -92,31 +92,28 @@ function switchTab(name){
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('bot-settings-form').addEventListener('submit', function(e){
|
||||
e.preventDefault();
|
||||
function saveSettings(){
|
||||
var data = {};
|
||||
this.querySelectorAll('input, textarea').forEach(function(el){
|
||||
document.querySelectorAll('#bot-settings-form input, #bot-settings-form textarea').forEach(function(el){
|
||||
if(el.name) data[el.name] = el.value;
|
||||
});
|
||||
fetch('/service/api/bot/settings/save', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data), credentials: 'include'
|
||||
}).then(function(r){ return r.json() }).then(function(d){
|
||||
if(d.ok) showNotification('Настройки сохранены', 'success');
|
||||
else showNotification('Ошибка: ' + (d.error || 'сохранения'), 'error');
|
||||
}).catch(function(){
|
||||
}).catch(function(err){
|
||||
console.error('Save settings error:', err);
|
||||
showNotification('Ошибка соединения', 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fetch('/service/api/bot/settings').then(function(r){ return r.json() }).then(function(d){
|
||||
['assistant_name','assistant_role','support_email','email_subject','max_bot_id'].forEach(function(k){
|
||||
var el = document.getElementById(k);
|
||||
if(el) el.value = d[k] || '';
|
||||
});
|
||||
if(document.getElementById('max_bot_id') && !document.getElementById('max_bot_id').value){
|
||||
document.getElementById('max_bot_id').value = 'f9LHodD0cOKkk03DYXj906MdIeRs...';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -43,6 +43,7 @@ body { font-family:'Inter','Segoe UI',sans-serif; background:var(--bg-body, #1B2
|
||||
<button onclick="runScenario('consent_no')">Отказ согласия</button>
|
||||
<button onclick="runScenario('question')">Общий вопрос</button>
|
||||
<button onclick="clearTest()">Очистить</button>
|
||||
<button onclick="clearTestData()" style="background:var(--danger,#dc3545) !important;border-color:var(--danger,#dc3545) !important;">Сбросить все тестовые данные</button>
|
||||
</div>
|
||||
<div class="test-container">
|
||||
<div class="test-chat">
|
||||
@@ -134,8 +135,17 @@ function simulateWebhook(type, text){
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function(r){ return r.json() }).then(function(d){
|
||||
log('response', JSON.stringify(d));
|
||||
if(d && d.ok) log('success', 'Событие обработано');
|
||||
else log('error', 'Ошибка: ' + JSON.stringify(d));
|
||||
if(d && d.ok){
|
||||
log('success', 'Событие обработано');
|
||||
if(d.bot_replies && Array.isArray(d.bot_replies)){
|
||||
d.bot_replies.forEach(function(reply){
|
||||
addMessage(reply, 'bot');
|
||||
log('bot_reply', reply.substring(0, 100));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log('error', 'Ошибка: ' + JSON.stringify(d));
|
||||
}
|
||||
}).catch(function(err){
|
||||
log('error', 'Сетевая ошибка: ' + err);
|
||||
});
|
||||
@@ -146,6 +156,20 @@ function clearTest(){
|
||||
document.getElementById('test-input').value = '';
|
||||
log('info', 'Тест очищен');
|
||||
}
|
||||
function clearTestData(){
|
||||
if(!confirm('Удалить все тестовые диалоги, сообщения и логи обработки? Это действие нельзя отменить.')) return;
|
||||
log('info', 'Очистка тестовых данных...');
|
||||
fetch('/api/bot/test/clear', {method: 'POST'}).then(function(r){ return r.json() }).then(function(d){
|
||||
if(d && d.ok){
|
||||
log('success', 'Тестовые данные удалены (диалогов: ' + (d.deleted || '?') + ')');
|
||||
clearTest();
|
||||
} else {
|
||||
log('error', 'Ошибка: ' + JSON.stringify(d));
|
||||
}
|
||||
}).catch(function(err){
|
||||
log('error', 'Ошибка запроса: ' + err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "page.html" %}
|
||||
{% block page_content %}
|
||||
<style>
|
||||
.tickets-table { width:100%; border-collapse:collapse; }
|
||||
.tickets-table { width:100%; border-collapse:collapse; overflow-x:auto; display:block; }
|
||||
.tickets-table th, .tickets-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
|
||||
.tickets-table th { font-weight:600; color:var(--text-muted); background:var(--bg-card); }
|
||||
.tickets-table td { vertical-align:middle; }
|
||||
@@ -18,6 +18,8 @@
|
||||
.ticket-detail-card .field .value { font-size:14px; }
|
||||
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
|
||||
.filter-bar select, .filter-bar input { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
|
||||
.contact-info { font-size:12px; color:var(--text-muted); }
|
||||
.contact-info strong { color:var(--text-primary); }
|
||||
</style>
|
||||
|
||||
<div class="card">
|
||||
@@ -42,6 +44,7 @@
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Заголовок</th>
|
||||
<th>Контакт</th>
|
||||
<th>Приоритет</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата</th>
|
||||
@@ -49,7 +52,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tickets-body">
|
||||
<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
|
||||
<tr><td colspan="7" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -57,6 +60,7 @@
|
||||
<div class="ticket-detail-card" id="ticket-detail">
|
||||
<h3 id="ticket-detail-title">Заявка #</h3>
|
||||
<div class="field"><label>Описание</label><div class="value" id="ticket-detail-desc"></div></div>
|
||||
<div class="field"><label>Контактные данные</label><div class="value" id="ticket-detail-contact"></div></div>
|
||||
<div class="field"><label>Приоритет</label><div class="value" id="ticket-detail-priority"></div></div>
|
||||
<div class="field"><label>Статус</label>
|
||||
<select id="ticket-detail-status" onchange="updateTicketStatus()">
|
||||
@@ -75,18 +79,21 @@ var currentTicketId = null;
|
||||
|
||||
function loadTickets(){
|
||||
var tbody = document.getElementById('tickets-body');
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>';
|
||||
fetch('/api/bot/tickets').then(function(r){ return r.json() }).then(function(data){
|
||||
allTickets = data;
|
||||
document.getElementById('ticket-count').textContent = data.length + ' заявок';
|
||||
renderTickets(data);
|
||||
}).catch(function(err){
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:30px;color:var(--danger);">Ошибка загрузки</td></tr>';
|
||||
console.error('loadTickets error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTickets(tickets){
|
||||
var tbody = document.getElementById('tickets-body');
|
||||
if(!tickets.length){
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Нет заявок</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:30px;color:var(--text-muted);">Нет заявок</td></tr>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
@@ -95,9 +102,11 @@ function renderTickets(tickets){
|
||||
if(t.status === 'В работе') statusClass = 'status-v-rabote';
|
||||
else if(t.status === 'Закрыта') statusClass = 'status-zakryta';
|
||||
var dateStr = t.created_at ? new Date(t.created_at).toLocaleString() : '';
|
||||
var contact = t.contact_name || t.contact_phone || t.contact_email || '—';
|
||||
html += '<tr>' +
|
||||
'<td>' + t.id + '</td>' +
|
||||
'<td><a href="javascript:void(0)" onclick="showDetail(' + t.id + ')">' + escapeHtml(t.title) + '</a></td>' +
|
||||
'<td class="contact-info"><strong>' + escapeHtml(t.contact_name || '—') + '</strong><br>' + escapeHtml(t.contact_phone || '') + (t.contact_phone && t.contact_email ? '<br>' : '') + escapeHtml(t.contact_email || '') + '</td>' +
|
||||
'<td>' + t.priority + '</td>' +
|
||||
'<td><span class="status-badge ' + statusClass + '">' + t.status + '</span></td>' +
|
||||
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>' +
|
||||
@@ -131,6 +140,12 @@ function showDetail(id){
|
||||
if(!ticket) return;
|
||||
document.getElementById('ticket-detail-title').textContent = 'Заявка #' + ticket.id;
|
||||
document.getElementById('ticket-detail-desc').textContent = ticket.description || '—';
|
||||
var contactHtml = '';
|
||||
if(ticket.contact_name) contactHtml += '<strong>' + escapeHtml(ticket.contact_name) + '</strong><br>';
|
||||
if(ticket.contact_phone) contactHtml += 'Тел: ' + escapeHtml(ticket.contact_phone) + '<br>';
|
||||
if(ticket.contact_email) contactHtml += 'Email: ' + escapeHtml(ticket.contact_email);
|
||||
if(!contactHtml) contactHtml = '—';
|
||||
document.getElementById('ticket-detail-contact').innerHTML = contactHtml;
|
||||
document.getElementById('ticket-detail-priority').textContent = ticket.priority;
|
||||
document.getElementById('ticket-detail-status').value = ticket.status;
|
||||
document.getElementById('ticket-detail-date').textContent = ticket.created_at ? new Date(ticket.created_at).toLocaleString() : '—';
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
{% extends "page.html" %}
|
||||
{% block page_content %}
|
||||
<style>
|
||||
.uq-table { width:100%; border-collapse:collapse; }
|
||||
.uq-table th, .uq-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
|
||||
.uq-table th { font-weight:600; color:var(--text-muted); background:var(--bg-card); }
|
||||
.uq-table td { vertical-align:top; }
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
|
||||
.badge.new { background:#fff3cd; color:#856404; }
|
||||
.badge.auto_added { background:#cce5ff; color:#004085; }
|
||||
.badge.reviewed { background:#d4edda; color:#155724; }
|
||||
.badge.ignored { background:#f8f9fa; color:#6c757d; }
|
||||
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4); z-index:1000; align-items:center; justify-content:center; }
|
||||
.modal-overlay.open { display:flex; }
|
||||
.modal-box { background:var(--bg-card); border-radius:12px; padding:30px; width:650px; max-width:90vw; max-height:85vh; overflow-y:auto; }
|
||||
.modal-box h3 { margin-bottom:20px; }
|
||||
.form-group { margin-bottom:14px; }
|
||||
.form-group label { display:block; font-weight:600; margin-bottom:4px; font-size:13px; color:var(--text-muted); }
|
||||
.form-group input, .form-group textarea, .form-group select {
|
||||
width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px; }
|
||||
.form-group textarea { min-height:80px; resize:vertical; }
|
||||
.modal-actions { display:flex; gap:12px; margin-top:16px; }
|
||||
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; }
|
||||
.filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
|
||||
</style>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Неизвестные вопросы</h2></div>
|
||||
<div class="filter-bar">
|
||||
<select id="uq-status-filter" onchange="loadUQ()">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="new">Новые</option>
|
||||
<option value="auto_added">Авто-сгенерированные</option>
|
||||
<option value="reviewed">Проверенные</option>
|
||||
<option value="ignored">Игнорированные</option>
|
||||
</select>
|
||||
<span id="uq-count" style="font-size:13px;color:var(--text-muted);"></span>
|
||||
</div>
|
||||
<table class="uq-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Вопрос</th>
|
||||
<th>Смысл (NLP)</th>
|
||||
<th>Раз</th>
|
||||
<th>Статус</th>
|
||||
<th>Сгенерированный ответ</th>
|
||||
<th>Дата</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="uq-table-body">
|
||||
<tr><td colspan="7" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="uq-modal">
|
||||
<div class="modal-box">
|
||||
<h3 id="uq-modal-title">Проверить вопрос</h3>
|
||||
<form id="uq-form">
|
||||
<input type="hidden" id="uq-edit-id">
|
||||
<div class="form-group">
|
||||
<label>Исходный вопрос</label>
|
||||
<input type="text" id="uq-original" readonly style="background:var(--bg-secondary,#f5f5f5);">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Канонический вопрос</label>
|
||||
<input type="text" id="uq-question" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Ответ</label>
|
||||
<textarea id="uq-answer" required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Ключевые слова (через запятую)</label>
|
||||
<input type="text" id="uq-keywords">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Категория</label>
|
||||
<select id="uq-category">
|
||||
<option value="1">О компании</option>
|
||||
<option value="2">Аудит безопасности</option>
|
||||
<option value="3">Сервисное обслуживание (SLA)</option>
|
||||
<option value="4">Реагирование на инциденты</option>
|
||||
<option value="5">Технический надзор</option>
|
||||
<option value="6">Документация и нормативы</option>
|
||||
<option value="7">Риск-инжиниринг</option>
|
||||
<option value="8">Отрасли и кейсы</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-primary">Опубликовать в базу знаний</button>
|
||||
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var allUQ = [];
|
||||
|
||||
function loadUQ(){
|
||||
fetch('/api/bot/unknown-questions').then(function(r){ return r.json() }).then(function(data){
|
||||
allUQ = data;
|
||||
filterUQ();
|
||||
});
|
||||
}
|
||||
|
||||
function filterUQ(){
|
||||
var status = document.getElementById('uq-status-filter').value;
|
||||
var filtered = allUQ;
|
||||
if(status) filtered = allUQ.filter(function(q){ return q.status === status; });
|
||||
document.getElementById('uq-count').textContent = filtered.length + ' из ' + allUQ.length;
|
||||
renderUQ(filtered);
|
||||
}
|
||||
|
||||
function renderUQ(items){
|
||||
var tbody = document.getElementById('uq-table-body');
|
||||
if(!items.length){
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:30px;color:var(--text-muted);">Нет вопросов</td></tr>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
items.forEach(function(q){
|
||||
var statusLabels = {new:'Новый',auto_added:'Авто',reviewed:'Проверен',ignored:'Игнор'};
|
||||
html += '<tr>' +
|
||||
'<td><strong>' + esc(q.question_text) + '</strong></td>' +
|
||||
'<td>' + esc(q.normalized_text || '—') + '</td>' +
|
||||
'<td style="text-align:center;">' + q.ask_count + '</td>' +
|
||||
'<td><span class="badge ' + q.status + '">' + (statusLabels[q.status]||q.status) + '</span></td>' +
|
||||
'<td style="font-size:12px;">' + esc((q.generated_answer||'').substring(0,80)) + (q.generated_answer&&q.generated_answer.length>80?'...':'') + '</td>' +
|
||||
'<td style="font-size:12px;">' + (q.created_at ? q.created_at.substring(0,10) : '—') + '</td>' +
|
||||
'<td>';
|
||||
if(q.status === 'new' || q.status === 'auto_added'){
|
||||
html += '<button class="btn btn-sm" onclick="openReview(' + q.id + ')" title="Проверить">✏️</button> ';
|
||||
html += '<button class="btn btn-sm" onclick="ignoreUQ(' + q.id + ')" style="color:#dc3545;" title="Игнорировать">🗑</button>';
|
||||
}
|
||||
html += '</td></tr>';
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
function openReview(id){
|
||||
var q = allUQ.find(function(x){ return x.id === id; });
|
||||
if(!q) return;
|
||||
document.getElementById('uq-edit-id').value = id;
|
||||
document.getElementById('uq-original').value = q.question_text;
|
||||
document.getElementById('uq-question').value = q.normalized_text || q.question_text;
|
||||
document.getElementById('uq-answer').value = q.generated_answer || '';
|
||||
document.getElementById('uq-keywords').value = (q.generated_keywords || []).join(', ');
|
||||
document.getElementById('uq-category').value = q.generated_category_id || '1';
|
||||
document.getElementById('uq-modal-title').textContent = 'Проверить вопрос #' + id;
|
||||
document.getElementById('uq-modal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeModal(){
|
||||
document.getElementById('uq-modal').classList.remove('open');
|
||||
}
|
||||
|
||||
document.getElementById('uq-form').addEventListener('submit', function(e){
|
||||
e.preventDefault();
|
||||
var id = document.getElementById('uq-edit-id').value;
|
||||
var data = {
|
||||
question: document.getElementById('uq-question').value,
|
||||
answer: document.getElementById('uq-answer').value,
|
||||
keywords: document.getElementById('uq-keywords').value.split(',').map(function(s){ return s.trim(); }).filter(Boolean),
|
||||
category_id: parseInt(document.getElementById('uq-category').value),
|
||||
};
|
||||
fetch('/api/bot/unknown-questions/' + id + '/edit', {
|
||||
method: 'PUT', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
}).then(function(r){ return r.json() }).then(function(d){
|
||||
if(d.ok){ closeModal(); loadUQ(); showNotification('Опубликовано в базу знаний', 'success'); }
|
||||
else showNotification('Ошибка', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
function ignoreUQ(id){
|
||||
if(!confirm('Игнорировать вопрос?')) return;
|
||||
fetch('/api/bot/unknown-questions/' + id + '/ignore', { method: 'PUT' })
|
||||
.then(function(r){ return r.json() }).then(function(d){
|
||||
if(d.ok){ loadUQ(); showNotification('Игнорировано', 'success'); }
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s){ return s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') : ''; }
|
||||
|
||||
loadUQ();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -44,29 +44,7 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var MENU_ITEMS = [
|
||||
// Панель
|
||||
{key: 'charts', label: 'Графики', section: 'Панель'},
|
||||
{key: 'ceo', label: 'CEO дашборд', section: 'Панель'},
|
||||
// Управление
|
||||
{key: 'customers', label: 'Клиенты', section: 'Управление'},
|
||||
{key: 'objects', label: 'Объекты', section: 'Управление'},
|
||||
{key: 'sla', label: 'SLA контракты', section: 'Управление'},
|
||||
{key: 'questionnaire', label: 'Опросник', section: 'Управление'},
|
||||
{key: 'passports', label: 'Паспорта объектов', section: 'Управление'},
|
||||
// Работа
|
||||
{key: 'users', label: 'Сотрудники', section: 'Работа'},
|
||||
{key: 'assignments', label: 'Назначение сотрудников', section: 'Работа'},
|
||||
{key: 'docs', label: 'Просмотр документации', section: 'Работа'},
|
||||
{key: 'tasks', label: 'Задачи', section: 'Работа'},
|
||||
{key: 'reports', label: 'Отчёты', section: 'Работа'},
|
||||
{key: 'incidents', label: 'Инциденты', section: 'Работа'},
|
||||
{key: 'checklist', label: 'Чек-лист', section: 'Работа'},
|
||||
{key: 'tech_access', label: 'Доступ техников к документам', section: 'Работа'},
|
||||
// Контент
|
||||
{key: 'blog', label: 'Управление блогом', section: 'Контент'},
|
||||
{key: 'cases', label: 'Примеры из практики', section: 'Контент'},
|
||||
];
|
||||
var MENU_ITEMS = [];
|
||||
|
||||
function switchTab(name){
|
||||
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
|
||||
@@ -76,7 +54,12 @@ function switchTab(name){
|
||||
}
|
||||
|
||||
function loadPermissions(){
|
||||
fetch('/service/api/role-permissions').then(function(r){ return r.json() }).then(function(data){
|
||||
Promise.all([
|
||||
fetch('/service/api/menu-items').then(function(r){ return r.json() }),
|
||||
fetch('/service/api/role-permissions').then(function(r){ return r.json() })
|
||||
]).then(function(results){
|
||||
MENU_ITEMS = results[0];
|
||||
var data = results[1];
|
||||
['engineer','technician'].forEach(function(role){
|
||||
var container = document.getElementById('menu-' + role);
|
||||
container.innerHTML = '';
|
||||
|
||||
Reference in New Issue
Block a user