This commit is contained in:
2026-06-30 09:31:33 +07:00
parent c736326162
commit bbf8336664
77 changed files with 12601 additions and 556 deletions

View 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ị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 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 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> 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 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 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 & 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> 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 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>
);
};