fix xem nhieu video
All checks were successful
Deploy on Master Change / deploy (push) Successful in 47s
All checks were successful
Deploy on Master Change / deploy (push) Successful in 47s
This commit is contained in:
338
management/src/components/ExamGridProctor.tsx
Normal file
338
management/src/components/ExamGridProctor.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||||
|
import { getWsUrl, type ExamRoomStudent } from '../api';
|
||||||
|
import { openStaffChat } from '../chatEvents';
|
||||||
|
|
||||||
|
interface ExamGridProctorProps {
|
||||||
|
students: ExamRoomStudent[];
|
||||||
|
onlineIds: number[];
|
||||||
|
onSelectStudent: (student: ExamRoomStudent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||||
|
students,
|
||||||
|
onlineIds,
|
||||||
|
onSelectStudent,
|
||||||
|
}) => {
|
||||||
|
const [screenFrames, setScreenFrames] = useState<Record<number, string>>({});
|
||||||
|
const [webcamFrames, setWebcamFrames] = useState<Record<number, string>>({});
|
||||||
|
const [gridCols, setGridCols] = useState<number>(3);
|
||||||
|
const [showWebcamOverlay, setShowWebcamOverlay] = useState<boolean>(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
const [onlyOnline, setOnlyOnline] = useState<boolean>(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const studentIdsString = useMemo(
|
||||||
|
() => students.map((s) => s.studentRkId).join(','),
|
||||||
|
[students]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (students.length === 0) return;
|
||||||
|
|
||||||
|
const wsUrl = getWsUrl('/ws?role=teacher');
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
students.forEach((s) => {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
event: 'teacher:subscribe',
|
||||||
|
data: { studentId: s.studentRkId },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.event === 'teacher:screenshot-stream-frame') {
|
||||||
|
const { studentId, imageBuffer } = msg.data;
|
||||||
|
setScreenFrames((prev) => ({ ...prev, [studentId]: imageBuffer }));
|
||||||
|
} else if (msg.event === 'teacher:webcam-stream-frame') {
|
||||||
|
const { studentId, imageBuffer } = msg.data;
|
||||||
|
setWebcamFrames((prev) => ({ ...prev, [studentId]: imageBuffer }));
|
||||||
|
} else if (msg.event === 'teacher:stream-stopped') {
|
||||||
|
const { studentId } = msg.data;
|
||||||
|
setScreenFrames((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[studentId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setWebcamFrames((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[studentId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing WS message in grid view:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
students.forEach((s) => {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
event: 'teacher:unsubscribe',
|
||||||
|
data: { studentId: s.studentRkId },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
}, [studentIdsString]);
|
||||||
|
|
||||||
|
// Clean up frames when students are removed or go offline
|
||||||
|
useEffect(() => {
|
||||||
|
setScreenFrames((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
let changed = false;
|
||||||
|
Object.keys(next).forEach((idStr) => {
|
||||||
|
const id = Number(idStr);
|
||||||
|
if (!onlineIds.includes(id)) {
|
||||||
|
delete next[id];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
setWebcamFrames((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
let changed = false;
|
||||||
|
Object.keys(next).forEach((idStr) => {
|
||||||
|
const id = Number(idStr);
|
||||||
|
if (!onlineIds.includes(id)) {
|
||||||
|
delete next[id];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [onlineIds]);
|
||||||
|
|
||||||
|
const filteredStudents = useMemo(() => {
|
||||||
|
return students.filter((s) => {
|
||||||
|
const matchesSearch =
|
||||||
|
s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
s.studentCode.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const isOnline = onlineIds.includes(s.studentRkId);
|
||||||
|
const matchesOnlineFilter = !onlyOnline || isOnline;
|
||||||
|
return matchesSearch && matchesOnlineFilter;
|
||||||
|
});
|
||||||
|
}, [students, onlineIds, searchQuery, onlyOnline]);
|
||||||
|
|
||||||
|
const handleOpenChat = (s: ExamRoomStudent, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openStaffChat({
|
||||||
|
studentRkId: s.studentRkId,
|
||||||
|
fullName: s.fullName,
|
||||||
|
studentCode: s.studentCode,
|
||||||
|
email: s.email,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFsChange = () => {
|
||||||
|
setIsFullscreen(document.fullscreenElement === gridContainerRef.current);
|
||||||
|
};
|
||||||
|
document.addEventListener('fullscreenchange', handleFsChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handleFsChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
const el = gridContainerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (document.fullscreenElement === el) {
|
||||||
|
document.exitFullscreen().catch(console.error);
|
||||||
|
} else {
|
||||||
|
el.requestFullscreen().catch(console.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid-proctor-container" ref={gridContainerRef}>
|
||||||
|
<div className="grid-proctor-toolbar">
|
||||||
|
<div className="grid-proctor-toolbar-left">
|
||||||
|
<div className="search-input-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="search-input"
|
||||||
|
placeholder="Lọc sinh viên..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="search-icon">🔍</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyOnline}
|
||||||
|
onChange={(e) => setOnlyOnline(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Chỉ hiện Online</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showWebcamOverlay}
|
||||||
|
onChange={(e) => setShowWebcamOverlay(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Đè webcam góc màn hình</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid-proctor-toolbar-right">
|
||||||
|
<div className="grid-cols-selector">
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Cột:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-secondary btn-xs ${gridCols === 2 ? 'active' : ''}`}
|
||||||
|
onClick={() => setGridCols(2)}
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-secondary btn-xs ${gridCols === 3 ? 'active' : ''}`}
|
||||||
|
onClick={() => setGridCols(3)}
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-secondary btn-xs ${gridCols === 4 ? 'active' : ''}`}
|
||||||
|
onClick={() => setGridCols(4)}
|
||||||
|
>
|
||||||
|
4
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-secondary btn-xs ${gridCols === 6 ? 'active' : ''}`}
|
||||||
|
onClick={() => setGridCols(6)}
|
||||||
|
>
|
||||||
|
6
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
>
|
||||||
|
{isFullscreen ? '🖥️ Thu nhỏ' : '🖥️ Toàn màn hình'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`grid-proctor-layout`}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '1rem 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredStudents.map((s) => {
|
||||||
|
const isOnline = onlineIds.includes(s.studentRkId);
|
||||||
|
const screenFrame = screenFrames[s.studentRkId];
|
||||||
|
const webcamFrame = webcamFrames[s.studentRkId];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`grid-proctor-card ${isOnline ? 'online' : 'offline'} ${s.submitted ? 'submitted' : ''}`}
|
||||||
|
onClick={() => onSelectStudent(s)}
|
||||||
|
>
|
||||||
|
<div className="grid-proctor-card-header">
|
||||||
|
<div className="student-info-left">
|
||||||
|
<span className={`status-dot ${isOnline ? 'online' : 'offline'}`} />
|
||||||
|
<span className="student-name" title={s.fullName}>
|
||||||
|
{s.fullName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="student-code">{s.studentCode}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid-proctor-card-body">
|
||||||
|
{isOnline ? (
|
||||||
|
<div className="proctor-frame-container">
|
||||||
|
{screenFrame ? (
|
||||||
|
<img
|
||||||
|
src={screenFrame}
|
||||||
|
alt={`Màn hình ${s.fullName}`}
|
||||||
|
className="proctor-screen-image"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="proctor-placeholder streaming">
|
||||||
|
<div className="sync-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px', marginBottom: '0.5rem' }} />
|
||||||
|
<span>Đang kết nối màn hình...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showWebcamOverlay && webcamFrame && (
|
||||||
|
<div className="proctor-webcam-overlay">
|
||||||
|
<img
|
||||||
|
src={webcamFrame}
|
||||||
|
alt={`Webcam ${s.fullName}`}
|
||||||
|
className="proctor-webcam-image"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="proctor-placeholder offline">
|
||||||
|
<span className="icon">📴</span>
|
||||||
|
<span>Ngoại tuyến (Offline)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid-proctor-card-footer" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<span className="assigned-paper" title={s.paperTitle || 'Chưa gán đề'}>
|
||||||
|
{s.paperTitle ? `Đề: ${s.paperTitle}` : 'Chưa gán đề'}
|
||||||
|
</span>
|
||||||
|
<div className="footer-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs text-primary"
|
||||||
|
onClick={(e) => handleOpenChat(s, e)}
|
||||||
|
title="Nhắn tin cho sinh viên"
|
||||||
|
>
|
||||||
|
💬 Nhắn tin
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
onClick={() => onSelectStudent(s)}
|
||||||
|
title="Xem chi tiết giám sát"
|
||||||
|
>
|
||||||
|
🔍 Giám sát
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filteredStudents.length === 0 && (
|
||||||
|
<div className="grid-proctor-empty" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<span>🔍</span>
|
||||||
|
<p>Không tìm thấy sinh viên nào.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ import { NavHistoryBar } from './NavHistoryBar';
|
|||||||
import { StudentAvatar } from './StudentAvatar';
|
import { StudentAvatar } from './StudentAvatar';
|
||||||
import { StudentDetailModal } from './StudentDetailModal';
|
import { StudentDetailModal } from './StudentDetailModal';
|
||||||
import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab';
|
import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab';
|
||||||
|
import { ExamGridProctor } from './ExamGridProctor';
|
||||||
|
|
||||||
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
|
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
const [onlineIds, setOnlineIds] = useState<number[]>([]);
|
const [onlineIds, setOnlineIds] = useState<number[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null);
|
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null);
|
||||||
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions'>('roster');
|
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions' | 'grid'>('roster');
|
||||||
const [configOpen, setConfigOpen] = useState(false);
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
|
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
|
||||||
const [papersModalOpen, setPapersModalOpen] = useState(false);
|
const [papersModalOpen, setPapersModalOpen] = useState(false);
|
||||||
@@ -858,6 +859,13 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
>
|
>
|
||||||
Sơ đồ sinh viên
|
Sơ đồ sinh viên
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tab-sub-btn ${activeSubTab === 'grid' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveSubTab('grid')}
|
||||||
|
>
|
||||||
|
Giám sát camera 🖥️
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`tab-sub-btn ${activeSubTab === 'detail' ? 'active' : ''}`}
|
className={`tab-sub-btn ${activeSubTab === 'detail' ? 'active' : ''}`}
|
||||||
@@ -876,7 +884,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
{activeSubTab !== 'submissions' && (
|
{activeSubTab !== 'submissions' && activeSubTab !== 'grid' && (
|
||||||
<div className="search-input-wrapper">
|
<div className="search-input-wrapper">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -969,6 +977,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
) : activeSubTab === 'grid' ? (
|
||||||
|
<ExamGridProctor
|
||||||
|
students={students}
|
||||||
|
onlineIds={onlineIds}
|
||||||
|
onSelectStudent={(s) => setSelectedStudent(toStudentItem(s))}
|
||||||
|
/>
|
||||||
) : activeSubTab === 'detail' ? (
|
) : activeSubTab === 'detail' ? (
|
||||||
<div className="session-logs-panel">
|
<div className="session-logs-panel">
|
||||||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
||||||
|
|||||||
@@ -5105,3 +5105,293 @@ input:checked + .slider:before {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Grid Proctoring (Net Cafe / Surveillance Cam Mode) ── */
|
||||||
|
.grid-proctor-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-card, #ffffff);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-container:fullscreen {
|
||||||
|
padding: 2rem;
|
||||||
|
background: #121214;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-container:fullscreen .grid-proctor-card {
|
||||||
|
background: #1e1e24;
|
||||||
|
border-color: #2e2e38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-container:fullscreen .grid-proctor-card-header {
|
||||||
|
border-color: #2e2e38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-container:fullscreen .student-name {
|
||||||
|
color: #e4e4eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-container:fullscreen .grid-proctor-card-footer {
|
||||||
|
border-color: #2e2e38;
|
||||||
|
color: #a0a0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cols-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
padding: 0.2rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cols-selector button {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cols-selector button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-layout {
|
||||||
|
/* Columns configured inline */
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-card.online {
|
||||||
|
border-color: rgba(13, 159, 110, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-card.online:hover {
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-card.offline {
|
||||||
|
opacity: 0.75;
|
||||||
|
filter: grayscale(0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-card.submitted {
|
||||||
|
border-left: 4px solid var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-info-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.online {
|
||||||
|
background: var(--success);
|
||||||
|
box-shadow: 0 0 0 2px rgba(13, 159, 110, 0.2);
|
||||||
|
animation: pulse-online 1.8s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.offline {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-card-body {
|
||||||
|
position: relative;
|
||||||
|
background: #141416;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proctor-frame-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proctor-screen-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proctor-webcam-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 28%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
border: 1.5px solid #ffffff;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
background: #000;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proctor-webcam-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proctor-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #8e8e9e;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proctor-placeholder.streaming {
|
||||||
|
background: #18181b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proctor-placeholder.offline {
|
||||||
|
background: #202023;
|
||||||
|
color: #71717a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proctor-placeholder .icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assigned-paper {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 50%;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 250px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-proctor-empty span {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ func (h *WsHub) Register(c *SocketClient) {
|
|||||||
if c.Role == "student" {
|
if c.Role == "student" {
|
||||||
h.students[c.StudentID] = c
|
h.students[c.StudentID] = c
|
||||||
log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID)
|
log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID)
|
||||||
|
if subs, exists := h.subscribers[c.StudentID]; exists && len(subs) > 0 {
|
||||||
|
_ = c.Conn.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
||||||
|
_ = c.Conn.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
||||||
|
log.Printf("[WS] Student %d has active subscribers. Sent start stream commands.", c.StudentID)
|
||||||
|
}
|
||||||
} else if c.Role == "teacher" {
|
} else if c.Role == "teacher" {
|
||||||
h.teachers[c.Addr] = c
|
h.teachers[c.Addr] = c
|
||||||
if c.StaffID > 0 {
|
if c.StaffID > 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user