|
|
|
|
|
const Utils = { |
|
|
|
|
|
scrollToSection(el, extraOffset = 0) { |
|
|
if (!el) return; |
|
|
const fixed = document.querySelector('.navbar.fixed-top, header.sticky-top, .sticky-top, .fixed-top'); |
|
|
const base = fixed ? Math.max(0, fixed.getBoundingClientRect().height + 8) : 56; |
|
|
const offset = Math.max(0, base + extraOffset); |
|
|
const rect = el.getBoundingClientRect(); |
|
|
const top = window.pageYOffset + rect.top - offset; |
|
|
window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); |
|
|
}, |
|
|
|
|
|
|
|
|
initAutoHideAlerts() { |
|
|
const alerts = document.querySelectorAll('.alert'); |
|
|
alerts.forEach(alert => { |
|
|
setTimeout(() => { |
|
|
alert.classList.add('fade-out'); |
|
|
setTimeout(() => alert.remove(), 500); |
|
|
}, 2000); |
|
|
}); |
|
|
} |
|
|
}; |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', function() { |
|
|
|
|
|
const elements = { |
|
|
form: document.getElementById('feedbackForm'), |
|
|
textarea: document.getElementById('feedbackText'), |
|
|
loadingSpinner: document.getElementById('loadingSpinner'), |
|
|
results: document.getElementById('results'), |
|
|
errorMessage: document.getElementById('errorMessage'), |
|
|
analyzeBtn: document.getElementById('analyzeBtn'), |
|
|
originalText: document.getElementById('originalText'), |
|
|
errorText: document.getElementById('errorText'), |
|
|
sentimentResult: document.getElementById('sentimentResult'), |
|
|
sentimentIcon: document.getElementById('sentimentIcon'), |
|
|
sentimentDescription: document.getElementById('sentimentDescription'), |
|
|
topicResult: document.getElementById('topicResult'), |
|
|
topicIcon: document.getElementById('topicIcon'), |
|
|
topicDescription: document.getElementById('topicDescription') |
|
|
}; |
|
|
|
|
|
|
|
|
const sentimentConfigs = { |
|
|
positive: { icon: 'fa-smile', label: 'Tích Cực', colorClass: 'sentiment-positive', description: 'Feedback thể hiện thái độ tích cực và hài lòng' }, |
|
|
neutral: { icon: 'fa-meh', label: 'Trung Tính', colorClass: 'sentiment-neutral', description: 'Feedback thể hiện thái độ trung lập, không rõ ràng' }, |
|
|
negative: { icon: 'fa-frown', label: 'Tiêu Cực', colorClass: 'sentiment-negative', description: 'Feedback thể hiện thái độ tiêu cực và không hài lòng' } |
|
|
}; |
|
|
|
|
|
const topicConfigs = { |
|
|
lecturer: { icon: 'fa-user-tie', label: 'Giảng Viên', colorClass: 'topic-lecturer', description: 'Feedback liên quan đến chất lượng giảng dạy của giảng viên' }, |
|
|
training_program: { icon: 'fa-graduation-cap', label: 'Chương Trình Đào Tạo', colorClass: 'topic-training_program', description: 'Feedback về nội dung và cấu trúc chương trình học' }, |
|
|
facility: { icon: 'fa-building', label: 'Cơ Sở Vật Chất', colorClass: 'topic-facility', description: 'Feedback về phòng học, thiết bị và cơ sở hạ tầng' }, |
|
|
others: { icon: 'fa-ellipsis-h', label: 'Khác', colorClass: 'topic-others', description: 'Feedback về các chủ đề khác không thuộc các danh mục trên' } |
|
|
}; |
|
|
|
|
|
const examples = [ |
|
|
"Giảng viên giảng bài rất hay và dễ hiểu, sinh viên rất thích", |
|
|
"Thầy cô rất nhiệt tình và hỗ trợ sinh viên học tập tốt", |
|
|
"Chương trình học rất phù hợp và bổ ích cho sinh viên", |
|
|
"Cơ sở vật chất rất hiện đại và đầy đủ tiện nghi", |
|
|
"Giảng viên giảng bài quá nhanh và khó hiểu", |
|
|
"Chương trình học quá khó và không phù hợp với sinh viên", |
|
|
"Phòng học quá nóng và không có điều hòa, rất khó chịu", |
|
|
"Môn học này có nội dung bình thường, không có gì đặc biệt" |
|
|
]; |
|
|
|
|
|
|
|
|
const utils = { |
|
|
showLoading() { |
|
|
elements.loadingSpinner.style.display = 'block'; |
|
|
elements.results.style.display = 'none'; |
|
|
elements.errorMessage.style.display = 'none'; |
|
|
elements.analyzeBtn.disabled = true; |
|
|
elements.analyzeBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Đang phân tích...'; |
|
|
}, |
|
|
hideLoading() { |
|
|
elements.loadingSpinner.style.display = 'none'; |
|
|
elements.analyzeBtn.disabled = false; |
|
|
elements.analyzeBtn.innerHTML = '<i class="fas fa-search me-2"></i>Phân Tích Feedback'; |
|
|
}, |
|
|
showError(message) { |
|
|
elements.errorText.textContent = message; |
|
|
elements.errorMessage.style.display = 'block'; |
|
|
elements.errorMessage.classList.add('fade-in'); |
|
|
elements.results.style.display = 'none'; |
|
|
elements.errorMessage.scrollIntoView({ behavior: 'smooth' }); |
|
|
}, |
|
|
clearForm() { |
|
|
elements.textarea.value = ''; |
|
|
elements.results.style.display = 'none'; |
|
|
elements.errorMessage.style.display = 'none'; |
|
|
this.updateCharCounter(); |
|
|
|
|
|
|
|
|
const formElement = elements.form; |
|
|
const formRect = formElement.getBoundingClientRect(); |
|
|
const scrollTop = window.pageYOffset + formRect.top - 120; |
|
|
|
|
|
window.scrollTo({ |
|
|
top: scrollTop, |
|
|
behavior: 'smooth' |
|
|
}); |
|
|
}, |
|
|
updateResult(type, value) { |
|
|
const configs = type === 'sentiment' ? sentimentConfigs : topicConfigs; |
|
|
const config = configs[value] || configs[type === 'sentiment' ? 'neutral' : 'others']; |
|
|
const resultEl = elements[`${type}Result`]; |
|
|
const iconEl = elements[`${type}Icon`]; |
|
|
const descEl = elements[`${type}Description`]; |
|
|
resultEl.className = `mb-2 ${config.colorClass}`; |
|
|
iconEl.innerHTML = `<i class="fas ${config.icon} fa-3x ${config.colorClass}"></i>`; |
|
|
resultEl.textContent = config.label; |
|
|
descEl.textContent = config.description; |
|
|
}, |
|
|
scrollToResults() { |
|
|
Utils.scrollToSection(elements.results, 105); |
|
|
}, |
|
|
updateCharCounter() { |
|
|
const count = elements.textarea.value.length; |
|
|
const charCount = document.getElementById('charCount'); |
|
|
const charCounter = document.querySelector('.form-text'); |
|
|
if (charCount) charCount.textContent = count; |
|
|
if (charCounter) { |
|
|
charCounter.style.color = count > 500 ? '#dc3545' : |
|
|
count > 300 ? '#ffc107' : '#6c757d'; |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
async function handleFormSubmit(e) { |
|
|
e.preventDefault(); |
|
|
const feedbackText = elements.textarea.value.trim(); |
|
|
if (!feedbackText) { |
|
|
utils.showError('Vui lòng nhập feedback trước khi phân tích!'); |
|
|
return; |
|
|
} |
|
|
utils.showLoading(); |
|
|
try { |
|
|
const response = await fetch('/predict', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ text: feedbackText }) |
|
|
}); |
|
|
const data = await response.json(); |
|
|
console.log('Predict response:', data); |
|
|
if (response.ok) { |
|
|
|
|
|
displayMultipleResults(data.results, feedbackText); |
|
|
|
|
|
|
|
|
elements.textarea.value = ''; |
|
|
utils.updateCharCounter(); |
|
|
|
|
|
|
|
|
|
|
|
console.log('Reloading feedback history...'); |
|
|
setTimeout(() => { |
|
|
loadFeedbackHistory(1, false); |
|
|
}, 500); |
|
|
} else { |
|
|
utils.showError(data.error || 'Có lỗi xảy ra khi phân tích feedback!'); |
|
|
} |
|
|
} catch (error) { |
|
|
utils.showError('Không thể kết nối đến server. Vui lòng thử lại!'); |
|
|
console.error('Error:', error); |
|
|
} finally { |
|
|
utils.hideLoading(); |
|
|
} |
|
|
} |
|
|
|
|
|
function showExamples() { |
|
|
const randomExample = examples[Math.floor(Math.random() * examples.length)]; |
|
|
elements.textarea.value = randomExample; |
|
|
elements.textarea.dispatchEvent(new Event('input')); |
|
|
|
|
|
|
|
|
const formElement = elements.form; |
|
|
const formRect = formElement.getBoundingClientRect(); |
|
|
const scrollTop = window.pageYOffset + formRect.top - 120; |
|
|
|
|
|
window.scrollTo({ |
|
|
top: scrollTop, |
|
|
behavior: 'smooth' |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
elements.form.addEventListener('submit', handleFormSubmit); |
|
|
elements.textarea.addEventListener('input', utils.updateCharCounter); |
|
|
document.addEventListener('keydown', function(e) { |
|
|
if (e.ctrlKey && e.key === 'Enter') { |
|
|
elements.form.dispatchEvent(new Event('submit')); |
|
|
} else if (e.key === 'Escape') { |
|
|
utils.clearForm(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
const buttonContainer = document.createElement('div'); |
|
|
buttonContainer.className = 'd-flex justify-content-center gap-2 mt-3'; |
|
|
const clearBtn = document.createElement('button'); |
|
|
clearBtn.type = 'button'; |
|
|
clearBtn.className = 'btn btn-outline-secondary'; |
|
|
clearBtn.innerHTML = '<i class="fas fa-trash me-2"></i>Xóa Form'; |
|
|
clearBtn.onclick = () => utils.clearForm(); |
|
|
const exampleBtn = document.createElement('button'); |
|
|
exampleBtn.type = 'button'; |
|
|
exampleBtn.className = 'btn btn-outline-info'; |
|
|
exampleBtn.innerHTML = '<i class="fas fa-lightbulb me-2"></i>Ví Dụ'; |
|
|
exampleBtn.onclick = showExamples; |
|
|
buttonContainer.appendChild(clearBtn); |
|
|
buttonContainer.appendChild(exampleBtn); |
|
|
elements.form.appendChild(buttonContainer); |
|
|
|
|
|
|
|
|
const charCounter = document.createElement('small'); |
|
|
charCounter.className = 'form-text text-muted'; |
|
|
charCounter.innerHTML = '<i class="fas fa-info-circle me-1"></i>Độ dài: <span id="charCount">0</span> ký tự'; |
|
|
elements.textarea.parentNode.appendChild(charCounter); |
|
|
|
|
|
|
|
|
loadFeedbackHistory(); |
|
|
|
|
|
|
|
|
Utils.initAutoHideAlerts(); |
|
|
|
|
|
|
|
|
initTimeFilter(); |
|
|
|
|
|
|
|
|
initAnalysisModeToggle(); |
|
|
}); |
|
|
|
|
|
|
|
|
let currentPage = 1; |
|
|
const itemsPerPage = 5; |
|
|
|
|
|
async function loadFeedbackHistory(page = 1, shouldScroll = false) { |
|
|
const historyLoading = document.getElementById('historyLoading'); |
|
|
const historyContent = document.getElementById('historyContent'); |
|
|
const historyPagination = document.getElementById('historyPagination'); |
|
|
const feedbackHistorySection = document.getElementById('feedbackHistory'); |
|
|
|
|
|
historyLoading.style.display = 'block'; |
|
|
historyContent.style.opacity = '0.5'; |
|
|
historyPagination.style.opacity = '0.5'; |
|
|
try { |
|
|
|
|
|
const filterParams = getTimeFilterParams(); |
|
|
const queryParams = new URLSearchParams({ |
|
|
page: page, |
|
|
per_page: itemsPerPage, |
|
|
...filterParams |
|
|
}); |
|
|
|
|
|
console.log('Loading feedback history, page:', page); |
|
|
const response = await fetch(`/api/feedback-history?${queryParams.toString()}`); |
|
|
const data = await response.json(); |
|
|
console.log('Feedback history response:', data); |
|
|
if (response.ok) { |
|
|
displayFeedbackHistory(data.feedbacks); |
|
|
displayPagination(data, page); |
|
|
updateFeedbackCount(data.total); |
|
|
if (shouldScroll && feedbackHistorySection) { |
|
|
|
|
|
Utils.scrollToSection(feedbackHistorySection, -40); |
|
|
} |
|
|
} else { |
|
|
historyContent.innerHTML = '<p class="text-muted text-center">Không thể tải lịch sử feedback.</p>'; |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error loading feedback history:', error); |
|
|
historyContent.innerHTML = '<p class="text-muted text-center">Có lỗi xảy ra khi tải lịch sử.</p>'; |
|
|
} finally { |
|
|
historyLoading.style.display = 'none'; |
|
|
historyContent.style.opacity = '1'; |
|
|
historyPagination.style.opacity = '1'; |
|
|
currentPage = page; |
|
|
} |
|
|
} |
|
|
|
|
|
function displayFeedbackHistory(feedbacks) { |
|
|
const historyContent = document.getElementById('historyContent'); |
|
|
if (!feedbacks || feedbacks.length === 0) { |
|
|
historyContent.innerHTML = '<p class="text-muted text-center">Chưa có feedback nào. Hãy phân tích feedback đầu tiên!</p>'; |
|
|
return; |
|
|
} |
|
|
let html = ''; |
|
|
feedbacks.forEach(feedback => { |
|
|
const sentimentConfig = getSentimentConfig(feedback.sentiment); |
|
|
const topicConfig = getTopicConfig(feedback.topic); |
|
|
const date = feedback.created_at; |
|
|
html += ` |
|
|
<div class="card mb-3 border-start border-4 border-${getSentimentColor(feedback.sentiment)}"> |
|
|
<div class="card-body"> |
|
|
<div class="row mb-3"> |
|
|
<div class="col-12"> |
|
|
<p class="card-text">${feedback.text}</p> |
|
|
<small class="text-muted"> |
|
|
<i class="fas fa-clock me-1"></i>${date} |
|
|
</small> |
|
|
</div> |
|
|
</div> |
|
|
<div class="row"> |
|
|
<div class="col-6"> |
|
|
<div class="d-flex align-items-center"> |
|
|
<span class="badge bg-${getSentimentColor(feedback.sentiment)} me-2"> |
|
|
<i class="fas ${sentimentConfig.icon} me-1"></i> |
|
|
${sentimentConfig.label} |
|
|
</span> |
|
|
<small class="text-muted">Tin cậy: ${(feedback.sentiment_confidence * 100).toFixed(1)}%</small> |
|
|
</div> |
|
|
</div> |
|
|
<div class="col-6"> |
|
|
<div class="d-flex align-items-center"> |
|
|
<span class="badge bg-secondary me-2"> |
|
|
<i class="fas ${topicConfig.icon} me-1"></i> |
|
|
${topicConfig.label} |
|
|
</span> |
|
|
<small class="text-muted">Tin cậy: ${(feedback.topic_confidence * 100).toFixed(1)}%</small> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}); |
|
|
historyContent.innerHTML = html; |
|
|
} |
|
|
|
|
|
function displayPagination(data, currentPage) { |
|
|
const historyPagination = document.getElementById('historyPagination'); |
|
|
if (!data || data.pages <= 1) { |
|
|
historyPagination.innerHTML = ''; |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const maxPages = 5; |
|
|
let startPage = Math.max(1, currentPage - Math.floor(maxPages / 2)); |
|
|
let endPage = Math.min(data.pages, startPage + maxPages - 1); |
|
|
|
|
|
|
|
|
if (endPage - startPage + 1 < maxPages) { |
|
|
startPage = Math.max(1, endPage - maxPages + 1); |
|
|
} |
|
|
|
|
|
let html = '<nav><ul class="pagination pagination-sm">'; |
|
|
if (data.has_prev) { |
|
|
html += `<li class="page-item"> |
|
|
<a class="page-link" href="javascript:void(0)" onclick="loadFeedbackHistory(${currentPage - 1}, true); return false;">Trước</a> |
|
|
</li>`; |
|
|
} |
|
|
|
|
|
|
|
|
if (startPage > 1) { |
|
|
html += `<li class="page-item"> |
|
|
<a class="page-link" href="javascript:void(0)" onclick="loadFeedbackHistory(1, true); return false;">1</a> |
|
|
</li>`; |
|
|
if (startPage > 2) { |
|
|
html += '<li class="page-item disabled"><span class="page-link">...</span></li>'; |
|
|
} |
|
|
} |
|
|
|
|
|
for (let i = startPage; i <= endPage; i++) { |
|
|
const active = i === currentPage ? 'active' : ''; |
|
|
html += `<li class="page-item ${active}"> |
|
|
<a class="page-link" href="javascript:void(0)" onclick="loadFeedbackHistory(${i}, true); return false;">${i}</a> |
|
|
</li>`; |
|
|
} |
|
|
|
|
|
|
|
|
if (endPage < data.pages) { |
|
|
if (endPage < data.pages - 1) { |
|
|
html += '<li class="page-item disabled"><span class="page-link">...</span></li>'; |
|
|
} |
|
|
html += `<li class="page-item"> |
|
|
<a class="page-link" href="javascript:void(0)" onclick="loadFeedbackHistory(${data.pages}, true); return false;">${data.pages}</a> |
|
|
</li>`; |
|
|
} |
|
|
|
|
|
if (data.has_next) { |
|
|
html += `<li class="page-item"> |
|
|
<a class="page-link" href="javascript:void(0)" onclick="loadFeedbackHistory(${currentPage + 1}, true); return false;">Sau</a> |
|
|
</li>`; |
|
|
} |
|
|
html += '</ul></nav>'; |
|
|
|
|
|
|
|
|
html += ` |
|
|
<div class="d-flex align-items-center justify-content-center" style="margin-left: 20px; margin-top: -3px;"> |
|
|
<span class="me-2 text-muted" style="font-size: 0.875rem; line-height: 2.2;">Đến:</span> |
|
|
<input type="number" id="pageInput" class="form-control form-control-sm me-2" |
|
|
style="width: 80px; height: 38px; font-size: 0.875rem; text-align: center; line-height: 1;" min="1" max="${data.pages}" value="${currentPage}"> |
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="goToPage()" style="height: 38px; width: 38px; padding: 0; display: flex; align-items: center; justify-content: center;"> |
|
|
<i class="fas fa-arrow-right" style="font-size: 0.875rem;"></i> |
|
|
</button> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
historyPagination.innerHTML = html; |
|
|
} |
|
|
|
|
|
function goToPage() { |
|
|
const pageInput = document.getElementById('pageInput'); |
|
|
const page = parseInt(pageInput.value); |
|
|
|
|
|
|
|
|
const paginationNav = document.querySelector('.pagination'); |
|
|
if (!paginationNav) return; |
|
|
|
|
|
|
|
|
const pageLinks = paginationNav.querySelectorAll('.page-link'); |
|
|
let maxPage = 1; |
|
|
pageLinks.forEach(link => { |
|
|
const pageNum = parseInt(link.textContent); |
|
|
if (!isNaN(pageNum) && pageNum > maxPage) { |
|
|
maxPage = pageNum; |
|
|
} |
|
|
}); |
|
|
|
|
|
if (page && page > 0 && page <= maxPage) { |
|
|
loadFeedbackHistory(page, true); |
|
|
} else if (page > maxPage) { |
|
|
showAlert(`Trang tối đa là ${maxPage}`, 'warning'); |
|
|
pageInput.value = maxPage; |
|
|
} else { |
|
|
showAlert('Vui lòng nhập số trang hợp lệ', 'warning'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('keypress', function(e) { |
|
|
if (e.target.id === 'pageInput' && e.key === 'Enter') { |
|
|
goToPage(); |
|
|
} |
|
|
}); |
|
|
|
|
|
function getSentimentConfig(sentiment) { |
|
|
const configs = { |
|
|
positive: { icon: 'fa-smile', label: 'Tích Cực' }, |
|
|
neutral: { icon: 'fa-meh', label: 'Trung Tính' }, |
|
|
negative: { icon: 'fa-frown', label: 'Tiêu Cực' } |
|
|
}; |
|
|
return configs[sentiment] || configs.neutral; |
|
|
} |
|
|
|
|
|
function getTopicConfig(topic) { |
|
|
const configs = { |
|
|
lecturer: { icon: 'fa-user-tie', label: 'Giảng Viên' }, |
|
|
training_program: { icon: 'fa-graduation-cap', label: 'Chương Trình' }, |
|
|
facility: { icon: 'fa-building', label: 'Cơ Sở Vật Chất' }, |
|
|
others: { icon: 'fa-ellipsis-h', label: 'Khác' } |
|
|
}; |
|
|
return configs[topic] || configs.others; |
|
|
} |
|
|
|
|
|
function getSentimentColor(sentiment) { |
|
|
const colors = { positive: 'success', neutral: 'warning', negative: 'danger' }; |
|
|
return colors[sentiment] || 'secondary'; |
|
|
} |
|
|
|
|
|
|
|
|
function displayMultipleResults(results, text) { |
|
|
const elements = { |
|
|
results: document.getElementById('results'), |
|
|
originalText: document.getElementById('originalText') |
|
|
}; |
|
|
|
|
|
|
|
|
if (!elements.results) { |
|
|
console.error('❌ Results element not found'); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!results || results.length === 0) { |
|
|
|
|
|
if (elements.originalText) elements.originalText.textContent = text; |
|
|
elements.results.innerHTML = ` |
|
|
<div class="alert alert-warning"> |
|
|
<i class="fas fa-info-circle me-2"></i> |
|
|
Không phát hiện topic rõ ràng trong feedback này. |
|
|
</div> |
|
|
`; |
|
|
elements.results.style.display = 'block'; |
|
|
elements.results.classList.add('fade-in'); |
|
|
Utils.scrollToSection(elements.results, 105); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (elements.originalText) { |
|
|
elements.originalText.textContent = text; |
|
|
} |
|
|
|
|
|
|
|
|
let html = '<div class="mb-4"><h5 class="mb-3"><i class="fas fa-check-circle me-2"></i>Kết quả phân tích:</h5></div>'; |
|
|
html += '<div class="multiple-topics-container">'; |
|
|
|
|
|
results.forEach(result => { |
|
|
const sentimentConfig = getSentimentConfig(result.sentiment); |
|
|
const topicConfig = getTopicConfig(result.topic); |
|
|
const sentimentColor = getSentimentColor(result.sentiment); |
|
|
|
|
|
html += ` |
|
|
<div class="topic-sentiment-item mb-3"> |
|
|
<div class="d-flex align-items-center"> |
|
|
<div class="flex-grow-1"> |
|
|
<div class="d-flex align-items-center mb-2"> |
|
|
<i class="fas ${topicConfig.icon} me-2" style="color: #6B7280;"></i> |
|
|
<span class="fw-semibold">${topicConfig.label}</span> |
|
|
</div> |
|
|
<div> |
|
|
<span class="badge bg-${sentimentColor} me-2"> |
|
|
<i class="fas ${sentimentConfig.icon} me-1"></i> |
|
|
${sentimentConfig.label} |
|
|
</span> |
|
|
<small class="text-muted"> |
|
|
<i class="fas fa-percentage me-1"></i> |
|
|
Độ tin cậy: ${(result.confidence * 100).toFixed(1)}% |
|
|
</small> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}); |
|
|
|
|
|
html += '</div>'; |
|
|
|
|
|
|
|
|
html += ` |
|
|
<div class="card shadow-sm"> |
|
|
<div class="card-header bg-primary text-white"> |
|
|
<h5 class="card-title mb-0"> |
|
|
<i class="fas fa-quote-left me-2"></i> |
|
|
Feedback Gốc |
|
|
</h5> |
|
|
</div> |
|
|
<div class="card-body"> |
|
|
<blockquote class="blockquote mb-0"> |
|
|
<p class="mb-0">${text}</p> |
|
|
</blockquote> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
elements.results.innerHTML = html; |
|
|
elements.results.style.display = 'block'; |
|
|
elements.results.classList.add('fade-in'); |
|
|
Utils.scrollToSection(elements.results, 105); |
|
|
} |
|
|
|
|
|
|
|
|
function initTimeFilter() { |
|
|
const timeFilterInputs = document.querySelectorAll('input[name="timeFilter"]'); |
|
|
const customDateRange = document.getElementById('customDateRange'); |
|
|
const filterInfo = document.getElementById('filterInfo'); |
|
|
|
|
|
|
|
|
const today = new Date().toISOString().split('T')[0]; |
|
|
document.getElementById('endDate').value = today; |
|
|
|
|
|
|
|
|
customDateRange.style.setProperty('display', 'none', 'important'); |
|
|
|
|
|
timeFilterInputs.forEach(input => { |
|
|
input.addEventListener('change', function() { |
|
|
if (this.value === 'custom') { |
|
|
customDateRange.style.setProperty('display', 'flex', 'important'); |
|
|
updateFilterInfo(); |
|
|
} else { |
|
|
customDateRange.style.setProperty('display', 'none', 'important'); |
|
|
updateFilterInfo(); |
|
|
loadFeedbackHistory(1, true); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
document.getElementById('startDate').addEventListener('change', function() { |
|
|
if (document.querySelector('input[name="timeFilter"]:checked').value === 'custom') { |
|
|
updateFilterInfo(); |
|
|
loadFeedbackHistory(1, true); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('endDate').addEventListener('change', function() { |
|
|
if (document.querySelector('input[name="timeFilter"]:checked').value === 'custom') { |
|
|
updateFilterInfo(); |
|
|
loadFeedbackHistory(1, true); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
function updateFilterInfo() { |
|
|
const selectedFilter = document.querySelector('input[name="timeFilter"]:checked'); |
|
|
const filterInfo = document.getElementById('filterInfo'); |
|
|
const startDate = document.getElementById('startDate').value; |
|
|
const endDate = document.getElementById('endDate').value; |
|
|
|
|
|
let infoText = ''; |
|
|
switch(selectedFilter.value) { |
|
|
case 'all': |
|
|
infoText = 'Hiển thị tất cả feedback'; |
|
|
break; |
|
|
case 'today': |
|
|
infoText = 'Hiển thị feedback trong ngày hôm nay'; |
|
|
break; |
|
|
case 'week': |
|
|
infoText = 'Hiển thị feedback trong 7 ngày qua'; |
|
|
break; |
|
|
case 'month': |
|
|
infoText = 'Hiển thị feedback trong 30 ngày qua'; |
|
|
break; |
|
|
case 'custom': |
|
|
if (startDate && endDate) { |
|
|
const start = new Date(startDate).toLocaleDateString('vi-VN'); |
|
|
const end = new Date(endDate).toLocaleDateString('vi-VN'); |
|
|
infoText = `Hiển thị feedback từ ${start} đến ${end}`; |
|
|
} else { |
|
|
infoText = 'Vui lòng chọn khoảng thời gian'; |
|
|
} |
|
|
break; |
|
|
} |
|
|
filterInfo.textContent = infoText; |
|
|
} |
|
|
|
|
|
function getTimeFilterParams() { |
|
|
const selectedFilter = document.querySelector('input[name="timeFilter"]:checked'); |
|
|
const params = { |
|
|
time_filter: selectedFilter.value |
|
|
}; |
|
|
|
|
|
if (selectedFilter.value === 'custom') { |
|
|
const startDate = document.getElementById('startDate').value; |
|
|
const endDate = document.getElementById('endDate').value; |
|
|
|
|
|
if (startDate) params.start_date = startDate; |
|
|
if (endDate) params.end_date = endDate; |
|
|
} |
|
|
|
|
|
return params; |
|
|
} |
|
|
|
|
|
function updateFeedbackCount(total) { |
|
|
const feedbackCount = document.getElementById('feedbackCount'); |
|
|
if (feedbackCount) { |
|
|
feedbackCount.textContent = total; |
|
|
|
|
|
|
|
|
feedbackCount.className = 'badge bg-dark text-white ms-2 rounded-pill px-3 py-1'; |
|
|
if (total === 0) { |
|
|
feedbackCount.className = 'badge bg-secondary text-white ms-2 rounded-pill px-3 py-1'; |
|
|
} else if (total < 5) { |
|
|
feedbackCount.className = 'badge bg-dark text-white ms-2 rounded-pill px-3 py-1'; |
|
|
} else if (total < 20) { |
|
|
feedbackCount.className = 'badge bg-dark text-white ms-2 rounded-pill px-3 py-1'; |
|
|
} else { |
|
|
feedbackCount.className = 'badge bg-dark text-white ms-2 rounded-pill px-3 py-1'; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function initAnalysisModeToggle() { |
|
|
const singleModeInput = document.getElementById('singleMode'); |
|
|
const csvModeInput = document.getElementById('csvMode'); |
|
|
const singleForm = document.getElementById('singleFeedbackForm'); |
|
|
const csvForm = document.getElementById('csvUploadForm'); |
|
|
const results = document.getElementById('results'); |
|
|
|
|
|
|
|
|
singleForm.style.display = 'block'; |
|
|
csvForm.style.display = 'none'; |
|
|
if (results) results.style.display = 'none'; |
|
|
|
|
|
singleModeInput.addEventListener('change', function() { |
|
|
if (this.checked) { |
|
|
singleForm.style.display = 'block'; |
|
|
csvForm.style.display = 'none'; |
|
|
if (results) results.style.display = 'none'; |
|
|
} |
|
|
}); |
|
|
|
|
|
csvModeInput.addEventListener('change', function() { |
|
|
if (this.checked) { |
|
|
singleForm.style.display = 'none'; |
|
|
csvForm.style.display = 'block'; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
document.getElementById('csvForm').addEventListener('submit', handleCsvUpload); |
|
|
|
|
|
|
|
|
document.getElementById('downloadTemplate').addEventListener('click', downloadCsvTemplate); |
|
|
} |
|
|
|
|
|
async function handleCsvUpload(event) { |
|
|
event.preventDefault(); |
|
|
|
|
|
const csvFile = document.getElementById('csvFile').files[0]; |
|
|
const analyzeBtn = document.getElementById('analyzeCsvBtn'); |
|
|
const loadingSpinner = document.getElementById('loadingSpinner'); |
|
|
const results = document.getElementById('results'); |
|
|
|
|
|
if (!csvFile) { |
|
|
showAlert('Vui lòng chọn file CSV', 'warning'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const maxSize = 10 * 1024 * 1024; |
|
|
if (csvFile.size > maxSize) { |
|
|
showAlert('File quá lớn. Kích thước tối đa là 10MB.', 'danger'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (!csvFile.name.toLowerCase().endsWith('.csv')) { |
|
|
showAlert('Vui lòng chọn file có định dạng .csv', 'danger'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
analyzeBtn.disabled = true; |
|
|
analyzeBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Đang phân tích...'; |
|
|
loadingSpinner.style.display = 'block'; |
|
|
results.style.display = 'none'; |
|
|
|
|
|
const formData = new FormData(); |
|
|
formData.append('csvFile', csvFile); |
|
|
|
|
|
try { |
|
|
const response = await fetch('/analyze-csv', { |
|
|
method: 'POST', |
|
|
body: formData |
|
|
}); |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
if (response.ok && data.success) { |
|
|
showCsvResults(data); |
|
|
showAlert(data.message, 'success'); |
|
|
|
|
|
setTimeout(() => { |
|
|
loadFeedbackHistory(1, true); |
|
|
}, 500); |
|
|
} else { |
|
|
showAlert(data.error || 'Có lỗi xảy ra khi xử lý file CSV', 'danger'); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('CSV upload error:', error); |
|
|
showAlert('Có lỗi xảy ra khi upload file CSV', 'danger'); |
|
|
} finally { |
|
|
|
|
|
analyzeBtn.disabled = false; |
|
|
analyzeBtn.innerHTML = '<i class="fas fa-chart-bar me-2"></i>Phân Tích File CSV'; |
|
|
loadingSpinner.style.display = 'none'; |
|
|
|
|
|
|
|
|
document.getElementById('csvFile').value = ''; |
|
|
} |
|
|
} |
|
|
|
|
|
function showCsvResults(data) { |
|
|
const results = document.getElementById('results'); |
|
|
|
|
|
let html = ` |
|
|
<div class="card shadow-lg mb-3"> |
|
|
<div class="card-header bg-secondary text-white"> |
|
|
<h4 class="card-title mb-0"> |
|
|
<i class="fas fa-check-circle me-2"></i> |
|
|
Kết quả phân tích CSV |
|
|
</h4> |
|
|
</div> |
|
|
<div class="card-body"> |
|
|
<div class="row mb-3"> |
|
|
<div class="col-md-4"> |
|
|
<div class="text-center"> |
|
|
<h5 class="text-success">${data.processed_count}</h5> |
|
|
<small class="text-muted">Thành công</small> |
|
|
</div> |
|
|
</div> |
|
|
<div class="col-md-4"> |
|
|
<div class="text-center"> |
|
|
<h5 class="text-danger">${data.error_count}</h5> |
|
|
<small class="text-muted">Lỗi</small> |
|
|
</div> |
|
|
</div> |
|
|
<div class="col-md-4"> |
|
|
<div class="text-center"> |
|
|
<h5 class="text-secondary">${data.total_rows}</h5> |
|
|
<small class="text-muted">Tổng cộng</small> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="table-responsive"> |
|
|
<table class="table table-sm" id="csvResultsTable"> |
|
|
<thead> |
|
|
<tr> |
|
|
<th>Dòng</th> |
|
|
<th>Feedback</th> |
|
|
<th>Sentiment</th> |
|
|
<th>Topic</th> |
|
|
<th>Tin cậy</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody> |
|
|
`; |
|
|
|
|
|
|
|
|
const displayResults = data.results.slice(0, 10); |
|
|
|
|
|
displayResults.forEach(result => { |
|
|
if (result.success) { |
|
|
const sentimentConfig = getSentimentConfig(result.sentiment); |
|
|
const topicConfig = getTopicConfig(result.topic); |
|
|
|
|
|
html += ` |
|
|
<tr> |
|
|
<td>${result.row}</td> |
|
|
<td>${result.text}</td> |
|
|
<td> |
|
|
<span class="badge bg-${getSentimentColor(result.sentiment)}"> |
|
|
<i class="fas fa-${sentimentConfig.icon} me-1"></i> |
|
|
${sentimentConfig.label} |
|
|
</span> |
|
|
</td> |
|
|
<td> |
|
|
<span class="badge bg-secondary"> |
|
|
<i class="fas fa-${topicConfig.icon} me-1"></i> |
|
|
${topicConfig.label} |
|
|
</span> |
|
|
</td> |
|
|
<td> |
|
|
<small class="text-muted"> |
|
|
S: ${result.sentiment_confidence}%<br> |
|
|
T: ${result.topic_confidence}% |
|
|
</small> |
|
|
</td> |
|
|
</tr> |
|
|
`; |
|
|
} else { |
|
|
html += ` |
|
|
<tr class="table-danger"> |
|
|
<td>${result.row}</td> |
|
|
<td>${result.text}</td> |
|
|
<td colspan="3"> |
|
|
<small class="text-danger"> |
|
|
<i class="fas fa-exclamation-triangle me-1"></i> |
|
|
${result.error} |
|
|
</small> |
|
|
</td> |
|
|
</tr> |
|
|
`; |
|
|
} |
|
|
}); |
|
|
|
|
|
html += ` |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
${data.total_rows > 10 ? ` |
|
|
<div class="alert alert-info mb-0 d-flex align-items-center custom-alert-left"> |
|
|
<i class="fas fa-info-circle me-2"></i> |
|
|
Chỉ hiển thị 10 dòng đầu tiên trong tổng số ${data.total_rows} dòng |
|
|
</div> |
|
|
` : ''} |
|
|
`; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
results.innerHTML = html; |
|
|
results.style.display = 'block'; |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
|
|
|
const blockPosition = data.total_rows > 10 ? 'start' : 'center'; |
|
|
results.scrollIntoView({ behavior: 'smooth', block: blockPosition }); |
|
|
}, 100); |
|
|
} |
|
|
|
|
|
function downloadCsvTemplate(event) { |
|
|
event.preventDefault(); |
|
|
|
|
|
const csvContent = `feedback |
|
|
"Giảng viên rất nhiệt tình và giảng bài dễ hiểu" |
|
|
"Chương trình học có nhiều kiến thức bổ ích" |
|
|
"Cơ sở vật chất hiện đại và tiện nghi" |
|
|
"Thời gian học hợp lý và không quá căng thẳng" |
|
|
"Tài liệu học tập đầy đủ và chất lượng"`; |
|
|
|
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); |
|
|
const link = document.createElement('a'); |
|
|
|
|
|
if (link.download !== undefined) { |
|
|
const url = URL.createObjectURL(blob); |
|
|
link.setAttribute('href', url); |
|
|
link.setAttribute('download', 'feedback_template.csv'); |
|
|
link.style.visibility = 'hidden'; |
|
|
document.body.appendChild(link); |
|
|
link.click(); |
|
|
document.body.removeChild(link); |
|
|
} |
|
|
} |
|
|
|