fix tam
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
apiFetchActiveSchedules,
|
apiFetchActiveSchedules,
|
||||||
apiFetchClassSchedule,
|
apiFetchClassSchedule,
|
||||||
@@ -18,26 +18,37 @@ import type {
|
|||||||
import { LiveProctorModal } from './LiveProctorModal';
|
import { LiveProctorModal } from './LiveProctorModal';
|
||||||
import { openClass } from './NavHistoryBar';
|
import { openClass } from './NavHistoryBar';
|
||||||
import { ScheduleEditor } from './ScheduleEditor';
|
import { ScheduleEditor } from './ScheduleEditor';
|
||||||
|
import { LearningTimetable } from './LearningTimetable';
|
||||||
|
import {
|
||||||
|
WEEK_DAYS,
|
||||||
|
filterActiveClasses,
|
||||||
|
countWeeklyShifts,
|
||||||
|
classHasToday,
|
||||||
|
schedulesByDay,
|
||||||
|
getDayLabel,
|
||||||
|
type ActiveClassItem,
|
||||||
|
} from './learningUtils';
|
||||||
|
|
||||||
|
type ViewMode = 'list' | 'timetable';
|
||||||
|
|
||||||
export const LearningTab: React.FC = () => {
|
export const LearningTab: React.FC = () => {
|
||||||
const [activeClasses, setActiveClasses] = useState<any[]>([]);
|
const [activeClasses, setActiveClasses] = useState<ActiveClassItem[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [dayFilter, setDayFilter] = useState<number | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
|
|
||||||
// Lớp đang cấu hình cấu trúc lịch/app (modal)
|
const [configClass, setConfigClass] = useState<ActiveClassItem | null>(null);
|
||||||
const [configClass, setConfigClass] = useState<any | null>(null);
|
|
||||||
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
|
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
|
||||||
const [allowedApps, setAllowedApps] = useState<string>('');
|
const [allowedApps, setAllowedApps] = useState<string>('');
|
||||||
|
|
||||||
// Lớp đang xem giám sát sinh viên
|
const [monitorClass, setMonitorClass] = useState<ActiveClassItem | null>(null);
|
||||||
const [monitorClass, setMonitorClass] = useState<any | null>(null);
|
|
||||||
const [studentsLogs, setStudentsLogs] = useState<StudentSessionLogItem[]>([]);
|
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 [showAddClassModal, setShowAddClassModal] = useState<boolean>(false);
|
||||||
const [allClasses, setAllClasses] = useState<ClassItem[]>([]);
|
const [allClasses, setAllClasses] = useState<ClassItem[]>([]);
|
||||||
const [searchClassQuery, setSearchClassQuery] = useState<string>('');
|
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 [proctorStudent, setProctorStudent] = useState<{ id: number; name: string; code: string } | null>(null);
|
||||||
|
|
||||||
const loadActiveClasses = async () => {
|
const loadActiveClasses = async () => {
|
||||||
@@ -56,10 +67,20 @@ export const LearningTab: React.FC = () => {
|
|||||||
loadActiveClasses();
|
loadActiveClasses();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const filteredClasses = useMemo(
|
||||||
|
() => filterActiveClasses(activeClasses, searchQuery, dayFilter),
|
||||||
|
[activeClasses, searchQuery, dayFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const weeklyShifts = useMemo(() => countWeeklyShifts(activeClasses), [activeClasses]);
|
||||||
|
const todayClassCount = useMemo(
|
||||||
|
() => activeClasses.filter(classHasToday).length,
|
||||||
|
[activeClasses]
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenAddClass = async () => {
|
const handleOpenAddClass = async () => {
|
||||||
setShowAddClassModal(true);
|
setShowAddClassModal(true);
|
||||||
try {
|
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 });
|
const res = await apiFetchClasses({ page: 1, pageSize: 1000 });
|
||||||
setAllClasses(res.data || []);
|
setAllClasses(res.data || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -73,18 +94,17 @@ export const LearningTab: React.FC = () => {
|
|||||||
await apiApplyScheduleTemplate(cls.rkId);
|
await apiApplyScheduleTemplate(cls.rkId);
|
||||||
await apiSaveAllowedApps(cls.rkId, DEFAULT_ALLOWED_APPS);
|
await apiSaveAllowedApps(cls.rkId, DEFAULT_ALLOWED_APPS);
|
||||||
await loadActiveClasses();
|
await loadActiveClasses();
|
||||||
handleOpenConfig(cls);
|
handleOpenConfig(cls as ActiveClassItem);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Không thể kích hoạt lớp: ' + err);
|
alert('Không thể kích hoạt lớp: ' + err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenConfig = async (cls: any) => {
|
const handleOpenConfig = async (cls: ActiveClassItem) => {
|
||||||
setConfigClass(cls);
|
setConfigClass(cls);
|
||||||
try {
|
try {
|
||||||
const schedRes = await apiFetchClassSchedule(cls.rkId);
|
const schedRes = await apiFetchClassSchedule(cls.rkId);
|
||||||
setSchedules(schedRes.data || []);
|
setSchedules(schedRes.data || []);
|
||||||
|
|
||||||
const appRes = await apiFetchAllowedApps(cls.rkId);
|
const appRes = await apiFetchAllowedApps(cls.rkId);
|
||||||
setAllowedApps(appRes.keywords || '');
|
setAllowedApps(appRes.keywords || '');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -92,7 +112,7 @@ export const LearningTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenMonitor = async (cls: any) => {
|
const handleOpenMonitor = async (cls: ActiveClassItem) => {
|
||||||
setMonitorClass(cls);
|
setMonitorClass(cls);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -117,6 +137,7 @@ export const LearningTab: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveApps = async () => {
|
const handleSaveApps = async () => {
|
||||||
|
if (!configClass) return;
|
||||||
try {
|
try {
|
||||||
await apiSaveAllowedApps(configClass.rkId, allowedApps);
|
await apiSaveAllowedApps(configClass.rkId, allowedApps);
|
||||||
alert('Lưu cấu hình ứng dụng được phép thành công!');
|
alert('Lưu cấu hình ứng dụng được phép thành công!');
|
||||||
@@ -126,7 +147,7 @@ export const LearningTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClass = async (cls: any) => {
|
const handleDeleteClass = async (cls: ActiveClassItem) => {
|
||||||
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}?`)) {
|
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 {
|
try {
|
||||||
await apiDeleteClassSchedule(cls.rkId);
|
await apiDeleteClassSchedule(cls.rkId);
|
||||||
@@ -139,6 +160,10 @@ export const LearningTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenWorkspace = (cls: ActiveClassItem) => {
|
||||||
|
openClass('learning', cls.rkId, cls.name);
|
||||||
|
};
|
||||||
|
|
||||||
const formatSeconds = (totalSeconds: number) => {
|
const formatSeconds = (totalSeconds: number) => {
|
||||||
if (!totalSeconds) return '0s';
|
if (!totalSeconds) return '0s';
|
||||||
const hrs = Math.floor(totalSeconds / 3600);
|
const hrs = Math.floor(totalSeconds / 3600);
|
||||||
@@ -151,11 +176,6 @@ export const LearningTab: React.FC = () => {
|
|||||||
return parts.join(' ');
|
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 =>
|
const filteredAddClasses = allClasses.filter(c =>
|
||||||
(c.name.toLowerCase().includes(searchClassQuery.toLowerCase()) ||
|
(c.name.toLowerCase().includes(searchClassQuery.toLowerCase()) ||
|
||||||
c.classCode.toLowerCase().includes(searchClassQuery.toLowerCase())) &&
|
c.classCode.toLowerCase().includes(searchClassQuery.toLowerCase())) &&
|
||||||
@@ -168,152 +188,259 @@ export const LearningTab: React.FC = () => {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div className="page-title">
|
<div className="page-title">
|
||||||
<h1>Quản lý lịch học & Giám sát</h1>
|
<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>
|
<p>Chọn lớp, lập lịch theo tuần và giám sát sinh viên — xem dạng danh sách hoặc thời khóa biểu</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary" onClick={handleOpenAddClass}>
|
<button className="btn btn-primary" onClick={handleOpenAddClass}>
|
||||||
Chọn lớp & Tạo lịch học
|
+ Chọn lớp & Tạo lịch
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tab-page-body tab-page-scroll">
|
<div className="tab-page-body tab-page-scroll">
|
||||||
<div className="content-card">
|
<div className="learning-stats-row">
|
||||||
<h2 style={{ margin: '0 0 1rem 0', fontSize: '1rem', fontWeight: 700 }}>Lớp đang hoạt động tuần này</h2>
|
<div className="learning-stat">
|
||||||
{loading && <div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>🔄 Đang tải...</div>}
|
<span className="learning-stat-value">{activeClasses.length}</span>
|
||||||
|
<span className="learning-stat-label">Lớp đang giám sát</span>
|
||||||
|
</div>
|
||||||
|
<div className="learning-stat">
|
||||||
|
<span className="learning-stat-value">{weeklyShifts}</span>
|
||||||
|
<span className="learning-stat-label">Ca học / tuần</span>
|
||||||
|
</div>
|
||||||
|
<div className="learning-stat learning-stat--accent">
|
||||||
|
<span className="learning-stat-value">{todayClassCount}</span>
|
||||||
|
<span className="learning-stat-label">Lớp có ca hôm nay</span>
|
||||||
|
</div>
|
||||||
|
<div className="learning-stat">
|
||||||
|
<span className="learning-stat-value">{filteredClasses.length}</span>
|
||||||
|
<span className="learning-stat-label">Kết quả lọc</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!loading && activeClasses.length === 0 ? (
|
<div className="content-card learning-toolbar-card">
|
||||||
<div style={{ textAlign: 'center', padding: '3rem 0', color: 'var(--text-muted)' }}>
|
<div className="learning-toolbar">
|
||||||
<span style={{ fontSize: '2.5rem' }}>📅</span>
|
<input
|
||||||
<p style={{ marginTop: '10px' }}>Chưa có lớp nào được tạo lịch học.</p>
|
type="search"
|
||||||
<button className="btn btn-secondary" onClick={handleOpenAddClass} style={{ marginTop: '10px' }}>
|
className="app-pool-search learning-search"
|
||||||
|
placeholder="Tìm mã lớp, tên lớp, môn học, chuyên ngành..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="learning-view-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`learning-view-btn${viewMode === 'list' ? ' learning-view-btn--active' : ''}`}
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
>
|
||||||
|
Danh sách
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`learning-view-btn${viewMode === 'timetable' ? ' learning-view-btn--active' : ''}`}
|
||||||
|
onClick={() => setViewMode('timetable')}
|
||||||
|
>
|
||||||
|
Thời khóa biểu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="learning-day-filters">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`learning-day-chip${dayFilter === null ? ' learning-day-chip--active' : ''}`}
|
||||||
|
onClick={() => setDayFilter(null)}
|
||||||
|
>
|
||||||
|
Tất cả
|
||||||
|
</button>
|
||||||
|
{WEEK_DAYS.map((label, day) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
className={`learning-day-chip${dayFilter === day ? ' learning-day-chip--active' : ''}`}
|
||||||
|
onClick={() => setDayFilter(dayFilter === day ? null : day)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-card">
|
||||||
|
{loading && (
|
||||||
|
<div className="learning-empty">
|
||||||
|
<span className="learning-empty-icon">🔄</span>
|
||||||
|
<p>Đang tải...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && activeClasses.length === 0 && (
|
||||||
|
<div className="learning-empty">
|
||||||
|
<span className="learning-empty-icon">📅</span>
|
||||||
|
<p>Chưa có lớp nào được tạo lịch học.</p>
|
||||||
|
<button className="btn btn-secondary" onClick={handleOpenAddClass}>
|
||||||
Bắt đầu thêm lớp đầu tiên
|
Bắt đầu thêm lớp đầu tiên
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
!loading && (
|
|
||||||
<div className="table-responsive">
|
{!loading && activeClasses.length > 0 && filteredClasses.length === 0 && (
|
||||||
<table className="table">
|
<div className="learning-empty">
|
||||||
<thead>
|
<span className="learning-empty-icon">🔍</span>
|
||||||
<tr>
|
<p>Không tìm thấy lớp phù hợp với bộ lọc hiện tại.</p>
|
||||||
<th>Mã Lớp / Tên Lớp</th>
|
<button
|
||||||
<th>Lịch Học Trong Tuần</th>
|
className="btn btn-secondary"
|
||||||
<th>App Được Phép</th>
|
onClick={() => {
|
||||||
<th style={{ textAlign: 'right' }}>Thao Tác</th>
|
setSearchQuery('');
|
||||||
</tr>
|
setDayFilter(null);
|
||||||
</thead>
|
}}
|
||||||
<tbody>
|
>
|
||||||
{activeClasses.map((cls) => (
|
Xóa bộ lọc
|
||||||
<tr key={cls.rkId}>
|
</button>
|
||||||
<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>
|
</div>
|
||||||
</td>
|
)}
|
||||||
<td>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
{!loading && filteredClasses.length > 0 && viewMode === 'timetable' && (
|
||||||
{cls.schedules && cls.schedules.length > 0 ? (
|
<LearningTimetable classes={filteredClasses} onOpenClass={handleOpenWorkspace} />
|
||||||
cls.schedules.map((s: any, idx: number) => (
|
)}
|
||||||
<span key={idx} className="course-tag">
|
|
||||||
{getDayString(s.dayOfWeek)} Ca{s.period || 1}: {s.startTime}-{s.endTime}
|
{!loading && filteredClasses.length > 0 && viewMode === 'list' && (
|
||||||
{s.courseName ? ` · ${s.courseName}` : ''}
|
<div className="learning-class-grid">
|
||||||
</span>
|
{filteredClasses.map(cls => {
|
||||||
))
|
const byDay = schedulesByDay(cls);
|
||||||
) : (
|
const today = classHasToday(cls);
|
||||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Chưa có lịch</span>
|
return (
|
||||||
|
<article key={cls.rkId} className={`learning-class-card${today ? ' learning-class-card--today' : ''}`}>
|
||||||
|
<header className="learning-class-card-head">
|
||||||
|
<div>
|
||||||
|
<div className="learning-class-code">{cls.classCode}</div>
|
||||||
|
<h3 className="learning-class-name" title={cls.name}>{cls.name}</h3>
|
||||||
|
{(cls.specializeName || cls.systemName) && (
|
||||||
|
<p className="learning-class-meta">
|
||||||
|
{[cls.specializeName, cls.systemName].filter(Boolean).join(' · ')}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div className="learning-class-badges">
|
||||||
<td>
|
{today && <span className="learning-badge learning-badge--today">Hôm nay</span>}
|
||||||
<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.studentCount != null && cls.studentCount > 0 && (
|
||||||
{cls.allowedApps || 'Mặc định'}
|
<span className="learning-badge">{cls.studentCount} SV</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="learning-class-schedule">
|
||||||
|
{byDay.size === 0 ? (
|
||||||
|
<p className="learning-class-no-sched">Chưa cấu hình ca học</p>
|
||||||
|
) : (
|
||||||
|
Array.from(byDay.entries()).map(([day, shifts]) => (
|
||||||
|
<div key={day} className="learning-schedule-day">
|
||||||
|
<span className="learning-schedule-day-label">{getDayLabel(day)}</span>
|
||||||
|
<div className="learning-schedule-shifts">
|
||||||
|
{shifts.map((s, idx) => (
|
||||||
|
<span key={idx} className="course-tag">
|
||||||
|
Ca{s.period || 1} {s.startTime && s.endTime ? `${s.startTime}–${s.endTime}` : ''}
|
||||||
|
{s.courseName ? ` · ${s.courseName}` : ''}
|
||||||
</span>
|
</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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cls.allowedApps && (
|
||||||
|
<p className="learning-class-apps" title={cls.allowedApps}>
|
||||||
|
App: <code>{cls.allowedApps}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="learning-class-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => handleOpenWorkspace(cls)}
|
||||||
|
>
|
||||||
|
Vào Workspace
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleOpenConfig(cls)}
|
||||||
|
>
|
||||||
|
Cấu hình
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||||
|
onClick={() => handleDeleteClass(cls)}
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MODAL CẤU HÌNH LỊCH HỌC & APP */}
|
|
||||||
{configClass && (
|
{configClass && (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal-container" style={{ maxWidth: '800px', width: '90%' }}>
|
<div className="modal-container" style={{ maxWidth: '800px', width: '90%' }}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="modal-title">⚙️ Cấu Hình Lịch Học & Ứng Dụng</h2>
|
<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>
|
<p style={{ margin: '4px 0 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||||
|
{configClass.name} ({configClass.classCode})
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-secondary close-btn" onClick={() => setConfigClass(null)}>✖ Đóng</button>
|
<button className="btn btn-secondary close-btn" onClick={() => setConfigClass(null)}>Đóng</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '1rem 0', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
<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' }}>
|
<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>
|
<h4 style={{ margin: '0 0 1rem 0' }}>Ca học trong tuần</h4>
|
||||||
<ScheduleEditor
|
<ScheduleEditor
|
||||||
classId={configClass.rkId}
|
classId={configClass.rkId}
|
||||||
schedules={schedules}
|
schedules={schedules}
|
||||||
onSaved={reloadConfigSchedules}
|
onSaved={reloadConfigSchedules}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cột phải: Quản lý App Whitelist */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||||
<div>
|
<div>
|
||||||
<h4 style={{ margin: '0 0 1rem 0' }}>🚫 App Được Phép Chạy</h4>
|
<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' }}>
|
<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.
|
Từ khóa ngăn chặn các ứng dụng ngoài luồng trên máy sinh viên.
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
rows={6}
|
rows={6}
|
||||||
value={allowedApps}
|
value={allowedApps}
|
||||||
onChange={e => setAllowedApps(e.target.value)}
|
onChange={e => setAllowedApps(e.target.value)}
|
||||||
placeholder="chrome,idea64,vscode,wails,simple_care"
|
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' }}
|
className="app-textarea"
|
||||||
/>
|
/>
|
||||||
<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>
|
</div>
|
||||||
<button className="btn btn-primary" onClick={handleSaveApps} style={{ width: '100%', marginTop: '15px' }}>
|
<button className="btn btn-primary" onClick={handleSaveApps} style={{ width: '100%', marginTop: '15px' }}>
|
||||||
💾 Lưu Whitelist App
|
Lưu whitelist app
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* MODAL GIÁM SÁT CHI TIẾT SINH VIÊN */}
|
|
||||||
{monitorClass && (
|
{monitorClass && (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal-container" style={{ maxWidth: '950px', width: '95%' }}>
|
<div className="modal-container" style={{ maxWidth: '950px', width: '95%' }}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="modal-title">📊 Nhật Ký & Giám Sát Sinh Viên</h2>
|
<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>
|
<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>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<button className="btn btn-secondary" onClick={() => handleOpenMonitor(monitorClass)}>🔄 Tải lại</button>
|
<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>
|
<button className="btn btn-secondary close-btn" onClick={() => setMonitorClass(null)}>Đóng</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '1rem 0', maxHeight: '450px', overflowY: 'auto' }}>
|
<div style={{ padding: '1rem 0', maxHeight: '450px', overflowY: 'auto' }}>
|
||||||
@@ -321,22 +448,24 @@ export const LearningTab: React.FC = () => {
|
|||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Học Viên</th>
|
<th>Học viên</th>
|
||||||
<th>Mã SV</th>
|
<th>Mã SV</th>
|
||||||
<th>Thời gian Online</th>
|
<th>Online</th>
|
||||||
<th>Thời gian Offline</th>
|
<th>Offline</th>
|
||||||
<th>Lịch sử Wifi</th>
|
<th>WiFi</th>
|
||||||
<th>Cập nhật cuối</th>
|
<th>Cập nhật</th>
|
||||||
<th style={{ textAlign: 'right' }}>Thao tác</th>
|
<th style={{ textAlign: 'right' }}>Thao tác</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{studentsLogs.length === 0 ? (
|
{studentsLogs.length === 0 ? (
|
||||||
<tr>
|
<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>
|
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
|
Chưa có dữ liệu sinh viên hôm nay.
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
studentsLogs.map((log) => (
|
studentsLogs.map(log => (
|
||||||
<tr key={log.studentRkId}>
|
<tr key={log.studentRkId}>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ fontWeight: 700 }}>{log.fullName}</div>
|
<div style={{ fontWeight: 700 }}>{log.fullName}</div>
|
||||||
@@ -346,17 +475,17 @@ export const LearningTab: React.FC = () => {
|
|||||||
<td style={{ color: '#4caf50', fontWeight: 700 }}>{formatSeconds(log.onlineSeconds)}</td>
|
<td style={{ color: '#4caf50', fontWeight: 700 }}>{formatSeconds(log.onlineSeconds)}</td>
|
||||||
<td style={{ color: '#f44336', fontWeight: 700 }}>{formatSeconds(log.offlineSeconds)}</td>
|
<td style={{ color: '#f44336', fontWeight: 700 }}>{formatSeconds(log.offlineSeconds)}</td>
|
||||||
<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)' }}>
|
<span className="course-tag">{log.wifiSsids || 'Chưa nhận'}</span>
|
||||||
{log.wifiSsids || 'Chưa nhận'}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>{log.lastActiveAt ? new Date(log.lastActiveAt).toLocaleTimeString() : 'Chưa hoạt động'}</td>
|
<td>{log.lastActiveAt ? new Date(log.lastActiveAt).toLocaleTimeString('vi-VN') : '—'}</td>
|
||||||
<td style={{ textAlign: 'right' }}>
|
<td style={{ textAlign: 'right' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={() => setProctorStudent({ id: log.studentRkId, name: log.fullName, code: log.studentCode })}
|
onClick={() =>
|
||||||
|
setProctorStudent({ id: log.studentRkId, name: log.fullName, code: log.studentCode })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
🖥️ Xem Camera & Screen
|
Xem màn hình
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -370,32 +499,31 @@ export const LearningTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* MODAL THÊM LỚP VÀO LỊCH GIÁM SÁT */}
|
|
||||||
{showAddClassModal && (
|
{showAddClassModal && (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal-container" style={{ maxWidth: '500px' }}>
|
<div className="modal-container" style={{ maxWidth: '500px' }}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2 className="modal-title">🏫 Chọn Lớp Học Từ Hệ Thống</h2>
|
<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>
|
<button className="btn btn-secondary close-btn" onClick={() => setShowAddClassModal(false)}>Đóng</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '1rem 0' }}>
|
<div style={{ padding: '1rem 0' }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="search"
|
||||||
placeholder="Nhập tên lớp hoặc mã lớp cần tìm..."
|
className="app-pool-search"
|
||||||
|
placeholder="Tìm tên lớp hoặc mã lớp..."
|
||||||
value={searchClassQuery}
|
value={searchClassQuery}
|
||||||
onChange={e => setSearchClassQuery(e.target.value)}
|
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' }}>
|
<div style={{ maxHeight: '300px', overflowY: 'auto', marginTop: '1rem' }}>
|
||||||
{filteredAddClasses.length === 0 ? (
|
{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>
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Không tìm thấy lớp phù hợp.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
<ul className="learning-add-list">
|
||||||
{filteredAddClasses.map(c => (
|
{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' }}>
|
<li key={c.rkId} className="learning-add-item">
|
||||||
<div style={{ maxWidth: '350px' }}>
|
<div>
|
||||||
<div style={{ fontWeight: 700, color: 'var(--primary-color)' }}>{c.classCode}</div>
|
<div className="learning-class-code">{c.classCode}</div>
|
||||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{c.name}</div>
|
<div className="learning-class-meta">{c.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)}>
|
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)}>
|
||||||
Chọn
|
Chọn
|
||||||
@@ -410,7 +538,6 @@ export const LearningTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* MODAL XEM STREAM CAMERA / SCREEN TRỰC TIẾP */}
|
|
||||||
{proctorStudent && (
|
{proctorStudent && (
|
||||||
<LiveProctorModal
|
<LiveProctorModal
|
||||||
studentId={proctorStudent.id}
|
studentId={proctorStudent.id}
|
||||||
|
|||||||
94
management/src/components/LearningTimetable.tsx
Normal file
94
management/src/components/LearningTimetable.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
WEEK_DAYS,
|
||||||
|
PERIODS,
|
||||||
|
getTodayDayOfWeek,
|
||||||
|
classAccentColor,
|
||||||
|
buildTimetableMap,
|
||||||
|
type ActiveClassItem,
|
||||||
|
} from './learningUtils';
|
||||||
|
|
||||||
|
interface LearningTimetableProps {
|
||||||
|
classes: ActiveClassItem[];
|
||||||
|
onOpenClass: (cls: ActiveClassItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LearningTimetable: React.FC<LearningTimetableProps> = ({ classes, onOpenClass }) => {
|
||||||
|
const today = getTodayDayOfWeek();
|
||||||
|
const cellMap = buildTimetableMap(classes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="learning-tkb-wrap">
|
||||||
|
<div className="learning-tkb-scroll">
|
||||||
|
<table className="learning-tkb">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="learning-tkb-corner">Ca</th>
|
||||||
|
{WEEK_DAYS.map((label, day) => (
|
||||||
|
<th
|
||||||
|
key={label}
|
||||||
|
className={`learning-tkb-dayhead${day === today ? ' learning-tkb-dayhead--today' : ''}`}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{day === today && <em>Hôm nay</em>}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{PERIODS.map(period => (
|
||||||
|
<tr key={period}>
|
||||||
|
<td className="learning-tkb-period">
|
||||||
|
<span className="learning-tkb-period-num">Ca {period}</span>
|
||||||
|
</td>
|
||||||
|
{WEEK_DAYS.map((_, day) => {
|
||||||
|
const entries = cellMap.get(`${day}-${period}`) || [];
|
||||||
|
const isToday = day === today;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={`${day}-${period}`}
|
||||||
|
className={`learning-tkb-cell${isToday ? ' learning-tkb-cell--today' : ''}${
|
||||||
|
entries.length ? ' learning-tkb-cell--filled' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<span className="learning-tkb-empty">—</span>
|
||||||
|
) : (
|
||||||
|
<div className="learning-tkb-blocks">
|
||||||
|
{entries.map(({ cls, schedule }) => {
|
||||||
|
const accent = classAccentColor(cls.classCode);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${cls.rkId}-${schedule.id ?? schedule.dayOfWeek}`}
|
||||||
|
type="button"
|
||||||
|
className={`learning-tkb-block${schedule.isActive === false ? ' learning-tkb-block--inactive' : ''}`}
|
||||||
|
style={{ '--tkb-accent': accent } as React.CSSProperties}
|
||||||
|
onClick={() => onOpenClass(cls)}
|
||||||
|
title={`${cls.name}\n${schedule.courseName || ''}\n${schedule.startTime}-${schedule.endTime}`}
|
||||||
|
>
|
||||||
|
<span className="learning-tkb-block-code">{cls.classCode}</span>
|
||||||
|
{schedule.courseName && (
|
||||||
|
<span className="learning-tkb-block-course">{schedule.courseName}</span>
|
||||||
|
)}
|
||||||
|
{(schedule.startTime || schedule.endTime) && (
|
||||||
|
<span className="learning-tkb-block-time">
|
||||||
|
{schedule.startTime}
|
||||||
|
{schedule.endTime ? `–${schedule.endTime}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
129
management/src/components/learningUtils.ts
Normal file
129
management/src/components/learningUtils.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { ClassScheduleItem } from '../api';
|
||||||
|
|
||||||
|
export const WEEK_DAYS = ['Thứ 2', 'Thứ 3', 'Thứ 4', 'Thứ 5', 'Thứ 6', 'Thứ 7', 'Chủ Nhật'] as const;
|
||||||
|
export const PERIODS = [1, 2, 3, 4] as const;
|
||||||
|
|
||||||
|
export interface ActiveClassItem {
|
||||||
|
rkId: number;
|
||||||
|
name: string;
|
||||||
|
classCode: string;
|
||||||
|
type?: string;
|
||||||
|
studentCount?: number;
|
||||||
|
specializeName?: string;
|
||||||
|
systemName?: string;
|
||||||
|
schedules?: ClassScheduleItem[];
|
||||||
|
allowedApps?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDayLabel(day: number): string {
|
||||||
|
return WEEK_DAYS[day] ?? `Ngày ${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thứ 2 = 0 … Chủ nhật = 6 */
|
||||||
|
export function getTodayDayOfWeek(): number {
|
||||||
|
const js = new Date().getDay();
|
||||||
|
return js === 0 ? 6 : js - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ca có dữ liệu hiển thị (có giờ hoặc môn) — kể cả ca template chưa bật */
|
||||||
|
export function isScheduleVisible(s: ClassScheduleItem): boolean {
|
||||||
|
return Boolean(s.startTime?.trim() || s.courseName?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterActiveClasses(
|
||||||
|
classes: ActiveClassItem[],
|
||||||
|
query: string,
|
||||||
|
dayFilter: number | null
|
||||||
|
): ActiveClassItem[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return classes.filter(cls => {
|
||||||
|
if (dayFilter !== null) {
|
||||||
|
const hasDay = (cls.schedules || []).some(
|
||||||
|
s => s.dayOfWeek === dayFilter && isScheduleVisible(s)
|
||||||
|
);
|
||||||
|
if (!hasDay) return false;
|
||||||
|
}
|
||||||
|
if (!q) return true;
|
||||||
|
const haystack = [
|
||||||
|
cls.name,
|
||||||
|
cls.classCode,
|
||||||
|
cls.specializeName,
|
||||||
|
cls.systemName,
|
||||||
|
cls.type,
|
||||||
|
...(cls.schedules || []).map(s => `${s.courseName} ${s.startTime} ${s.endTime}`),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
return haystack.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countWeeklyShifts(classes: ActiveClassItem[]): number {
|
||||||
|
return classes.reduce(
|
||||||
|
(sum, cls) => sum + (cls.schedules || []).filter(isScheduleVisible).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classHasToday(cls: ActiveClassItem): boolean {
|
||||||
|
const today = getTodayDayOfWeek();
|
||||||
|
return (cls.schedules || []).some(s => s.dayOfWeek === today && isScheduleVisible(s) && s.isActive !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schedulesByDay(cls: ActiveClassItem): Map<number, ClassScheduleItem[]> {
|
||||||
|
const map = new Map<number, ClassScheduleItem[]>();
|
||||||
|
for (const s of cls.schedules || []) {
|
||||||
|
if (!isScheduleVisible(s)) continue;
|
||||||
|
const list = map.get(s.dayOfWeek) || [];
|
||||||
|
list.push(s);
|
||||||
|
map.set(s.dayOfWeek, list);
|
||||||
|
}
|
||||||
|
for (const [, list] of map) {
|
||||||
|
list.sort((a, b) => (a.period || 1) - (b.period || 1));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLASS_COLORS = [
|
||||||
|
'#c62828',
|
||||||
|
'#1565c0',
|
||||||
|
'#2e7d32',
|
||||||
|
'#6a1b9a',
|
||||||
|
'#ef6c00',
|
||||||
|
'#00838f',
|
||||||
|
'#ad1457',
|
||||||
|
'#4527a0',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function classAccentColor(classCode: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < classCode.length; i++) {
|
||||||
|
hash = classCode.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return CLASS_COLORS[Math.abs(hash) % CLASS_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimetableCellEntry {
|
||||||
|
cls: ActiveClassItem;
|
||||||
|
schedule: ClassScheduleItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimetableMap(
|
||||||
|
classes: ActiveClassItem[]
|
||||||
|
): Map<string, TimetableCellEntry[]> {
|
||||||
|
const map = new Map<string, TimetableCellEntry[]>();
|
||||||
|
for (const cls of classes) {
|
||||||
|
for (const s of cls.schedules || []) {
|
||||||
|
if (!isScheduleVisible(s)) continue;
|
||||||
|
const key = `${s.dayOfWeek}-${s.period || 1}`;
|
||||||
|
const list = map.get(key) || [];
|
||||||
|
list.push({ cls, schedule: s });
|
||||||
|
map.set(key, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [, list] of map) {
|
||||||
|
list.sort((a, b) => a.cls.classCode.localeCompare(b.cls.classCode));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -2547,3 +2547,446 @@ input:checked + .slider:before {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Learning tab: search, cards, timetable ── */
|
||||||
|
.learning-stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-stat {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-stat--accent {
|
||||||
|
border-color: rgba(198, 40, 40, 0.35);
|
||||||
|
background: rgba(198, 40, 40, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-stat-value {
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-stat-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-toolbar-card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-search {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-view-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-view-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-view-btn--active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-day-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-day-chip {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.28rem 0.65rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-day-chip--active {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: rgba(198, 40, 40, 0.12);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-empty-icon {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-card:hover {
|
||||||
|
border-color: rgba(198, 40, 40, 0.35);
|
||||||
|
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-card--today {
|
||||||
|
border-color: rgba(198, 40, 40, 0.45);
|
||||||
|
background: linear-gradient(145deg, rgba(198, 40, 40, 0.07), var(--bg-subtle));
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-code {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-name {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-meta {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-badge {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-badge--today {
|
||||||
|
background: rgba(198, 40, 40, 0.15);
|
||||||
|
border-color: rgba(198, 40, 40, 0.4);
|
||||||
|
color: #ff8a80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-schedule {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-schedule-day {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-schedule-day-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 3.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-schedule-shifts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-no-sched {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-apps {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-apps code {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-class-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-btn-danger {
|
||||||
|
color: #ff8a80 !important;
|
||||||
|
border-color: rgba(198, 40, 40, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-add-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-add-item {
|
||||||
|
padding: 0.65rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timetable (TKB) */
|
||||||
|
.learning-tkb-wrap {
|
||||||
|
margin: -0.25rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 720px;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-corner {
|
||||||
|
width: 64px;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm) 0 0 0;
|
||||||
|
padding: 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-dayhead {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-left: none;
|
||||||
|
padding: 0.55rem 0.4rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-dayhead span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-dayhead em {
|
||||||
|
display: block;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-dayhead--today {
|
||||||
|
background: rgba(198, 40, 40, 0.1);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-period {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-top: none;
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-period-num {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-cell {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-left: none;
|
||||||
|
border-top: none;
|
||||||
|
padding: 0.35rem;
|
||||||
|
vertical-align: top;
|
||||||
|
min-height: 72px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-cell--today {
|
||||||
|
background: rgba(198, 40, 40, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-cell--filled {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-empty {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-blocks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.1rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
border-left: 3px solid var(--tkb-accent, var(--primary-color));
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-block:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-block-code {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--tkb-accent, var(--primary-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-block-course {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.25;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-block--inactive {
|
||||||
|
opacity: 0.55;
|
||||||
|
border-left-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-tkb-block-time {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.learning-stats-row {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-view-toggle {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learning-view-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user