core v1
This commit is contained in:
424
management/src/components/LearningTab.tsx
Normal file
424
management/src/components/LearningTab.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
apiFetchActiveSchedules,
|
||||
apiFetchClassSchedule,
|
||||
apiApplyScheduleTemplate,
|
||||
apiDeleteClassSchedule,
|
||||
apiFetchAllowedApps,
|
||||
apiSaveAllowedApps,
|
||||
apiFetchClassSessionLogs,
|
||||
apiFetchClasses
|
||||
} from '../api';
|
||||
import { DEFAULT_ALLOWED_APPS } from '../constants';
|
||||
import type {
|
||||
ClassItem,
|
||||
ClassScheduleItem,
|
||||
StudentSessionLogItem
|
||||
} from '../api';
|
||||
import { LiveProctorModal } from './LiveProctorModal';
|
||||
import { openClass } from './NavHistoryBar';
|
||||
import { ScheduleEditor } from './ScheduleEditor';
|
||||
|
||||
export const LearningTab: React.FC = () => {
|
||||
const [activeClasses, setActiveClasses] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
// Lớp đang cấu hình cấu trúc lịch/app (modal)
|
||||
const [configClass, setConfigClass] = useState<any | null>(null);
|
||||
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
|
||||
const [allowedApps, setAllowedApps] = useState<string>('');
|
||||
|
||||
// Lớp đang xem giám sát sinh viên
|
||||
const [monitorClass, setMonitorClass] = useState<any | null>(null);
|
||||
const [studentsLogs, setStudentsLogs] = useState<StudentSessionLogItem[]>([]);
|
||||
|
||||
// Trạng thái modal Thêm lớp vào lịch
|
||||
const [showAddClassModal, setShowAddClassModal] = useState<boolean>(false);
|
||||
const [allClasses, setAllClasses] = useState<ClassItem[]>([]);
|
||||
const [searchClassQuery, setSearchClassQuery] = useState<string>('');
|
||||
|
||||
// Trạng thái modal Live stream proctor
|
||||
const [proctorStudent, setProctorStudent] = useState<{ id: number; name: string; code: string } | null>(null);
|
||||
|
||||
const loadActiveClasses = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetchActiveSchedules();
|
||||
setActiveClasses(res.data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load active classes:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadActiveClasses();
|
||||
}, []);
|
||||
|
||||
const handleOpenAddClass = async () => {
|
||||
setShowAddClassModal(true);
|
||||
try {
|
||||
// Tải tối đa 1000 lớp trong hệ thống để người dùng thoải mái tìm kiếm
|
||||
const res = await apiFetchClasses({ page: 1, pageSize: 1000 });
|
||||
setAllClasses(res.data || []);
|
||||
} catch (err) {
|
||||
console.error('Error loading classes:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivateClass = async (cls: ClassItem) => {
|
||||
setShowAddClassModal(false);
|
||||
try {
|
||||
await apiApplyScheduleTemplate(cls.rkId);
|
||||
await apiSaveAllowedApps(cls.rkId, DEFAULT_ALLOWED_APPS);
|
||||
await loadActiveClasses();
|
||||
handleOpenConfig(cls);
|
||||
} catch (err) {
|
||||
alert('Không thể kích hoạt lớp: ' + err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenConfig = async (cls: any) => {
|
||||
setConfigClass(cls);
|
||||
try {
|
||||
const schedRes = await apiFetchClassSchedule(cls.rkId);
|
||||
setSchedules(schedRes.data || []);
|
||||
|
||||
const appRes = await apiFetchAllowedApps(cls.rkId);
|
||||
setAllowedApps(appRes.keywords || '');
|
||||
} catch (err) {
|
||||
console.error('Error fetching config:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMonitor = async (cls: any) => {
|
||||
setMonitorClass(cls);
|
||||
setLoading(true);
|
||||
try {
|
||||
const logRes = await apiFetchClassSessionLogs(cls.rkId);
|
||||
setStudentsLogs(logRes.data || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching logs:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reloadConfigSchedules = async () => {
|
||||
if (!configClass) return;
|
||||
try {
|
||||
const schedRes = await apiFetchClassSchedule(configClass.rkId);
|
||||
setSchedules(schedRes.data || []);
|
||||
loadActiveClasses();
|
||||
} catch (err) {
|
||||
console.error('Error reloading schedules:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveApps = async () => {
|
||||
try {
|
||||
await apiSaveAllowedApps(configClass.rkId, allowedApps);
|
||||
alert('Lưu cấu hình ứng dụng được phép thành công!');
|
||||
loadActiveClasses();
|
||||
} catch (err) {
|
||||
alert('Không thể lưu cấu hình app: ' + err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClass = async (cls: any) => {
|
||||
if (window.confirm(`Bạn có chắc chắn muốn hủy cấu hình và dừng giám sát lớp ${cls.name}?`)) {
|
||||
try {
|
||||
await apiDeleteClassSchedule(cls.rkId);
|
||||
loadActiveClasses();
|
||||
if (monitorClass?.rkId === cls.rkId) setMonitorClass(null);
|
||||
if (configClass?.rkId === cls.rkId) setConfigClass(null);
|
||||
} catch (err) {
|
||||
alert('Không thể hủy kích hoạt: ' + err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatSeconds = (totalSeconds: number) => {
|
||||
if (!totalSeconds) return '0s';
|
||||
const hrs = Math.floor(totalSeconds / 3600);
|
||||
const mins = Math.floor((totalSeconds % 3600) / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
const parts = [];
|
||||
if (hrs > 0) parts.push(`${hrs}h`);
|
||||
if (mins > 0) parts.push(`${mins}m`);
|
||||
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const getDayString = (day: number) => {
|
||||
const days = ['Thứ 2', 'Thứ 3', 'Thứ 4', 'Thứ 5', 'Thứ 6', 'Thứ 7', 'Chủ Nhật'];
|
||||
return days[day] || `Thứ ${day + 2}`;
|
||||
};
|
||||
|
||||
const filteredAddClasses = allClasses.filter(c =>
|
||||
(c.name.toLowerCase().includes(searchClassQuery.toLowerCase()) ||
|
||||
c.classCode.toLowerCase().includes(searchClassQuery.toLowerCase())) &&
|
||||
!activeClasses.some(ac => ac.rkId === c.rkId)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="tab-page">
|
||||
<div className="tab-page-toolbar">
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>Quản lý lịch học & Giám sát</h1>
|
||||
<p>Chọn các lớp học từ hệ thống chính, tạo lịch học theo tuần và giám sát sinh viên</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleOpenAddClass}>
|
||||
Chọn lớp & Tạo lịch học
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-page-body tab-page-scroll">
|
||||
<div className="content-card">
|
||||
<h2 style={{ margin: '0 0 1rem 0', fontSize: '1rem', fontWeight: 700 }}>Lớp đang hoạt động tuần này</h2>
|
||||
{loading && <div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>🔄 Đang tải...</div>}
|
||||
|
||||
{!loading && activeClasses.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '3rem 0', color: 'var(--text-muted)' }}>
|
||||
<span style={{ fontSize: '2.5rem' }}>📅</span>
|
||||
<p style={{ marginTop: '10px' }}>Chưa có lớp nào được tạo lịch học.</p>
|
||||
<button className="btn btn-secondary" onClick={handleOpenAddClass} style={{ marginTop: '10px' }}>
|
||||
Bắt đầu thêm lớp đầu tiên
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
!loading && (
|
||||
<div className="table-responsive">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mã Lớp / Tên Lớp</th>
|
||||
<th>Lịch Học Trong Tuần</th>
|
||||
<th>App Được Phép</th>
|
||||
<th style={{ textAlign: 'right' }}>Thao Tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeClasses.map((cls) => (
|
||||
<tr key={cls.rkId}>
|
||||
<td style={{ maxWidth: '300px' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '0.95rem', color: 'var(--primary-color)' }}>{cls.classCode}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{cls.name}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{cls.schedules && cls.schedules.length > 0 ? (
|
||||
cls.schedules.map((s: any, idx: number) => (
|
||||
<span key={idx} className="course-tag">
|
||||
{getDayString(s.dayOfWeek)} Ca{s.period || 1}: {s.startTime}-{s.endTime}
|
||||
{s.courseName ? ` · ${s.courseName}` : ''}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Chưa có lịch</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', background: 'rgba(255,255,255,0.03)', padding: '4px 8px', borderRadius: '6px', fontFamily: 'monospace', display: 'inline-block', maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={cls.allowedApps}>
|
||||
{cls.allowedApps || 'Mặc định'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ padding: '0.45rem 0.85rem', fontSize: '0.8rem', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
|
||||
onClick={() => openClass('learning', cls.rkId, cls.name)}
|
||||
>
|
||||
💻 Vào Workspace
|
||||
</button>
|
||||
<button className="btn btn-sm" style={{ background: 'rgba(198,40,40,0.1)', color: '#ff8a80', padding: '0.45rem 0.85rem', fontSize: '0.8rem' }} onClick={() => handleDeleteClass(cls)}>
|
||||
🗑️ Hủy
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MODAL CẤU HÌNH LỊCH HỌC & APP */}
|
||||
{configClass && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-container" style={{ maxWidth: '800px', width: '90%' }}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title">⚙️ Cấu Hình Lịch Học & Ứng Dụng</h2>
|
||||
<p style={{ margin: '4px 0 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>{configClass.name} ({configClass.classCode})</p>
|
||||
</div>
|
||||
<button className="btn btn-secondary close-btn" onClick={() => setConfigClass(null)}>✖ Đóng</button>
|
||||
</div>
|
||||
<div style={{ padding: '1rem 0', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
||||
|
||||
{/* Cột trái: Quản lý Ca học */}
|
||||
<div style={{ borderRight: '1px solid var(--border-color)', paddingRight: '1.5rem' }}>
|
||||
<h4 style={{ margin: '0 0 1rem 0' }}>📅 Ca Học Trong Tuần</h4>
|
||||
<ScheduleEditor
|
||||
classId={configClass.rkId}
|
||||
schedules={schedules}
|
||||
onSaved={reloadConfigSchedules}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cột phải: Quản lý App Whitelist */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 1rem 0' }}>🚫 App Được Phép Chạy</h4>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', lineHeight: '1.4', marginBottom: '8px' }}>
|
||||
Từ khóa ngăn chặn các ứng dụng ngoài luồng. Các ứng dụng có giao diện chứa từ khóa trong danh sách này mới được chạy trên máy sinh viên.
|
||||
</p>
|
||||
<textarea
|
||||
rows={6}
|
||||
value={allowedApps}
|
||||
onChange={e => setAllowedApps(e.target.value)}
|
||||
placeholder="chrome,idea64,vscode,wails,simple_care"
|
||||
style={{ width: '100%', padding: '10px', background: '#25262b', color: '#fff', border: '1px solid var(--border-color)', borderRadius: '6px', fontFamily: 'monospace', fontSize: '0.85rem', boxSizing: 'border-box' }}
|
||||
/>
|
||||
<small style={{ color: 'var(--text-muted)', display: 'block', marginTop: '4px' }}>Ngăn cách bằng dấu phẩy. Mặc định: <code>chrome,idea64,vscode,wails,simple_care</code></small>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleSaveApps} style={{ width: '100%', marginTop: '15px' }}>
|
||||
💾 Lưu Whitelist App
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL GIÁM SÁT CHI TIẾT SINH VIÊN */}
|
||||
{monitorClass && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-container" style={{ maxWidth: '950px', width: '95%' }}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title">📊 Nhật Ký & Giám Sát Sinh Viên</h2>
|
||||
<p style={{ margin: '4px 0 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>Lớp: <strong>{monitorClass.name}</strong> ({monitorClass.classCode})</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-secondary" onClick={() => handleOpenMonitor(monitorClass)}>🔄 Tải lại</button>
|
||||
<button className="btn btn-secondary close-btn" onClick={() => setMonitorClass(null)}>✖ Đóng</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '1rem 0', maxHeight: '450px', overflowY: 'auto' }}>
|
||||
<div className="table-responsive">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Học Viên</th>
|
||||
<th>Mã SV</th>
|
||||
<th>Thời gian Online</th>
|
||||
<th>Thời gian Offline</th>
|
||||
<th>Lịch sử Wifi</th>
|
||||
<th>Cập nhật cuối</th>
|
||||
<th style={{ textAlign: 'right' }}>Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{studentsLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>Chưa có dữ liệu sinh viên điểm danh hôm nay.</td>
|
||||
</tr>
|
||||
) : (
|
||||
studentsLogs.map((log) => (
|
||||
<tr key={log.studentRkId}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 700 }}>{log.fullName}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{log.email}</div>
|
||||
</td>
|
||||
<td><code>{log.studentCode}</code></td>
|
||||
<td style={{ color: '#4caf50', fontWeight: 700 }}>{formatSeconds(log.onlineSeconds)}</td>
|
||||
<td style={{ color: '#f44336', fontWeight: 700 }}>{formatSeconds(log.offlineSeconds)}</td>
|
||||
<td>
|
||||
<span style={{ fontSize: '0.8rem', background: 'rgba(255,255,255,0.05)', padding: '3px 8px', borderRadius: '4px', border: '1px solid var(--border-color)' }}>
|
||||
{log.wifiSsids || 'Chưa nhận'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{log.lastActiveAt ? new Date(log.lastActiveAt).toLocaleTimeString() : 'Chưa hoạt động'}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setProctorStudent({ id: log.studentRkId, name: log.fullName, code: log.studentCode })}
|
||||
>
|
||||
🖥️ Xem Camera & Screen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL THÊM LỚP VÀO LỊCH GIÁM SÁT */}
|
||||
{showAddClassModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-container" style={{ maxWidth: '500px' }}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">🏫 Chọn Lớp Học Từ Hệ Thống</h2>
|
||||
<button className="btn btn-secondary close-btn" onClick={() => setShowAddClassModal(false)}>✖</button>
|
||||
</div>
|
||||
<div style={{ padding: '1rem 0' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nhập tên lớp hoặc mã lớp cần tìm..."
|
||||
value={searchClassQuery}
|
||||
onChange={e => setSearchClassQuery(e.target.value)}
|
||||
style={{ width: '100%', padding: '10px', background: '#25262b', color: '#fff', border: '1px solid var(--border-color)', borderRadius: '6px', marginBottom: '1rem', boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{filteredAddClasses.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Không tìm thấy lớp học phù hợp nào.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{filteredAddClasses.map(c => (
|
||||
<li key={c.rkId} style={{ padding: '10px', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ maxWidth: '350px' }}>
|
||||
<div style={{ fontWeight: 700, color: 'var(--primary-color)' }}>{c.classCode}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{c.name}</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)}>
|
||||
Chọn
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL XEM STREAM CAMERA / SCREEN TRỰC TIẾP */}
|
||||
{proctorStudent && (
|
||||
<LiveProctorModal
|
||||
studentId={proctorStudent.id}
|
||||
studentName={proctorStudent.name}
|
||||
studentCode={proctorStudent.code}
|
||||
onClose={() => setProctorStudent(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user