import './style.css'; import './app.css'; import logoUrl from './assets/logo.jpeg'; import { decodeExamFilePayload, detectExamViewKind, renderSecureImage, renderSecurePdf, renderSecureText, } from './examViewer.js'; import * as GoApp from '../wailsjs/go/main/App.js'; // Map Wails bindings to window.go.main.App for backward compatibility with obfuscated builds window.go = window.go || {}; window.go.main = window.go.main || {}; if (typeof window.go.main.App === 'undefined') { window.go.main.App = GoApp; window.go.main._custom = true; } const brandLogoHtml = `Simple Care`; const APP_VERSION = '1.2'; // Trạng thái cục bộ let loggedIn = false; let studentInfo = null; let stats = { wifiSSID: '', onlineSecs: 0, offlineSecs: 0, wsConnected: false, serverReachable: false, allowedApps: '', monitorMode: '', monitorLabel: '', className: '', classCode: '', currentPeriod: 0, currentCourseName: '', shifts: [], exam: null }; let chatOpen = false; let chatMessages = []; let chatConversations = []; let activeStaffId = 0; let lastRenderedMsgCount = 0; let bannerStaffId = 0; // Quản lý webcam let webcamStream = null; let webcamInterval = null; const webcamVideo = document.createElement('video'); webcamVideo.autoplay = true; webcamVideo.playsInline = true; webcamVideo.style.display = 'none'; document.body.appendChild(webcamVideo); const webcamCanvas = document.createElement('canvas'); webcamCanvas.width = 320; webcamCanvas.height = 240; webcamCanvas.style.display = 'none'; document.body.appendChild(webcamCanvas); function init() { const ready = (typeof window.ObfuscatedCall === 'function') || (typeof window.go !== 'undefined' && typeof window.go.main !== 'undefined' && !window.go.main._custom); if (!ready || typeof window.runtime === 'undefined') { setTimeout(init, 200); return; } window.runtime.EventsOn('start_webcam_stream', startWebcam); window.runtime.EventsOn('stop_webcam_stream', stopWebcam); window.runtime.EventsOn('chat:message', (data) => { updateChatBadge(); loadChatConversations().catch(console.error); if (chatOpen && activeStaffId) { loadChatMessages(activeStaffId, true).catch(console.error); } }); window.runtime.EventsOn('chat:notify', (data) => { updateChatBadge(); if (data?.staffId) bannerStaffId = Number(data.staffId); pulseChatFab(); }); window.runtime.EventsOn('exam:paper-sent', () => { if (loggedIn) { examPaperFiles = null; examPaperFilesRoomId = 0; lastExamPanelKey = ''; window.go.main.App.GetStats().then((s) => { stats = { ...stats, ...s }; updateExamPanel(true); }).catch(console.error); } }); checkLogin(); } async function checkLogin() { try { const isLogged = await window.go.main.App.CheckLoginStatus(); if (isLogged) { loggedIn = true; studentInfo = await window.go.main.App.GetStudentInfo(); renderDashboard(); startStatsTicker(); ensureChatWidget(); updateChatBadge(); if (stats.monitorMode === 'exam') updateExamPanel(); } else { loggedIn = false; renderLoginPrompt(); } } catch (err) { console.error('Error checking login status:', err); } } function renderLoginPrompt() { document.querySelector('#app').innerHTML = `
`; document.getElementById('btn-goto-login').addEventListener('click', () => { window.go.main.App.NavigateToLogin(); }); } function monitorModeMeta(mode) { switch (mode) { case 'learning': return { icon: '🛡️', label: 'Đang giám sát', cls: 'mode-learning' }; case 'exam': return { icon: '📝', label: 'Phòng thi', cls: 'mode-exam' }; case 'outside_schedule': return { icon: '😴', label: 'Ngoài giờ học', cls: 'mode-outside' }; case 'not_configured': return { icon: '⚙️', label: 'Chưa sẵn sàng', cls: 'mode-config' }; default: return { icon: '⏳', label: 'Đang kết nối', cls: 'mode-pending' }; } } function attendanceClass(status) { if (status === 4) return 'att-ok'; if (status === 3) return 'att-late'; if (status === 1 || status === 2) return 'att-leave'; if (status === 0) return 'att-absent'; return 'att-pending'; } function renderShiftsTable(shifts) { if (!shifts || shifts.length === 0) { return '
Hôm nay không có ca học theo lịch lớp.
'; } return ` ${shifts.map(s => ` `).join('')}
Ca Môn học Giờ Trực tuyến Ngoại tuyến Điểm danh
Ca ${s.period}${s.isActiveNow ? ' đang học' : ''} ${s.courseName || '—'} ${s.startTime}–${s.endTime} ${formatDuration(s.onlineSeconds || 0)} ${formatDuration(s.offlineSeconds || 0)} ${s.attendanceLabel || 'Chưa tính'}
`; } let examPaperFiles = null; let examPaperFilesRoomId = 0; let examPaperFilesLoading = false; let lastExamPanelKey = ''; let examViewerObjectUrl = null; function wireExamViewerGuards(overlay) { overlay.addEventListener('contextmenu', (e) => e.preventDefault()); overlay.addEventListener('keydown', (e) => { const key = e.key.toLowerCase(); if ((e.ctrlKey || e.metaKey) && (key === 'p' || key === 's')) { e.preventDefault(); } }); } function ensureExamViewer() { let overlay = document.getElementById('exam-viewer-overlay'); if (overlay) return overlay; overlay = document.createElement('div'); overlay.id = 'exam-viewer-overlay'; overlay.className = 'exam-viewer-overlay'; overlay.innerHTML = `
Đang tải...
`; document.body.appendChild(overlay); overlay.querySelector('#exam-viewer-close').addEventListener('click', closeExamViewer); wireExamViewerGuards(overlay); return overlay; } function setExamViewerLoading(loading) { const loadingEl = document.getElementById('exam-viewer-loading'); const contentEl = document.getElementById('exam-viewer-content'); const errorEl = document.getElementById('exam-viewer-error'); if (loadingEl) loadingEl.classList.toggle('is-active', loading); if (contentEl) contentEl.classList.toggle('is-ready', !loading); if (errorEl) errorEl.classList.remove('is-active'); } function setExamViewerError(message) { const loadingEl = document.getElementById('exam-viewer-loading'); const contentEl = document.getElementById('exam-viewer-content'); const errorEl = document.getElementById('exam-viewer-error'); if (loadingEl) loadingEl.classList.remove('is-active'); if (contentEl) contentEl.classList.remove('is-ready'); if (errorEl) { errorEl.classList.add('is-active'); errorEl.textContent = message; } } function resetExamViewerPanels() { const loadingEl = document.getElementById('exam-viewer-loading'); const contentEl = document.getElementById('exam-viewer-content'); const errorEl = document.getElementById('exam-viewer-error'); if (loadingEl) loadingEl.classList.remove('is-active'); if (contentEl) contentEl.classList.remove('is-ready'); if (errorEl) errorEl.classList.remove('is-active'); } function clearExamViewerContent() { if (examViewerObjectUrl) { URL.revokeObjectURL(examViewerObjectUrl); examViewerObjectUrl = null; } const contentEl = document.getElementById('exam-viewer-content'); if (contentEl) contentEl.innerHTML = ''; } async function showExamViewer(url, title, fileName = '') { const overlay = ensureExamViewer(); document.getElementById('exam-viewer-title').textContent = title || 'Xem trong app'; overlay.classList.add('is-open'); overlay.focus(); clearExamViewerContent(); resetExamViewerPanels(); setExamViewerLoading(true); const contentEl = document.getElementById('exam-viewer-content'); if (!contentEl) return; try { const payload = await window.go.main.App.LoadExamViewFile(url); const bytes = decodeExamFilePayload(payload); const mime = payload?.mime || ''; const kind = detectExamViewKind(fileName || title, mime, bytes); if (kind === 'pdf') { await new Promise((resolve) => requestAnimationFrame(resolve)); await renderSecurePdf(contentEl, bytes); } else if (kind === 'image') { renderSecureImage(contentEl, bytes, mime); const img = contentEl.querySelector('img'); if (img?.src?.startsWith('blob:')) examViewerObjectUrl = img.src; if (img) { await new Promise((resolve, reject) => { if (img.complete) resolve(); else { img.onload = () => resolve(); img.onerror = () => reject(new Error('Không hiển thị được ảnh')); } }); } } else if (kind === 'text') { renderSecureText(contentEl, bytes); } else { setExamViewerError('Chỉ hỗ trợ xem PDF, ảnh hoặc file text trong app.'); return; } setExamViewerLoading(false); } catch (e) { setExamViewerError(e?.message || e || 'Không mở được file'); } } function closeExamViewer() { const overlay = document.getElementById('exam-viewer-overlay'); if (!overlay) return; overlay.classList.remove('is-open'); clearExamViewerContent(); resetExamViewerPanels(); } function renderExamResources() { const ex = stats.exam; if (!ex?.paperSent) return ''; if (examPaperFilesLoading && !examPaperFiles) { return `
📎 Tài nguyên kèm đề

Đang tải danh sách tài nguyên...

`; } const resources = Array.isArray(examPaperFiles?.resources) ? examPaperFiles.resources : []; if (!resources.length) { return `
📎 Tài nguyên kèm đề

Gói đề này không có tài nguyên đính kèm.

`; } const rows = resources.map((r) => `
${escapeHtml(r.fileName || 'Tài nguyên')}
`).join(''); return `
📎 Tài nguyên kèm đề (${resources.length})
${rows}
`; } async function loadExamPaperFiles(force = false) { const ex = stats.exam; if (!ex || stats.monitorMode !== 'exam' || !ex.paperSent) { examPaperFiles = null; examPaperFilesRoomId = 0; examPaperFilesLoading = false; return; } if (!force && examPaperFiles && examPaperFilesRoomId === ex.examRoomId) { return; } examPaperFilesLoading = true; try { examPaperFiles = await window.go.main.App.GetExamPaperFiles(); examPaperFilesRoomId = ex.examRoomId; } catch (err) { console.error('GetExamPaperFiles failed:', err); examPaperFiles = { resources: [] }; examPaperFilesRoomId = ex.examRoomId; } finally { examPaperFilesLoading = false; } } function renderExamPanel() { const ex = stats.exam; if (!ex || stats.monitorMode !== 'exam') return ''; const paperBtn = ex.paperSent ? `` : `Chờ giảng viên gửi đề...`; const quizBtn = ex.quizUrl ? `` : ''; const submitBtn = ex.submitted ? `✓ Đã nộp bài tự luận` : ``; return `
📝 ${ex.examName || 'Phòng thi'}

Đề PDF và tài nguyên xem trong app. Trắc nghiệm mở trang quiz trực tiếp (giữ đăng nhập). F5 / Xóa cache / Về trang chính: menu Simple Care (F5, Ctrl+Delete, Ctrl+H).

${paperBtn} ${quizBtn} ${submitBtn}
${renderExamResources()}
`; } function wireExamPanel() { const ex = stats.exam; const paper = document.getElementById('btn-exam-paper'); if (paper) paper.addEventListener('click', async () => { try { const url = await window.go.main.App.GetExamPaperViewURL(); showExamViewer(url, ex?.paperTitle || 'Đề thi', ex?.paperTitle || 'de.pdf'); } catch (e) { alert(e?.message || e || 'Không mở được đề'); } }); const quiz = document.getElementById('btn-exam-quiz'); if (quiz) quiz.addEventListener('click', () => { window.go.main.App.OpenExamQuiz().catch((e) => alert(e?.message || e)); }); const submit = document.getElementById('btn-exam-submit'); if (submit) submit.addEventListener('click', async () => { try { const name = await window.go.main.App.SubmitExamWork(); alert(`Đã nộp bài: ${name}`); const fresh = await window.go.main.App.GetStats(); stats = { ...stats, ...fresh }; await loadExamPaperFiles(); updateExamPanel(); } catch (e) { alert(e?.message || e || 'Nộp bài thất bại'); } }); document.querySelectorAll('.btn-exam-res-dl').forEach((btn) => { btn.addEventListener('click', async () => { const id = Number(btn.getAttribute('data-id')); try { await window.go.main.App.DownloadExamResource(id); } catch (e) { alert(e?.message || e || 'Tải thất bại'); } }); }); } async function updateExamPanel(forceReloadFiles = false) { const host = document.getElementById('exam-panel-host'); if (!host) return; if (stats.monitorMode !== 'exam' || !stats.exam) { host.innerHTML = ''; lastExamPanelKey = ''; return; } await loadExamPaperFiles(forceReloadFiles); host.innerHTML = renderExamPanel(); wireExamPanel(); } function examPanelKey() { const ex = stats.exam; if (!ex) return ''; const resCount = Array.isArray(examPaperFiles?.resources) ? examPaperFiles.resources.length : -1; return `${ex.examRoomId}:${ex.paperSent}:${ex.submitted}:${resCount}:${examPaperFilesLoading}`; } function renderDashboard() { if (!studentInfo) return; const mode = monitorModeMeta(stats.monitorMode); const classLine = stats.className ? `${stats.className}${stats.classCode ? ` (${stats.classCode})` : ''}` : 'Đang xác định lớp...'; document.querySelector('#app').innerHTML = `
${brandLogoHtml}
Simple Care v${APP_VERSION} Rikkei Education
Avatar

${studentInfo.fullName}

${studentInfo.studentCode}

${studentInfo.email}

Lớp ${classLine}
Điện thoại ${studentInfo.phone || 'Chưa cung cấp'}
${mode.icon}
${mode.label} ${classLine}
${stats.monitorLabel || stats.statusMsg || 'Đang kết nối...'}
${stats.currentPeriod > 0 && stats.currentCourseName ? `Ca ${stats.currentPeriod} · ${stats.currentCourseName}` : 'Theo dõi theo lịch học của lớp'}
${renderExamPanel()}
Công cụ học tập

GitHub & Google Dịch mở trong app. Trình duyệt Local chỉ chạy link localhost để test bài làm.

📡
WiFi
${stats.wifiSSID || 'Đang quét...'}
🖥️
Máy chủ
${stats.serverReachable ? 'Đã kết nối' : 'Mất kết nối'}
Hôm nay — theo ca
00:00:00 · 00:00:00 ${stats.sessionDate || ''}
${renderShiftsTable(stats.shifts)}
`; document.getElementById('btn-logout').addEventListener('click', async () => { if (confirm('Bạn có chắc chắn muốn đăng xuất khỏi ứng dụng giám sát?')) { loggedIn = false; studentInfo = null; stopWebcam(); const w = document.getElementById('student-chat-widget'); if (w) w.remove(); await window.go.main.App.Logout(); } }); document.getElementById('btn-open-github')?.addEventListener('click', () => { window.go.main.App.OpenGitHub(); }); document.getElementById('btn-open-translate')?.addEventListener('click', () => { window.go.main.App.OpenGoogleTranslate(); }); document.getElementById('btn-open-local-browser')?.addEventListener('click', () => { window.go.main.App.OpenLocalBrowser(); }); wireExamPanel(); } function ensureChatWidget() { if (document.getElementById('student-chat-widget')) return; const wrap = document.createElement('div'); wrap.id = 'student-chat-widget'; wrap.className = 'student-chat-dock'; wrap.innerHTML = `
Tin nhắn
Chọn hội thoại
Chọn giảng viên bên trái để xem tin nhắn
`; document.body.appendChild(wrap); document.getElementById('student-chat-fab').addEventListener('click', () => setChatOpen(true)); document.getElementById('student-chat-close').addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); setChatOpen(false); }); document.getElementById('student-chat-send').addEventListener('click', sendStudentChat); document.getElementById('student-chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); sendStudentChat(); } }); } function pulseChatFab() { const fab = document.getElementById('student-chat-fab'); if (!fab) return; fab.classList.remove('student-chat-fab--pulse'); void fab.offsetWidth; fab.classList.add('student-chat-fab--pulse'); } function setChatOpen(open) { chatOpen = open; const modal = document.getElementById('student-chat-modal'); const fab = document.getElementById('student-chat-fab'); if (modal) modal.classList.toggle('is-open', open); if (fab) fab.classList.toggle('is-hidden', open); if (open) { loadChatConversations().then(() => { const sid = bannerStaffId || activeStaffId || Number(chatConversations[0]?.staffId || 0); if (sid) selectStaffChat(sid); }).catch(console.error); } } function selectStaffChat(staffId) { if (!staffId) return; activeStaffId = staffId; const conv = chatConversations.find((c) => Number(c.staffId) === staffId); const head = document.getElementById('student-chat-thread-head'); if (head) head.textContent = conv?.staffName || conv?.staffEmail || 'Giảng viên'; renderChatConversations(); loadChatMessages(staffId).catch(console.error); } async function loadChatConversations() { const rows = await window.go.main.App.GetChatConversations(); chatConversations = Array.isArray(rows) ? rows : []; if (chatOpen) renderChatConversations(); updateChatBadge(); return chatConversations; } function renderChatConversations() { const list = document.getElementById('student-chat-sidebar'); if (!list) return; if (!chatConversations.length) { list.innerHTML = '
Chưa có tin nhắn
'; return; } list.innerHTML = chatConversations.map((c) => { const sid = Number(c.staffId || 0); const unread = Number(c.unread || 0); const active = sid === activeStaffId ? ' active' : ''; const name = escapeHtml(c.staffName || c.staffEmail || 'Giảng viên'); const preview = escapeHtml(c.lastMessage || '—'); return ``; }).join(''); list.querySelectorAll('.student-chat-conv-row').forEach((btn) => { btn.addEventListener('click', () => selectStaffChat(Number(btn.getAttribute('data-staff-id')))); }); } async function loadChatMessages(staffId, silent = false) { const msgs = await window.go.main.App.GetChatMessages(staffId); chatMessages = Array.isArray(msgs) ? msgs : []; renderChatMessages(silent); updateChatBadge(); if (!silent) loadChatConversations().catch(console.error); } function renderChatMessages(silent = false) { const box = document.getElementById('student-chat-messages'); if (!box) return; const wasAtBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 48; if (!chatMessages.length) { box.innerHTML = '
Chưa có tin nhắn
'; lastRenderedMsgCount = 0; return; } if (chatMessages.length === lastRenderedMsgCount && silent) return; lastRenderedMsgCount = chatMessages.length; box.innerHTML = chatMessages.map((m) => { const role = m.senderRole === 'student' ? 'student' : 'staff'; const time = m.createdAt ? new Date(m.createdAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' }) : ''; return `
${escapeHtml(m.body || '')}
${time}
`; }).join(''); if (!silent || wasAtBottom) { requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; }); } } function escapeHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } async function sendStudentChat() { const input = document.getElementById('student-chat-input'); if (!input || !input.value.trim() || !activeStaffId) return; try { await window.go.main.App.SendChatMessage(input.value.trim()); input.value = ''; await loadChatMessages(activeStaffId); } catch (err) { alert(err?.message || err || 'Gửi tin thất bại'); } } async function updateChatBadge() { const badge = document.getElementById('student-chat-badge'); if (!badge || !window.go?.main?.App?.GetChatUnread) return; try { const n = await window.go.main.App.GetChatUnread(); if (n > 0) { badge.hidden = false; badge.textContent = n > 9 ? '9+' : String(n); } else { badge.hidden = true; } } catch { /* ignore */ } } function formatDuration(totalSeconds) { const hrs = Math.floor(totalSeconds / 3600); const mins = Math.floor((totalSeconds % 3600) / 60); const secs = totalSeconds % 60; const pad = (num) => String(num).padStart(2, '0'); return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`; } function startStatsTicker() { const pullStats = async () => { if (!loggedIn) return; try { const freshStats = await window.go.main.App.GetStats(); stats = { ...stats, ...freshStats }; if (!Array.isArray(stats.shifts)) stats.shifts = []; const wifiEl = document.getElementById('wifi-ssid'); if (wifiEl) wifiEl.innerText = stats.wifiSSID || 'Không có WiFi'; const serverEl = document.getElementById('server-status'); if (serverEl) { serverEl.innerText = stats.serverReachable ? 'Đã kết nối' : 'Mất kết nối'; serverEl.style.color = stats.serverReachable ? 'var(--online-color)' : 'var(--offline-color)'; } const statusMsgEl = document.getElementById('status-message'); if (statusMsgEl) { statusMsgEl.innerText = stats.monitorLabel || stats.statusMsg || 'Đang kết nối...'; } const mode = monitorModeMeta(stats.monitorMode); const statusIconEl = document.getElementById('status-icon'); if (statusIconEl) statusIconEl.innerText = mode.icon; const modeTagEl = document.getElementById('monitor-mode-tag'); if (modeTagEl) modeTagEl.innerText = mode.label; const banner = document.querySelector('.status-banner'); if (banner) banner.className = `card status-banner ${mode.cls}`; const classLine = stats.className ? `${stats.className}${stats.classCode ? ` (${stats.classCode})` : ''}` : 'Đang xác định lớp...'; const classLineEl = document.getElementById('status-class-line'); if (classLineEl) classLineEl.innerText = classLine; const profileClassEl = document.getElementById('profile-class'); if (profileClassEl) profileClassEl.innerText = classLine; const statusSubEl = document.getElementById('status-sub'); if (statusSubEl) { if (stats.monitorMode === 'exam' && stats.exam?.examName) { statusSubEl.innerText = stats.exam.examName; } else { statusSubEl.innerText = stats.currentPeriod > 0 && stats.currentCourseName ? `Ca ${stats.currentPeriod} · ${stats.currentCourseName}` : 'Theo dõi theo lịch học của lớp'; } } if (stats.monitorMode === 'exam' && stats.exam) { const panelKey = examPanelKey(); if (panelKey !== lastExamPanelKey) { lastExamPanelKey = panelKey; updateExamPanel(); } } else if (lastExamPanelKey !== '') { lastExamPanelKey = ''; const host = document.getElementById('exam-panel-host'); if (host) host.innerHTML = ''; } const onlineEl = document.getElementById('clock-online'); if (onlineEl) onlineEl.innerText = formatDuration(stats.onlineSecs || 0); const offlineEl = document.getElementById('clock-offline'); if (offlineEl) offlineEl.innerText = formatDuration(stats.offlineSecs || 0); const shiftsWrap = document.getElementById('shifts-wrap'); if (shiftsWrap) shiftsWrap.innerHTML = renderShiftsTable(stats.shifts); const sessionDateEl = document.getElementById('session-date'); if (sessionDateEl && stats.sessionDate) sessionDateEl.innerText = stats.sessionDate; const indicator = document.querySelector('.status-indicator'); if (indicator) { indicator.className = `status-indicator ${stats.serverReachable ? 'online' : 'offline'}`; } updateChatBadge(); } catch (err) { console.error('Error fetching stats:', err); } }; pullStats(); setInterval(pullStats, 1000); } async function startWebcam() { if (webcamStream) return; try { webcamStream = await navigator.mediaDevices.getUserMedia({ video: { width: 320, height: 240, frameRate: { max: 10 } } }); webcamVideo.srcObject = webcamStream; webcamInterval = setInterval(() => { const ctx = webcamCanvas.getContext('2d'); if (ctx) { ctx.drawImage(webcamVideo, 0, 0, webcamCanvas.width, webcamCanvas.height); const dataUrl = webcamCanvas.toDataURL('image/jpeg', 0.4); window.go.main.App.SendWebcamFrame(dataUrl); } }, 250); } catch (err) { console.error('Failed to open webcam:', err); } } function stopWebcam() { if (webcamInterval) { clearInterval(webcamInterval); webcamInterval = null; } if (webcamStream) { webcamStream.getTracks().forEach(track => track.stop()); webcamStream = null; } webcamVideo.srcObject = null; } init();