add view de

This commit is contained in:
2026-06-30 15:34:59 +07:00
parent 840ddcdce9
commit 595aebd8a5
10 changed files with 1325 additions and 10 deletions

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
apiExam,
staffFetch,
@@ -12,6 +12,7 @@ import { navigate, pushNav } from '../navigation';
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
import { openStaffChat } from '../chatEvents';
import { AppPoolModal } from './AppPoolModal';
import { ExamPaperPdfModal } from './ExamPaperPdfModal';
import { NavHistoryBar } from './NavHistoryBar';
import { StudentAvatar } from './StudentAvatar';
import { StudentDetailModal } from './StudentDetailModal';
@@ -83,6 +84,10 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions'>('roster');
const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
const [papersModalOpen, setPapersModalOpen] = useState(false);
const [pdfViewer, setPdfViewer] = useState<{ title: string; bytes: Uint8Array | null } | null>(null);
const [pdfLoading, setPdfLoading] = useState(false);
const [inlineResource, setInlineResource] = useState<{ url: string; title: string; isImage: boolean } | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -117,6 +122,25 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
useEffect(() => { load().catch(console.error); }, [load]);
const closePdfViewer = useCallback(() => {
setPdfViewer(null);
setPdfLoading(false);
}, []);
const closeInlineResource = useCallback(() => {
setInlineResource((prev) => {
if (prev?.url) URL.revokeObjectURL(prev.url);
return null;
});
}, []);
const inlineResourceRef = useRef(inlineResource);
inlineResourceRef.current = inlineResource;
useEffect(() => () => {
if (inlineResourceRef.current?.url) URL.revokeObjectURL(inlineResourceRef.current.url);
}, []);
const fetchOnline = useCallback(async () => {
try {
const res = await apiExam.fetchOnlineStudents(examId);
@@ -237,6 +261,64 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
URL.revokeObjectURL(url);
};
const viewPaperPdf = async (paperId: number, title?: string) => {
setErr('');
const paperTitle = title || papers.find((p) => p.id === paperId)?.title || 'Đề thi';
setPapersModalOpen(false);
setPdfLoading(true);
setPdfViewer({ title: paperTitle, bytes: null });
try {
const buf = await apiExam.fetchPaperPdfBytes(examId, paperId);
setPdfViewer({ title: paperTitle, bytes: new Uint8Array(buf) });
} catch (e: any) {
closePdfViewer();
setErr(e?.message || 'Không mở được đề PDF');
} finally {
setPdfLoading(false);
}
};
const viewPaperResource = async (paperId: number, resourceId: number, fileName: string) => {
setErr('');
if (/\.pdf$/i.test(fileName)) {
setPapersModalOpen(false);
setPdfLoading(true);
setPdfViewer({ title: fileName, bytes: null });
try {
const buf = await apiExam.fetchPaperResourceBytes(examId, paperId, resourceId);
setPdfViewer({ title: fileName, bytes: new Uint8Array(buf) });
} catch (e: any) {
closePdfViewer();
setErr(e?.message || 'Không mở được PDF');
} finally {
setPdfLoading(false);
}
return;
}
closeInlineResource();
try {
const buf = await apiExam.fetchPaperResourceBytes(examId, paperId, resourceId);
const isImage = /\.(png|jpe?g|gif|webp)$/i.test(fileName);
const mime = /\.png$/i.test(fileName) ? 'image/png'
: /\.jpe?g$/i.test(fileName) ? 'image/jpeg'
: /\.gif$/i.test(fileName) ? 'image/gif'
: 'image/webp';
const url = URL.createObjectURL(new Blob([buf], { type: mime }));
setInlineResource({ url, title: fileName, isImage });
} catch (e: any) {
setErr(e?.message || 'Không mở được tài nguyên');
}
};
const downloadPaperResource = async (paperId: number, resourceId: number, fileName: string) => {
setErr('');
try {
await apiExam.downloadPaperFile(examId, paperId, { resourceId, fileName });
} catch (e: any) {
setErr(e?.message || 'Không tải được tài nguyên');
}
};
const downloadAllSubs = async () => {
setErr(''); setMsg('');
setBundlingSubs(true);
@@ -417,6 +499,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
{canCancel && (
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button>
)}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ
</button>
)}
</div>
</div>
@@ -610,9 +697,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
{papers.map((p) => (
<div key={p.id} style={{ border: '1px solid var(--border-light)', borderRadius: '8px', padding: '0.75rem', background: 'var(--bg-subtle)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<strong>{p.title}</strong>
<span className="badge badge-muted">Gói đ</span>
<div style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => viewPaperPdf(p.id)}>Xem PDF</button>
<span className="badge badge-muted">Gói đ</span>
</div>
</div>
<ul className="config-card-desc" style={{ margin: '0.5rem 0 0', paddingLeft: '1.1rem' }}>
<li>📄 main.pdf (đ chính)</li>
@@ -620,7 +710,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<li style={{ color: 'var(--text-muted)' }}>Chưa tài nguyên kèm</li>
) : (
(p.resources || []).map((r) => (
<li key={r.id}>📎 {r.fileName}</li>
<li key={r.id}>
📎 {r.fileName}{' '}
<button type="button" className="link-btn" onClick={() => downloadPaperResource(p.id, r.id, r.fileName)}>
Tải
</button>
</li>
))
)}
</ul>
@@ -718,6 +813,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
+ Thêm sinh viên
</button>
)}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ ({papers.length})
</button>
)}
</div>
</div>
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} />
@@ -820,7 +920,19 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<td style={{ color: isOnline ? 'var(--success)' : 'var(--text-muted)', fontWeight: 700 }}>
{isOnline ? 'Online' : 'Offline'}
</td>
<td>{s.paperTitle || '—'}</td>
<td>
{s.paperTitle || '—'}
{s.assignedPaperId && (
<button
type="button"
className="link-btn"
style={{ marginLeft: '0.35rem' }}
onClick={() => viewPaperPdf(s.assignedPaperId!)}
>
Xem đ
</button>
)}
</td>
<td>{s.paperSentAt ? fmtTime(s.paperSentAt) : s.paperScheduledAt ? `Hẹn ${fmtTime(s.paperScheduledAt)}` : '—'}</td>
<td>{s.submitted ? <span className="badge badge-success">Đã nộp</span> : '—'}</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
@@ -853,6 +965,15 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div className="session-logs-panel">
{submissions.length > 0 && (
<div className="exam-submissions-toolbar">
{papers.length > 0 && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => setPapersModalOpen(true)}
>
📄 Xem đ phòng thi
</button>
)}
<button
type="button"
className="btn btn-primary btn-sm"
@@ -939,6 +1060,81 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</div>
</div>
{pdfViewer && (
<ExamPaperPdfModal
title={pdfViewer.title}
bytes={pdfViewer.bytes}
loading={pdfLoading}
onClose={closePdfViewer}
/>
)}
{inlineResource && (
<div className="modal-overlay exam-pdf-viewer-overlay" onClick={closeInlineResource}>
<div className="exam-pdf-viewer-modal exam-inline-resource-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header exam-pdf-viewer-header">
<h2 className="modal-title">{inlineResource.title}</h2>
<button type="button" className="modal-close-btn" onClick={closeInlineResource} aria-label="Đóng">×</button>
</div>
<div className="exam-inline-resource-body">
{inlineResource.isImage ? (
<img src={inlineResource.url} alt={inlineResource.title} className="exam-inline-resource-image" />
) : (
<iframe src={inlineResource.url} title={inlineResource.title} className="exam-pdf-viewer-frame" />
)}
</div>
</div>
</div>
)}
{papersModalOpen && (
<div className="modal-overlay" onClick={() => setPapersModalOpen(false)}>
<div className="modal-container exam-papers-view-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2 className="modal-title">Đ thi {roomName}</h2>
<p className="exam-student-modal-desc">{papers.length} gói đ trong phòng thi</p>
</div>
<button type="button" className="modal-close-btn" onClick={() => setPapersModalOpen(false)} aria-label="Đóng">×</button>
</div>
<div className="exam-papers-view-body">
{papers.map((p) => (
<div key={p.id} className="exam-paper-view-card">
<div className="exam-paper-view-head">
<strong>{p.title}</strong>
<button type="button" className="btn btn-primary btn-sm" onClick={(e) => { e.preventDefault(); void viewPaperPdf(p.id); }}>
Xem PDF đ
</button>
</div>
<ul className="exam-paper-view-resources">
<li>📄 Đ chính (PDF)</li>
{(p.resources || []).length === 0 ? (
<li className="text-muted">Không tài nguyên kèm</li>
) : (
(p.resources || []).map((r) => (
<li key={r.id}>
📎 {r.fileName}
<span className="exam-paper-view-actions">
{/\.(pdf|png|jpe?g|gif|webp|txt)$/i.test(r.fileName) && (
<button type="button" className="link-btn" onClick={() => viewPaperResource(p.id, r.id, r.fileName)}>
Xem
</button>
)}
<button type="button" className="link-btn" onClick={() => downloadPaperResource(p.id, r.id, r.fileName)}>
Tải
</button>
</span>
</li>
))
)}
</ul>
</div>
))}
</div>
</div>
</div>
)}
{selectedStudent && (
<StudentDetailModal
student={selectedStudent}