add view de
This commit is contained in:
44
management/src/components/ExamPaperPdfModal.tsx
Normal file
44
management/src/components/ExamPaperPdfModal.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { renderExamPdfPages } from '../utils/renderExamPdf';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
bytes: Uint8Array | null;
|
||||
loading?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExamPaperPdfModal({ title, bytes, loading, onClose }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!bytes || !containerRef.current) return;
|
||||
let cancelled = false;
|
||||
setErr('');
|
||||
renderExamPdfPages(containerRef.current, bytes).catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setErr(e instanceof Error ? e.message : 'Không hiển thị được PDF');
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [bytes]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay exam-pdf-viewer-overlay" onClick={onClose}>
|
||||
<div className="exam-pdf-viewer-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header exam-pdf-viewer-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button type="button" className="modal-close-btn" onClick={onClose} aria-label="Đóng">×</button>
|
||||
</div>
|
||||
<div className="exam-pdf-viewer-scroll" ref={containerRef}>
|
||||
{loading && <p className="exam-pdf-viewer-status">Đang tải đề...</p>}
|
||||
{err && <p className="login-error" style={{ padding: '1rem' }}>{err}</p>}
|
||||
{!loading && !bytes && !err && (
|
||||
<p className="exam-pdf-viewer-status">Không có dữ liệu PDF</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 có 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 có 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}
|
||||
|
||||
Reference in New Issue
Block a user