import './style.css';
import './app.css';
import logoUrl from './assets/logo.jpeg';
import {
decodeExamFilePayload,
detectExamViewKind,
renderSecureImage,
renderSecurePdf,
renderSecureText,
} from './examViewer.js';
const brandLogoHtml = `
`;
// 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() {
if (typeof window.go === 'undefined' || typeof window.go.main === '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 = `
${brandLogoHtml}
Simple Care
Rikkei Education
Đăng nhập sinh viên
Ứng dụng sẽ chuyển hướng bạn đến trang Rikkei Portal. Sau khi đăng nhập thành công, hệ thống sẽ tự động đồng bộ tài khoản học tập của bạn.
`;
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 `
| Ca |
Môn học |
Giờ |
Trực tuyến |
Ngoại tuyến |
Điểm danh |
${shifts.map(s => `
| 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'} |
`).join('')}
`;
}
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 = `
`;
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 ngay trong app. Link trắc nghiệm mở trang thi — xong thì menu Simple Care → Về trang chính (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 = `
${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()}
📡
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();
}
});
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 = `
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();