This commit is contained in:
2026-06-30 13:08:18 +07:00
parent 795726aea5
commit 1c2bfde005
18 changed files with 1504 additions and 90 deletions

View File

@@ -430,6 +430,147 @@ body {
margin-bottom: 0.5rem;
}
.exam-resources-hint {
margin: 0;
font-size: 0.82rem;
color: var(--text-muted);
}
.exam-viewer-overlay {
position: fixed;
inset: 0;
z-index: 10050;
background: rgba(15, 23, 42, 0.72);
display: none;
align-items: stretch;
justify-content: center;
padding: 0.75rem;
outline: none;
user-select: none;
}
.exam-viewer-overlay.is-open {
display: flex;
}
.exam-viewer-shell {
width: min(1100px, 100%);
margin: 0 auto;
background: #fff;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
max-height: calc(100vh - 1.5rem);
}
.exam-viewer-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 0.85rem;
background: #0f172a;
color: #f8fafc;
flex-shrink: 0;
}
.exam-viewer-title {
font-size: 0.9rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exam-viewer-body {
flex: 1;
min-height: 0;
background: #e2e8f0;
position: relative;
height: calc(100vh - 6.5rem);
}
.exam-viewer-content {
position: absolute;
inset: 0;
overflow: auto;
padding: 1rem;
background: #cbd5e1;
opacity: 0;
pointer-events: none;
}
.exam-viewer-content.is-ready {
opacity: 1;
pointer-events: auto;
}
.exam-viewer-loading,
.exam-viewer-error {
display: none;
position: absolute;
inset: 0;
z-index: 2;
align-items: center;
justify-content: center;
padding: 1.5rem;
color: #475569;
font-size: 0.92rem;
background: #e2e8f0;
}
.exam-viewer-loading.is-active,
.exam-viewer-error.is-active {
display: flex;
}
.exam-viewer-error {
color: #b91c1c;
}
.exam-viewer-error.is-active {
display: flex;
}
.exam-pdf-page-wrap {
display: flex;
justify-content: center;
margin: 0 auto 1rem;
}
.exam-pdf-page {
display: block;
max-width: 100%;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
pointer-events: none;
}
.exam-view-image {
display: block;
width: 100%;
max-width: 100%;
height: auto;
margin: 0 auto;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
pointer-events: none;
}
.exam-view-text {
max-width: 900px;
margin: 0 auto;
padding: 1rem 1.25rem;
background: #fff;
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.88rem;
line-height: 1.55;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.exam-resource-row {
display: flex;
align-items: center;

View File

@@ -0,0 +1,78 @@
import * as pdfjsLib from 'pdfjs-dist/build/pdf';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.js?url';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
function decodeBase64(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
return bytes;
}
export function detectExamViewKind(fileName, mime, bytes) {
if (bytes && bytes.length >= 4) {
if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) return 'pdf';
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image';
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image';
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image';
}
const name = String(fileName || '').toLowerCase();
const type = String(mime || '').toLowerCase();
if (type.includes('pdf') || name.endsWith('.pdf')) return 'pdf';
if (type.startsWith('image/') || /\.(png|jpe?g|gif|webp)$/.test(name)) return 'image';
if (type.startsWith('text/') || name.endsWith('.txt')) return 'text';
return 'unsupported';
}
export async function renderSecurePdf(container, bytes) {
container.innerHTML = '';
const pdf = await pdfjsLib.getDocument({ data: bytes }).promise;
const pad = 16;
const width = container.clientWidth || container.parentElement?.clientWidth || 900;
const maxWidth = Math.max(360, width - pad * 2);
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) {
const page = await pdf.getPage(pageNum);
const base = page.getViewport({ scale: 1 });
const scale = Math.min(1.6, maxWidth / base.width);
const viewport = page.getViewport({ scale });
const wrap = document.createElement('div');
wrap.className = 'exam-pdf-page-wrap';
const canvas = document.createElement('canvas');
canvas.className = 'exam-pdf-page';
canvas.width = viewport.width;
canvas.height = viewport.height;
wrap.appendChild(canvas);
container.appendChild(wrap);
await page.render({
canvasContext: canvas.getContext('2d'),
viewport,
}).promise;
}
}
export function renderSecureImage(container, bytes, mime) {
container.innerHTML = '';
const blob = new Blob([bytes], { type: mime || 'image/png' });
const img = document.createElement('img');
img.className = 'exam-view-image';
img.alt = 'Tài nguyên';
img.draggable = false;
img.src = URL.createObjectURL(blob);
container.appendChild(img);
}
export function renderSecureText(container, bytes) {
container.innerHTML = '';
const pre = document.createElement('pre');
pre.className = 'exam-view-text';
pre.textContent = new TextDecoder('utf-8').decode(bytes);
container.appendChild(pre);
}
export function decodeExamFilePayload(payload) {
return decodeBase64(payload.data || payload);
}

View File

@@ -1,6 +1,13 @@
import './style.css';
import './app.css';
import logoUrl from './assets/logo.jpeg';
import {
decodeExamFilePayload,
detectExamViewKind,
renderSecureImage,
renderSecurePdf,
renderSecureText,
} from './examViewer.js';
const brandLogoHtml = `<img src="${logoUrl}" alt="Simple Care" class="brand-logo-img" />`;
@@ -68,9 +75,12 @@ function init() {
});
window.runtime.EventsOn('exam:paper-sent', () => {
if (loggedIn) {
examPaperFiles = null;
examPaperFilesRoomId = 0;
lastExamPanelKey = '';
window.go.main.App.GetStats().then((s) => {
stats = { ...stats, ...s };
updateExamPanel();
updateExamPanel(true);
}).catch(console.error);
}
});
@@ -87,6 +97,7 @@ async function checkLogin() {
startStatsTicker();
ensureChatWidget();
updateChatBadge();
if (stats.monitorMode === 'exam') updateExamPanel();
} else {
loggedIn = false;
renderLoginPrompt();
@@ -179,36 +190,197 @@ function renderShiftsTable(shifts) {
}
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 = `
<div class="exam-viewer-shell">
<div class="exam-viewer-bar">
<button type="button" class="btn btn-secondary" id="exam-viewer-close">← Quay lại</button>
<span class="exam-viewer-title" id="exam-viewer-title"></span>
</div>
<div class="exam-viewer-body" id="exam-viewer-body">
<div class="exam-viewer-content" id="exam-viewer-content"></div>
<div class="exam-viewer-loading" id="exam-viewer-loading">Đang tải...</div>
<div class="exam-viewer-error" id="exam-viewer-error"></div>
</div>
</div>
`;
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() {
if (!examPaperFiles?.resources?.length) return '';
const rows = examPaperFiles.resources.map((r) => `
const ex = stats.exam;
if (!ex?.paperSent) return '';
if (examPaperFilesLoading && !examPaperFiles) {
return `
<div class="exam-resources">
<div class="exam-resources-title">📎 Tài nguyên kèm đề</div>
<p class="exam-resources-hint">Đang tải danh sách tài nguyên...</p>
</div>
`;
}
const resources = Array.isArray(examPaperFiles?.resources) ? examPaperFiles.resources : [];
if (!resources.length) {
return `
<div class="exam-resources">
<div class="exam-resources-title">📎 Tài nguyên kèm đề</div>
<p class="exam-resources-hint">Gói đề này không có tài nguyên đính kèm.</p>
</div>
`;
}
const rows = resources.map((r) => `
<div class="exam-resource-row">
<span class="exam-resource-name" title="${escapeHtml(r.fileName || '')}">${escapeHtml(r.fileName || 'Tài nguyên')}</span>
<div class="exam-resource-btns">
<button type="button" class="btn btn-secondary btn-sm btn-exam-res-view" data-id="${r.id}">Xem</button>
<button type="button" class="btn btn-secondary btn-sm btn-exam-res-dl" data-id="${r.id}">Tải về</button>
</div>
<button type="button" class="btn btn-secondary btn-sm btn-exam-res-dl" data-id="${r.id}">Tải về</button>
</div>
`).join('');
return `
<div class="exam-resources">
<div class="exam-resources-title">Tài nguyên kèm đề</div>
<div class="exam-resources-title">📎 Tài nguyên kèm đề (${resources.length})</div>
${rows}
</div>
`;
}
async function loadExamPaperFiles() {
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();
} catch {
examPaperFiles = null;
examPaperFilesRoomId = ex.examRoomId;
} catch (err) {
console.error('GetExamPaperFiles failed:', err);
examPaperFiles = { resources: [] };
examPaperFilesRoomId = ex.examRoomId;
} finally {
examPaperFilesLoading = false;
}
}
@@ -229,7 +401,7 @@ function renderExamPanel() {
<div class="card-title-row">
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
</div>
<p class="exam-panel-desc">Bạn đang trong giờ thi. Đề, link thi và tài nguyên mở trong app — không dùng trình duyệt ngoài.</p>
<p class="exam-panel-desc">Đề PDF và tài nguyên xem ngay trong app. Link trắc nghiệm mở trang thi — xong thì menu <strong>Simple Care → Về trang chính</strong> (Ctrl+H).</p>
<div class="exam-panel-actions">
${paperBtn}
${quizBtn}
@@ -241,9 +413,15 @@ function renderExamPanel() {
}
function wireExamPanel() {
const ex = stats.exam;
const paper = document.getElementById('btn-exam-paper');
if (paper) paper.addEventListener('click', () => {
window.go.main.App.OpenExamPaper().catch((e) => alert(e?.message || e));
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', () => {
@@ -262,18 +440,11 @@ function wireExamPanel() {
alert(e?.message || e || 'Nộp bài thất bại');
}
});
document.querySelectorAll('.btn-exam-res-view').forEach((btn) => {
btn.addEventListener('click', () => {
const id = Number(btn.getAttribute('data-id'));
window.go.main.App.OpenExamResource(id).catch((e) => alert(e?.message || e));
});
});
document.querySelectorAll('.btn-exam-res-dl').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = Number(btn.getAttribute('data-id'));
try {
const path = await window.go.main.App.DownloadExamResource(id);
alert(`Đã tải về:\n${path}`);
await window.go.main.App.DownloadExamResource(id);
} catch (e) {
alert(e?.message || e || 'Tải thất bại');
}
@@ -281,14 +452,26 @@ function wireExamPanel() {
});
}
async function updateExamPanel() {
async function updateExamPanel(forceReloadFiles = false) {
const host = document.getElementById('exam-panel-host');
if (!host) return;
await loadExamPaperFiles();
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;
@@ -629,7 +812,17 @@ function startStatsTicker() {
}
}
updateExamPanel();
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);