add
This commit is contained in:
205
client/app.go
205
client/app.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
@@ -11,8 +12,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -127,6 +130,15 @@ type StudentShiftSnapshot struct {
|
||||
AttendanceLabel string `json:"attendanceLabel"`
|
||||
}
|
||||
|
||||
type StudentExamSnapshot struct {
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
ExamName string `json:"examName"`
|
||||
QuizURL string `json:"quizUrl"`
|
||||
PaperSent bool `json:"paperSent"`
|
||||
PaperTitle string `json:"paperTitle"`
|
||||
Submitted bool `json:"submitted"`
|
||||
}
|
||||
|
||||
type StudentDashboardSnapshot struct {
|
||||
SessionDate string `json:"sessionDate"`
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
@@ -141,6 +153,7 @@ type StudentDashboardSnapshot struct {
|
||||
InScheduleNow bool `json:"inScheduleNow"`
|
||||
BlockerActive bool `json:"blockerActive"`
|
||||
Shifts []StudentShiftSnapshot `json:"shifts"`
|
||||
Exam *StudentExamSnapshot `json:"exam,omitempty"`
|
||||
}
|
||||
|
||||
func NewApp() *App {
|
||||
@@ -480,6 +493,7 @@ func (a *App) GetStats() map[string]any {
|
||||
"inScheduleNow": a.dashboard.InScheduleNow,
|
||||
"blockerActive": a.dashboard.BlockerActive,
|
||||
"shifts": shifts,
|
||||
"exam": a.dashboard.Exam,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,6 +931,8 @@ func (a *App) fetchStudentStatus(studentID int64) {
|
||||
a.statusMsg = fmt.Sprintf("%s · Ngoài giờ học", res.ClassName)
|
||||
} else if res.ClassName != "" && res.MonitorMode == "not_configured" {
|
||||
a.statusMsg = fmt.Sprintf("%s · %s", res.ClassName, res.MonitorLabel)
|
||||
} else if res.MonitorMode == "exam" {
|
||||
a.statusMsg = res.MonitorLabel
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
@@ -992,6 +1008,16 @@ func (a *App) connectWS() {
|
||||
a.alertChatIncoming(from, preview)
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "chat:message", msg.Data)
|
||||
case "exam:paper-sent":
|
||||
runtime.EventsEmit(a.ctx, "exam:paper-sent", msg.Data)
|
||||
a.mu.Lock()
|
||||
if a.dashboard.Exam != nil {
|
||||
a.dashboard.Exam.PaperSent = true
|
||||
if t, ok := msg.Data["paperTitle"].(string); ok {
|
||||
a.dashboard.Exam.PaperTitle = t
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -1188,3 +1214,182 @@ func (a *App) SendChatMessage(body string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) OpenExamQuiz() error {
|
||||
a.mu.Lock()
|
||||
var url string
|
||||
if a.dashboard.Exam != nil {
|
||||
url = strings.TrimSpace(a.dashboard.Exam.QuizURL)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if url == "" {
|
||||
return errors.New("Phòng thi chưa cấu hình link trắc nghiệm")
|
||||
}
|
||||
return openPrivateBrowser(url)
|
||||
}
|
||||
|
||||
func openPrivateBrowser(url string) error {
|
||||
tries := [][]string{
|
||||
{"cmd", "/c", "start", "", "chrome", "--incognito", "--new-window", "--disk-cache-size=1", url},
|
||||
{"cmd", "/c", "start", "", "msedge", "--inprivate", "--new-window", url},
|
||||
{"cmd", "/c", "start", "", url},
|
||||
}
|
||||
var lastErr error
|
||||
for _, args := range tries {
|
||||
if err := exec.Command(args[0], args[1:]...).Start(); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("không mở được trình duyệt")
|
||||
}
|
||||
|
||||
func (a *App) OpenExamPaper() error {
|
||||
a.mu.Lock()
|
||||
student := a.student
|
||||
exam := a.dashboard.Exam
|
||||
a.mu.Unlock()
|
||||
if student == nil {
|
||||
return errors.New("chưa đăng nhập")
|
||||
}
|
||||
if exam == nil || !exam.PaperSent {
|
||||
return errors.New("Giảng viên chưa gửi đề")
|
||||
}
|
||||
dlURL := fmt.Sprintf("http://127.0.0.1:8080/api/student/exam/download?studentRkId=%d&kind=pdf", student.StudentID)
|
||||
resp, err := http.Get(dlURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return errors.New("không tải được đề")
|
||||
}
|
||||
dir := filepath.Join(os.TempDir(), "SimpleCareExam")
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
dest := filepath.Join(dir, fmt.Sprintf("de_thi_%d.pdf", exam.ExamRoomID))
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
return exec.Command("cmd", "/c", "start", "", dest).Start()
|
||||
}
|
||||
|
||||
func (a *App) SubmitExamWork() (string, error) {
|
||||
a.mu.Lock()
|
||||
student := a.student
|
||||
exam := a.dashboard.Exam
|
||||
a.mu.Unlock()
|
||||
if student == nil {
|
||||
return "", errors.New("chưa đăng nhập")
|
||||
}
|
||||
if exam == nil {
|
||||
return "", errors.New("không trong giờ thi")
|
||||
}
|
||||
dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Chọn folder bài làm để nộp",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if dir == "" {
|
||||
return "", errors.New("đã hủy")
|
||||
}
|
||||
zipPath := filepath.Join(os.TempDir(), fmt.Sprintf("exam_submit_%d_%d.zip", exam.ExamRoomID, time.Now().Unix()))
|
||||
if err := zipFolder(dir, zipPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(zipPath)
|
||||
|
||||
f, err := os.Open(zipPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var body bytes.Buffer
|
||||
w := multipart.NewWriter(&body)
|
||||
_ = w.WriteField("studentRkId", fmt.Sprintf("%d", student.StudentID))
|
||||
part, err := w.CreateFormFile("submission", filepath.Base(zipPath))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(part, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", "http://127.0.0.1:8080/api/student/exam/submit", &body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var res struct {
|
||||
OK bool `json:"ok"`
|
||||
FileName string `json:"fileName"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&res)
|
||||
if resp.StatusCode >= 300 || !res.OK {
|
||||
if res.Error != "" {
|
||||
return "", errors.New(res.Error)
|
||||
}
|
||||
return "", errors.New("nộp bài thất bại")
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.dashboard.Exam != nil {
|
||||
a.dashboard.Exam.Submitted = true
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return res.FileName, nil
|
||||
}
|
||||
|
||||
func zipFolder(srcDir, destZip string) error {
|
||||
out, err := os.Create(destZip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
zw := zip.NewWriter(out)
|
||||
defer zw.Close()
|
||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
hdr, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr.Name = rel
|
||||
hdr.Method = zip.Deflate
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rf, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rf.Close()
|
||||
_, err = io.Copy(w, rf)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
@@ -390,6 +390,31 @@ body {
|
||||
.status-banner.mode-exam { border-left-color: #7c3aed; }
|
||||
.status-banner.mode-exam .monitor-mode-tag { background: #f5f3ff; color: #6d28d9; }
|
||||
|
||||
.exam-panel {
|
||||
border: 1px solid #c4b5fd;
|
||||
background: linear-gradient(135deg, #faf5ff 0%, #fff 100%);
|
||||
padding: 1rem 1.15rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.exam-panel-desc {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.exam-panel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.exam-wait, .exam-done {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
@@ -20,7 +20,8 @@ let stats = {
|
||||
classCode: '',
|
||||
currentPeriod: 0,
|
||||
currentCourseName: '',
|
||||
shifts: []
|
||||
shifts: [],
|
||||
exam: null
|
||||
};
|
||||
|
||||
let chatOpen = false;
|
||||
@@ -65,6 +66,14 @@ function init() {
|
||||
if (data?.staffId) bannerStaffId = Number(data.staffId);
|
||||
pulseChatFab();
|
||||
});
|
||||
window.runtime.EventsOn('exam:paper-sent', () => {
|
||||
if (loggedIn) {
|
||||
window.go.main.App.GetStats().then((s) => {
|
||||
stats = { ...stats, ...s };
|
||||
updateExamPanel();
|
||||
}).catch(console.error);
|
||||
}
|
||||
});
|
||||
checkLogin();
|
||||
}
|
||||
|
||||
@@ -169,6 +178,63 @@ function renderShiftsTable(shifts) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderExamPanel() {
|
||||
const ex = stats.exam;
|
||||
if (!ex || stats.monitorMode !== 'exam') return '';
|
||||
const paperBtn = ex.paperSent
|
||||
? `<button type="button" class="btn btn-primary" id="btn-exam-paper">📄 Xem đề${ex.paperTitle ? ` (${ex.paperTitle})` : ''}</button>`
|
||||
: `<span class="exam-wait">Chờ giảng viên gửi đề...</span>`;
|
||||
const quizBtn = ex.quizUrl
|
||||
? `<button type="button" class="btn btn-secondary" id="btn-exam-quiz">📝 Làm trắc nghiệm</button>`
|
||||
: '';
|
||||
const submitBtn = ex.submitted
|
||||
? `<span class="exam-done">✓ Đã nộp bài tự luận</span>`
|
||||
: `<button type="button" class="btn btn-primary" id="btn-exam-submit">📦 Nộp bài (chọn folder)</button>`;
|
||||
return `
|
||||
<div class="card exam-panel" id="exam-panel">
|
||||
<div class="card-title-row">
|
||||
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
|
||||
</div>
|
||||
<p class="exam-panel-desc">Bạn đang trong giờ thi. Làm bài theo hướng dẫn của giảng viên.</p>
|
||||
<div class="exam-panel-actions">
|
||||
${paperBtn}
|
||||
${quizBtn}
|
||||
${submitBtn}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function wireExamPanel() {
|
||||
const paper = document.getElementById('btn-exam-paper');
|
||||
if (paper) paper.addEventListener('click', () => {
|
||||
window.go.main.App.OpenExamPaper().catch((e) => alert(e?.message || e));
|
||||
});
|
||||
const quiz = document.getElementById('btn-exam-quiz');
|
||||
if (quiz) quiz.addEventListener('click', () => {
|
||||
window.go.main.App.OpenExamQuiz().catch((e) => alert(e?.message || e));
|
||||
});
|
||||
const submit = document.getElementById('btn-exam-submit');
|
||||
if (submit) submit.addEventListener('click', async () => {
|
||||
try {
|
||||
const name = await window.go.main.App.SubmitExamWork();
|
||||
alert(`Đã nộp bài: ${name}`);
|
||||
const fresh = await window.go.main.App.GetStats();
|
||||
stats = { ...stats, ...fresh };
|
||||
updateExamPanel();
|
||||
} catch (e) {
|
||||
alert(e?.message || e || 'Nộp bài thất bại');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateExamPanel() {
|
||||
const host = document.getElementById('exam-panel-host');
|
||||
if (!host) return;
|
||||
host.innerHTML = renderExamPanel();
|
||||
wireExamPanel();
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
if (!studentInfo) return;
|
||||
|
||||
@@ -227,6 +293,8 @@ function renderDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="exam-panel-host">${renderExamPanel()}</div>
|
||||
|
||||
<div class="stats-grid stats-grid--two">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-icon-wrap">📡</div>
|
||||
@@ -275,6 +343,7 @@ function renderDashboard() {
|
||||
await window.go.main.App.Logout();
|
||||
}
|
||||
});
|
||||
wireExamPanel();
|
||||
}
|
||||
|
||||
function ensureChatWidget() {
|
||||
@@ -497,11 +566,17 @@ function startStatsTicker() {
|
||||
|
||||
const statusSubEl = document.getElementById('status-sub');
|
||||
if (statusSubEl) {
|
||||
statusSubEl.innerText = stats.currentPeriod > 0 && stats.currentCourseName
|
||||
? `Ca ${stats.currentPeriod} · ${stats.currentCourseName}`
|
||||
: 'Theo dõi theo lịch học của lớp';
|
||||
if (stats.monitorMode === 'exam' && stats.exam?.examName) {
|
||||
statusSubEl.innerText = stats.exam.examName;
|
||||
} else {
|
||||
statusSubEl.innerText = stats.currentPeriod > 0 && stats.currentCourseName
|
||||
? `Ca ${stats.currentPeriod} · ${stats.currentCourseName}`
|
||||
: 'Theo dõi theo lịch học của lớp';
|
||||
}
|
||||
}
|
||||
|
||||
updateExamPanel();
|
||||
|
||||
const onlineEl = document.getElementById('clock-online');
|
||||
if (onlineEl) onlineEl.innerText = formatDuration(stats.onlineSecs || 0);
|
||||
|
||||
|
||||
6
client/frontend/wailsjs/go/main/App.d.ts
vendored
6
client/frontend/wailsjs/go/main/App.d.ts
vendored
@@ -19,8 +19,14 @@ export function Logout():Promise<void>;
|
||||
|
||||
export function NavigateToLogin():Promise<void>;
|
||||
|
||||
export function OpenExamPaper():Promise<void>;
|
||||
|
||||
export function OpenExamQuiz():Promise<void>;
|
||||
|
||||
export function SendChatMessage(arg1:string):Promise<void>;
|
||||
|
||||
export function SendWebcamFrame(arg1:string):Promise<void>;
|
||||
|
||||
export function SubmitExamWork():Promise<string>;
|
||||
|
||||
export function UnlockChatAudio():Promise<void>;
|
||||
|
||||
@@ -38,6 +38,14 @@ export function NavigateToLogin() {
|
||||
return window['go']['main']['App']['NavigateToLogin']();
|
||||
}
|
||||
|
||||
export function OpenExamPaper() {
|
||||
return window['go']['main']['App']['OpenExamPaper']();
|
||||
}
|
||||
|
||||
export function OpenExamQuiz() {
|
||||
return window['go']['main']['App']['OpenExamQuiz']();
|
||||
}
|
||||
|
||||
export function SendChatMessage(arg1) {
|
||||
return window['go']['main']['App']['SendChatMessage'](arg1);
|
||||
}
|
||||
@@ -46,6 +54,10 @@ export function SendWebcamFrame(arg1) {
|
||||
return window['go']['main']['App']['SendWebcamFrame'](arg1);
|
||||
}
|
||||
|
||||
export function SubmitExamWork() {
|
||||
return window['go']['main']['App']['SubmitExamWork']();
|
||||
}
|
||||
|
||||
export function UnlockChatAudio() {
|
||||
return window['go']['main']['App']['UnlockChatAudio']();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { DashboardTab } from './components/DashboardTab';
|
||||
import { ClassesTab } from './components/ClassesTab';
|
||||
import { StudentsTab } from './components/StudentsTab';
|
||||
import { LearningTab } from './components/LearningTab';
|
||||
import { ExamsTab } from './components/ExamsTab';
|
||||
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
|
||||
import { NetworkTab } from './components/NetworkTab';
|
||||
import { EmailDomainsTab, MyAccountTab } from './components/AccountsTab';
|
||||
import { ChatWidget } from './components/ChatWidget';
|
||||
@@ -41,6 +43,14 @@ const IconLearning = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconExam = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconNetwork = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
||||
@@ -63,7 +73,7 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const initial = parseRoute();
|
||||
if (initial.classId) {
|
||||
if (initial.classId || initial.examId) {
|
||||
return;
|
||||
}
|
||||
pushNav({ kind: 'tab', tab: initial.tab, label: TAB_LABELS[initial.tab] });
|
||||
@@ -73,10 +83,12 @@ function App() {
|
||||
navigate(tab as TabId);
|
||||
};
|
||||
|
||||
const handleBackFromClass = () => {
|
||||
const handleBackFromWorkspace = () => {
|
||||
goBack(route.tab);
|
||||
};
|
||||
|
||||
const inWorkspace = Boolean(route.classId || route.examId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="sidebar">
|
||||
@@ -93,7 +105,7 @@ function App() {
|
||||
<div className="nav-header">Tổng quan</div>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${route.tab === 'dashboard' && !route.classId ? 'active' : ''}`}
|
||||
className={`nav-btn ${route.tab === 'dashboard' && !inWorkspace ? 'active' : ''}`}
|
||||
onClick={() => navigate('dashboard')}
|
||||
>
|
||||
<span className="nav-icon"><IconDashboard /></span>
|
||||
@@ -104,7 +116,7 @@ function App() {
|
||||
<div className="nav-header">CSDL liên kết</div>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${route.tab === 'classes' && !route.classId ? 'active' : ''}`}
|
||||
className={`nav-btn ${route.tab === 'classes' && !inWorkspace ? 'active' : ''}`}
|
||||
onClick={() => navigate('classes')}
|
||||
>
|
||||
<span className="nav-icon"><IconClass /></span>
|
||||
@@ -113,7 +125,7 @@ function App() {
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${route.tab === 'students' && !route.classId ? 'active' : ''}`}
|
||||
className={`nav-btn ${route.tab === 'students' && !inWorkspace ? 'active' : ''}`}
|
||||
onClick={() => navigate('students')}
|
||||
>
|
||||
<span className="nav-icon"><IconStudent /></span>
|
||||
@@ -124,7 +136,7 @@ function App() {
|
||||
<div className="nav-header">Quản lý học tập</div>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${route.tab === 'learning' && !route.classId ? 'active' : ''}`}
|
||||
className={`nav-btn ${route.tab === 'learning' && !inWorkspace ? 'active' : ''}`}
|
||||
onClick={() => navigate('learning')}
|
||||
>
|
||||
<span className="nav-icon"><IconLearning /></span>
|
||||
@@ -133,7 +145,16 @@ function App() {
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${route.tab === 'network' && !route.classId ? 'active' : ''}`}
|
||||
className={`nav-btn ${route.tab === 'exams' && !inWorkspace ? 'active' : ''}`}
|
||||
onClick={() => navigate('exams')}
|
||||
>
|
||||
<span className="nav-icon"><IconExam /></span>
|
||||
Phòng thi
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${route.tab === 'network' && !inWorkspace ? 'active' : ''}`}
|
||||
onClick={() => navigate('network')}
|
||||
>
|
||||
<span className="nav-icon"><IconNetwork /></span>
|
||||
@@ -144,7 +165,7 @@ function App() {
|
||||
<div className="nav-header">Hệ thống</div>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${route.tab === 'email-domains' && !route.classId ? 'active' : ''}`}
|
||||
className={`nav-btn ${route.tab === 'email-domains' && !inWorkspace ? 'active' : ''}`}
|
||||
onClick={() => navigate('email-domains')}
|
||||
>
|
||||
<span className="nav-icon"><IconEmail /></span>
|
||||
@@ -157,7 +178,7 @@ function App() {
|
||||
<div className="sidebar-footer">
|
||||
<button
|
||||
type="button"
|
||||
className={`sidebar-user-btn ${route.tab === 'profile' && !route.classId ? 'active' : ''}`}
|
||||
className={`sidebar-user-btn ${route.tab === 'profile' && !inWorkspace ? 'active' : ''}`}
|
||||
onClick={() => navigate('profile')}
|
||||
title="Tài khoản của tôi"
|
||||
>
|
||||
@@ -178,17 +199,20 @@ function App() {
|
||||
<ClassWorkspace
|
||||
classId={route.classId}
|
||||
sourceTab={route.tab}
|
||||
onBack={handleBackFromClass}
|
||||
onBack={handleBackFromWorkspace}
|
||||
renderNavBar={(className) => (
|
||||
<NavHistoryBar classLabel={className} onBack={handleBackFromClass} />
|
||||
<NavHistoryBar classLabel={className} onBack={handleBackFromWorkspace} />
|
||||
)}
|
||||
/>
|
||||
) : route.examId ? (
|
||||
<ExamRoomWorkspace examId={route.examId} onBack={handleBackFromWorkspace} />
|
||||
) : (
|
||||
<>
|
||||
{route.tab === 'dashboard' && <DashboardTab onNavigate={setActiveTab} />}
|
||||
{route.tab === 'classes' && <ClassesTab />}
|
||||
{route.tab === 'students' && <StudentsTab />}
|
||||
{route.tab === 'learning' && <LearningTab />}
|
||||
{route.tab === 'exams' && <ExamsTab />}
|
||||
{route.tab === 'network' && <NetworkTab />}
|
||||
{route.tab === 'email-domains' && <EmailDomainsTab />}
|
||||
{route.tab === 'profile' && <MyAccountTab />}
|
||||
|
||||
@@ -4,7 +4,7 @@ function getToken(): string | null {
|
||||
return localStorage.getItem('sc_staff_token');
|
||||
}
|
||||
|
||||
async function staffFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
export async function staffFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = getToken();
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
@@ -16,7 +16,10 @@ async function staffFetch(path: string, init: RequestInit = {}): Promise<Respons
|
||||
|
||||
async function parseError(res: Response, fallback: string): Promise<never> {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error((errData as { error?: string }).error || fallback);
|
||||
const msg = (errData as { error?: string }).error;
|
||||
if (msg) throw new Error(msg);
|
||||
if (res.status === 413) throw new Error('File quá lớn (tối đa 100MB)');
|
||||
throw new Error(`${fallback} (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
export interface StaffUser {
|
||||
@@ -568,3 +571,185 @@ export const apiPushAttendanceQLDT = async (rkId: number, date: string, period:
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
async function staffUpload(path: string, form: FormData): Promise<Response> {
|
||||
const headers = new Headers();
|
||||
const token = getToken();
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
return fetch(`${API_BASE}${path}`, { method: 'POST', headers, body: form });
|
||||
}
|
||||
|
||||
export interface ExamRoomItem {
|
||||
id: number;
|
||||
name: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
allowedApps: string;
|
||||
quizUrl: string;
|
||||
status: 'draft' | 'ready' | 'ended' | 'cancelled';
|
||||
studentCount: number;
|
||||
paperCount: number;
|
||||
displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled';
|
||||
}
|
||||
|
||||
export interface ExamPaperResource {
|
||||
id: number;
|
||||
examPaperId: number;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface ExamPaper {
|
||||
id: number;
|
||||
examRoomId: number;
|
||||
title: string;
|
||||
pdfPath: string;
|
||||
sortOrder: number;
|
||||
resources: ExamPaperResource[];
|
||||
}
|
||||
|
||||
export interface ExamRoomStudent {
|
||||
id: number;
|
||||
examRoomId: number;
|
||||
studentRkId: number;
|
||||
assignedPaperId?: number;
|
||||
paperSentAt?: string;
|
||||
paperScheduledAt?: string;
|
||||
fullName: string;
|
||||
studentCode: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
paperTitle?: string;
|
||||
submitted: boolean;
|
||||
}
|
||||
|
||||
export interface ExamSubmission {
|
||||
id: number;
|
||||
examRoomId: number;
|
||||
studentRkId: number;
|
||||
fileName: string;
|
||||
createdAt: string;
|
||||
fullName: string;
|
||||
studentCode: string;
|
||||
}
|
||||
|
||||
export const apiExam = {
|
||||
list: async (): Promise<{ data: ExamRoomItem[] }> => {
|
||||
const res = await staffFetch('/exam-rooms');
|
||||
if (!res.ok) await parseError(res, 'Failed');
|
||||
return res.json();
|
||||
},
|
||||
create: async (payload: { name: string; startTime: string; endTime: string; allowedApps?: string; quizUrl?: string }) => {
|
||||
const res = await staffFetch('/exam-rooms', { method: 'POST', body: JSON.stringify(payload) });
|
||||
if (!res.ok) await parseError(res, 'Tạo phòng thi thất bại');
|
||||
return res.json();
|
||||
},
|
||||
get: async (id: number) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}`);
|
||||
if (!res.ok) await parseError(res, 'Không tìm thấy');
|
||||
return res.json() as Promise<{
|
||||
room: ExamRoomItem;
|
||||
papers: ExamPaper[];
|
||||
students: ExamRoomStudent[];
|
||||
displayStatus: string;
|
||||
editable: boolean;
|
||||
prepEditable: boolean;
|
||||
canPublish: boolean;
|
||||
canUnpublish: boolean;
|
||||
canCancel: boolean;
|
||||
}>;
|
||||
},
|
||||
publish: async (id: number) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/publish`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({})) as { error?: string; issues?: string[] };
|
||||
if (data.issues?.length) {
|
||||
throw new Error(data.issues.join('\n'));
|
||||
}
|
||||
throw new Error(data.error || 'Đẩy phòng thi thất bại');
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
unpublish: async (id: number) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/unpublish`, { method: 'POST' });
|
||||
if (!res.ok) await parseError(res, 'Thu hồi thất bại');
|
||||
return res.json();
|
||||
},
|
||||
cancel: async (id: number) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/cancel`, { method: 'POST' });
|
||||
if (!res.ok) await parseError(res, 'Hủy phòng thi thất bại');
|
||||
return res.json();
|
||||
},
|
||||
update: async (id: number, payload: Partial<{ name: string; startTime: string; endTime: string; allowedApps: string; quizUrl: string }>) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
||||
if (!res.ok) await parseError(res, 'Cập nhật thất bại');
|
||||
return res.json();
|
||||
},
|
||||
remove: async (id: number) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) await parseError(res, 'Xóa thất bại');
|
||||
return res.json();
|
||||
},
|
||||
searchStudents: async (q: string) => {
|
||||
const res = await staffFetch(`/exam-rooms/search-students?q=${encodeURIComponent(q)}`);
|
||||
if (!res.ok) await parseError(res, 'Tìm kiếm thất bại');
|
||||
return res.json() as Promise<{ data: { studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[] }>;
|
||||
},
|
||||
addStudents: async (id: number, studentRkIds: number[]) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/students`, { method: 'POST', body: JSON.stringify({ studentRkIds }) });
|
||||
if (!res.ok) await parseError(res, 'Thêm sinh viên thất bại');
|
||||
return res.json();
|
||||
},
|
||||
removeStudent: async (id: number, studentRkId: number) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/students/${studentRkId}`, { method: 'DELETE' });
|
||||
if (!res.ok) await parseError(res, 'Xóa sinh viên thất bại');
|
||||
return res.json();
|
||||
},
|
||||
fetchOnlineStudents: async (id: number): Promise<{ onlineStudentIds: number[] }> => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/online-students`);
|
||||
if (!res.ok) throw new Error('Không tải được trạng thái online');
|
||||
return res.json();
|
||||
},
|
||||
uploadPackage: async (id: number, title: string, pdf: File, resources: File[] = []) => {
|
||||
const form = new FormData();
|
||||
form.append('title', title);
|
||||
form.append('pdf', pdf);
|
||||
resources.forEach((f) => form.append('resources', f));
|
||||
const res = await staffUpload(`/exam-rooms/${id}/papers`, form);
|
||||
if (!res.ok) await parseError(res, 'Tạo gói đề thất bại');
|
||||
return res.json();
|
||||
},
|
||||
uploadPaper: async (id: number, title: string, pdf: File, resources: File[] = []) => {
|
||||
return apiExam.uploadPackage(id, title, pdf, resources);
|
||||
},
|
||||
uploadPackageResources: async (id: number, paperId: number, files: File[]) => {
|
||||
const form = new FormData();
|
||||
files.forEach((f) => form.append('resources', f));
|
||||
const res = await staffUpload(`/exam-rooms/${id}/papers/${paperId}/resources`, form);
|
||||
if (!res.ok) await parseError(res, 'Thêm tài nguyên thất bại');
|
||||
return res.json();
|
||||
},
|
||||
uploadResource: async (id: number, paperId: number, file: File) => {
|
||||
return apiExam.uploadPackageResources(id, paperId, [file]);
|
||||
},
|
||||
deletePaper: async (id: number, paperId: number) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/papers/${paperId}`, { method: 'DELETE' });
|
||||
if (!res.ok) await parseError(res, 'Xóa gói đề thất bại');
|
||||
return res.json();
|
||||
},
|
||||
assignRandom: async (id: number) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' });
|
||||
if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
|
||||
return res.json() as Promise<{ assigned: number; packages: number }>;
|
||||
},
|
||||
sendPapers: async (id: number, payload?: { scheduledAt?: string; studentRkIds?: number[] }) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/send-papers`, { method: 'POST', body: JSON.stringify(payload || {}) });
|
||||
if (!res.ok) await parseError(res, 'Gửi gói đề thất bại');
|
||||
return res.json();
|
||||
},
|
||||
listSubmissions: async (id: number) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/submissions`);
|
||||
if (!res.ok) await parseError(res, 'Failed');
|
||||
return res.json() as Promise<{ data: ExamSubmission[] }>;
|
||||
},
|
||||
downloadSubmission: (id: number, subId: number) => `${API_BASE}/exam-rooms/${id}/submissions/${subId}/download`,
|
||||
};
|
||||
|
||||
10
management/src/chatEvents.ts
Normal file
10
management/src/chatEvents.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type StaffChatOpenDetail = {
|
||||
studentRkId: number;
|
||||
fullName: string;
|
||||
studentCode: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export function openStaffChat(student: StaffChatOpenDetail) {
|
||||
window.dispatchEvent(new CustomEvent<StaffChatOpenDetail>('staff-chat:open', { detail: student }));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
|
||||
import { type StaffChatOpenDetail } from '../chatEvents';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { onStaffChatMessage, playChatSound } from '../hooks/useStaffChatSocket';
|
||||
|
||||
@@ -82,6 +83,26 @@ export function ChatWidget() {
|
||||
return () => { off(); };
|
||||
}, [handleIncoming]);
|
||||
|
||||
useEffect(() => {
|
||||
const onOpen = (e: Event) => {
|
||||
const detail = (e as CustomEvent<StaffChatOpenDetail>).detail;
|
||||
if (!detail?.studentRkId) return;
|
||||
setOpen(true);
|
||||
setActive({
|
||||
studentRkId: detail.studentRkId,
|
||||
fullName: detail.fullName,
|
||||
studentCode: detail.studentCode,
|
||||
email: detail.email || '',
|
||||
online: false,
|
||||
});
|
||||
setPickerOpen(false);
|
||||
setQuery('');
|
||||
loadMessages(detail.studentRkId).catch(console.error);
|
||||
};
|
||||
window.addEventListener('staff-chat:open', onOpen);
|
||||
return () => window.removeEventListener('staff-chat:open', onOpen);
|
||||
}, [loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!staff?.id) return;
|
||||
loadConversations().catch(console.error);
|
||||
|
||||
881
management/src/components/ExamRoomWorkspace.tsx
Normal file
881
management/src/components/ExamRoomWorkspace.tsx
Normal file
@@ -0,0 +1,881 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
apiExam,
|
||||
staffFetch,
|
||||
type ExamPaper,
|
||||
type ExamRoomStudent,
|
||||
type ExamSubmission,
|
||||
type StudentItem,
|
||||
} from '../api';
|
||||
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
|
||||
import { pushNav } from '../navigation';
|
||||
import { openStaffChat } from '../chatEvents';
|
||||
import { AppPoolModal } from './AppPoolModal';
|
||||
import { NavHistoryBar } from './NavHistoryBar';
|
||||
import { StudentAvatar } from './StudentAvatar';
|
||||
import { StudentDetailModal } from './StudentDetailModal';
|
||||
import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab';
|
||||
|
||||
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
|
||||
|
||||
interface Props {
|
||||
examId: number;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function statusBadge(displayStatus: string) {
|
||||
switch (displayStatus) {
|
||||
case 'draft': return { text: 'Tạm thời', cls: 'badge-muted' };
|
||||
case 'ready': return { text: 'Sẵn sàng', cls: 'badge-info' };
|
||||
case 'active': return { text: 'Đang thi', cls: 'badge-success' };
|
||||
case 'cancelled': return { text: 'Đã hủy', cls: 'badge-warning' };
|
||||
case 'ended': return { text: 'Kết thúc', cls: 'badge-muted' };
|
||||
default: return { text: displayStatus, cls: 'badge-muted' };
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(displayStatus: string) {
|
||||
switch (displayStatus) {
|
||||
case 'active': return 'var(--success)';
|
||||
case 'ready': return 'var(--accent)';
|
||||
case 'cancelled': return 'var(--danger)';
|
||||
default: return 'var(--text-muted)';
|
||||
}
|
||||
}
|
||||
|
||||
export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const [roomName, setRoomName] = useState('');
|
||||
const [start, setStart] = useState('');
|
||||
const [end, setEnd] = useState('');
|
||||
const [allowedApps, setAllowedApps] = useState(DEFAULT_EXAM_ALLOWED_APPS);
|
||||
const [quizUrl, setQuizUrl] = useState('');
|
||||
const [editable, setEditable] = useState(false);
|
||||
const [prepEditable, setPrepEditable] = useState(false);
|
||||
const [displayStatus, setDisplayStatus] = useState('draft');
|
||||
const [canPublish, setCanPublish] = useState(false);
|
||||
const [canUnpublish, setCanUnpublish] = useState(false);
|
||||
const [canCancel, setCanCancel] = useState(false);
|
||||
const [papers, setPapers] = useState<ExamPaper[]>([]);
|
||||
const [students, setStudents] = useState<ExamRoomStudent[]>([]);
|
||||
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
|
||||
const [searchQ, setSearchQ] = useState('');
|
||||
const [searchHits, setSearchHits] = useState<{ studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[]>([]);
|
||||
const [paperTitle, setPaperTitle] = useState('');
|
||||
const [packagePdf, setPackagePdf] = useState<File | null>(null);
|
||||
const [packageResources, setPackageResources] = useState<File[]>([]);
|
||||
const [creatingPackage, setCreatingPackage] = useState(false);
|
||||
const [sendSchedule, setSendSchedule] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
||||
const [studentPickerOpen, setStudentPickerOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [onlineIds, setOnlineIds] = useState<number[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null);
|
||||
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions'>('roster');
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiExam.get(examId);
|
||||
setRoomName(data.room.name);
|
||||
setStart(toLocalInput(data.room.startTime));
|
||||
setEnd(toLocalInput(data.room.endTime));
|
||||
setAllowedApps(data.room.allowedApps || DEFAULT_EXAM_ALLOWED_APPS);
|
||||
setQuizUrl(data.room.quizUrl || '');
|
||||
setEditable(data.editable);
|
||||
setPrepEditable(data.prepEditable ?? data.editable);
|
||||
setDisplayStatus(data.displayStatus);
|
||||
setCanPublish(data.canPublish);
|
||||
setCanUnpublish(data.canUnpublish);
|
||||
setCanCancel(data.canCancel);
|
||||
setPapers(data.papers);
|
||||
setStudents(data.students);
|
||||
pushNav({
|
||||
kind: 'exam',
|
||||
tab: 'exams',
|
||||
examId,
|
||||
label: data.room.name || `Phòng thi ${examId}`,
|
||||
});
|
||||
const subs = await apiExam.listSubmissions(examId);
|
||||
setSubmissions(subs.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [examId]);
|
||||
|
||||
useEffect(() => { load().catch(console.error); }, [load]);
|
||||
|
||||
const fetchOnline = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiExam.fetchOnlineStudents(examId);
|
||||
setOnlineIds(res.onlineStudentIds || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [examId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOnline();
|
||||
const t = setInterval(fetchOnline, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [fetchOnline]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!studentPickerOpen || !searchQ.trim()) {
|
||||
if (!studentPickerOpen) setSearchHits([]);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
apiExam.searchStudents(searchQ.trim()).then((r) => setSearchHits(r.data)).catch(console.error);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchQ, studentPickerOpen]);
|
||||
|
||||
const addAppKeyword = (kw: string) => {
|
||||
const trimmed = kw.trim().toLowerCase();
|
||||
if (!trimmed) return;
|
||||
setAllowedApps((prev) => {
|
||||
const parts = prev.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
if (parts.includes(trimmed)) return prev;
|
||||
return prev ? `${prev}, ${trimmed}` : trimmed;
|
||||
});
|
||||
};
|
||||
|
||||
const saveRoom = async () => {
|
||||
setErr(''); setMsg('');
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiExam.update(examId, {
|
||||
name: roomName.trim(),
|
||||
startTime: localInputToISO(start),
|
||||
endTime: localInputToISO(end),
|
||||
allowedApps,
|
||||
quizUrl: quizUrl.trim(),
|
||||
});
|
||||
setMsg('Đã lưu cấu hình phòng thi');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Lỗi');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addStudent = async (studentRkId: number) => {
|
||||
await apiExam.addStudents(examId, [studentRkId]);
|
||||
await load();
|
||||
await fetchOnline();
|
||||
};
|
||||
|
||||
const createPackage = async () => {
|
||||
if (!packagePdf) {
|
||||
setErr('Chọn file PDF cho gói đề');
|
||||
return;
|
||||
}
|
||||
setErr('');
|
||||
setCreatingPackage(true);
|
||||
try {
|
||||
const title = paperTitle.trim() || packagePdf.name.replace(/\.pdf$/i, '') || 'Gói đề';
|
||||
const resCount = packageResources.length;
|
||||
await apiExam.uploadPackage(examId, title, packagePdf, packageResources);
|
||||
setPaperTitle('');
|
||||
setPackagePdf(null);
|
||||
setPackageResources([]);
|
||||
setMsg(`Đã tạo gói đề "${title}" (1 PDF${resCount ? ` + ${resCount} tài nguyên` : ''})`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Tạo gói đề thất bại');
|
||||
} finally {
|
||||
setCreatingPackage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadResource = async (paperId: number, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
await apiExam.uploadPackageResources(examId, paperId, files);
|
||||
e.target.value = '';
|
||||
await load();
|
||||
};
|
||||
|
||||
const assignRandomPackages = async () => {
|
||||
const res = await apiExam.assignRandom(examId);
|
||||
setMsg(`Đã chia ${res.assigned} sinh viên vào ${res.packages} gói đề`);
|
||||
await load();
|
||||
};
|
||||
|
||||
const sendPapers = async (scheduled: boolean) => {
|
||||
const payload = scheduled && sendSchedule
|
||||
? { scheduledAt: localInputToISO(sendSchedule) }
|
||||
: undefined;
|
||||
const res = await apiExam.sendPapers(examId, payload);
|
||||
setMsg(`Đã gửi ${res.sent} gói đề${res.scheduled ? `, hẹn ${res.scheduled}` : ''}`);
|
||||
await load();
|
||||
};
|
||||
|
||||
const downloadSub = async (subId: number, fileName: string) => {
|
||||
const res = await staffFetch(`/exam-rooms/${examId}/submissions/${subId}/download`);
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
setErr(''); setMsg('');
|
||||
try {
|
||||
await apiExam.publish(examId);
|
||||
setMsg('Đã đẩy phòng thi — hệ thống sẽ mở khi tới giờ');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Lỗi');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpublish = async () => {
|
||||
if (!confirm('Thu hồi lệnh đẩy? Phòng thi quay về trạng thái tạm thời.')) return;
|
||||
setErr(''); setMsg('');
|
||||
try {
|
||||
await apiExam.unpublish(examId);
|
||||
setMsg('Đã thu hồi — phòng thi ở trạng thái tạm thời');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Lỗi');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!confirm('Hủy phòng thi ngay? Sinh viên sẽ thoát chế độ thi.')) return;
|
||||
setErr(''); setMsg('');
|
||||
try {
|
||||
await apiExam.cancel(examId);
|
||||
setMsg('Đã hủy phòng thi');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Lỗi');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredStudents = students.filter((s) => {
|
||||
const term = searchQuery.toLowerCase().trim();
|
||||
if (!term) return true;
|
||||
return (
|
||||
s.fullName.toLowerCase().includes(term) ||
|
||||
s.studentCode.toLowerCase().includes(term) ||
|
||||
s.email.toLowerCase().includes(term) ||
|
||||
(s.paperTitle || '').toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
const toStudentItem = (s: ExamRoomStudent): StudentItem => ({
|
||||
id: s.id,
|
||||
rkId: s.studentRkId,
|
||||
studentCode: s.studentCode,
|
||||
fullName: s.fullName,
|
||||
email: s.email,
|
||||
avatar: s.avatar,
|
||||
});
|
||||
|
||||
const openStudentChat = (s: ExamRoomStudent) => {
|
||||
openStaffChat({
|
||||
studentRkId: s.studentRkId,
|
||||
fullName: s.fullName,
|
||||
studentCode: s.studentCode,
|
||||
email: s.email,
|
||||
});
|
||||
};
|
||||
|
||||
if (loading && !roomName) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }} />
|
||||
<div style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--text-secondary)' }}>
|
||||
Đang tải không gian làm việc phòng thi...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const badge = statusBadge(displayStatus);
|
||||
const enrolledIds = new Set(students.map((s) => s.studentRkId));
|
||||
const onlineCount = students.filter((s) => onlineIds.includes(s.studentRkId)).length;
|
||||
|
||||
return (
|
||||
<div className="workspace-layout workspace-embedded">
|
||||
<NavHistoryBar classLabel={roomName || 'Phòng thi'} onBack={onBack} />
|
||||
|
||||
{(msg || err) && (
|
||||
<div className={`alert-banner ${err ? 'alert-error' : 'alert-success'}`}>{err || msg}</div>
|
||||
)}
|
||||
|
||||
<div className="workspace-header">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div className="brand-logo" style={{ width: 44, height: 44, fontSize: '1.1rem' }}>📝</div>
|
||||
<div>
|
||||
<h1 className="workspace-class-title">{roomName || 'Phòng thi'}</h1>
|
||||
<div className="class-badge-container">
|
||||
<span className={`badge ${badge.cls}`}>{badge.text}</span>
|
||||
{start && end && (
|
||||
<span className="badge badge-muted">
|
||||
{fmtTime(localInputToISO(start))} → {fmtTime(localInputToISO(end))}
|
||||
</span>
|
||||
)}
|
||||
<span className="badge badge-info">ID: {examId}</span>
|
||||
<span className="badge badge-muted">{papers.length} gói đề · {submissions.length} bài nộp</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1.25rem', flexWrap: 'wrap' }}>
|
||||
<div className="status-panel">
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
Trạng thái thi
|
||||
</span>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: statusColor(displayStatus) }}>
|
||||
{badge.text}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
{canPublish && (
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={handlePublish}>Đẩy phòng thi</button>
|
||||
)}
|
||||
{canUnpublish && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={handleUnpublish}>Thu hồi</button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
Online / Sĩ số
|
||||
</div>
|
||||
<div className="workspace-stat-value">
|
||||
<span style={{ color: 'var(--success)' }}>{onlineCount}</span>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem', fontWeight: 500 }}> / {students.length} SV</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workspace-main">
|
||||
{configOpen && (
|
||||
<div className="workspace-drawer-backdrop" onClick={() => setConfigOpen(false)} aria-hidden />
|
||||
)}
|
||||
|
||||
<aside className={`workspace-config-drawer ${configOpen ? 'open' : ''}`}>
|
||||
<div className="workspace-drawer-header">
|
||||
<strong>Cấu hình phòng thi</strong>
|
||||
<button type="button" className="btn btn-secondary workspace-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">×</button>
|
||||
</div>
|
||||
<div className="workspace-drawer-tabs">
|
||||
<button type="button" className={`workspace-drawer-tab ${configTab === 'info' ? 'active' : ''}`} onClick={() => setConfigTab('info')}>
|
||||
Thông tin
|
||||
</button>
|
||||
<button type="button" className={`workspace-drawer-tab ${configTab === 'apps' ? 'active' : ''}`} onClick={() => setConfigTab('apps')}>
|
||||
Ứng dụng
|
||||
</button>
|
||||
<button type="button" className={`workspace-drawer-tab ${configTab === 'papers' ? 'active' : ''}`} onClick={() => setConfigTab('papers')}>
|
||||
Gói đề & gửi
|
||||
</button>
|
||||
</div>
|
||||
<div className="workspace-drawer-body">
|
||||
{configTab === 'info' && (
|
||||
<div className="config-card config-card-flat">
|
||||
<div className="card-header-title">Thông tin phòng thi</div>
|
||||
<p className="config-card-desc">
|
||||
{editable ? 'Chỉnh sửa được khi phòng ở trạng thái tạm thời.' : 'Phòng đã khóa chỉnh sửa.'}
|
||||
</p>
|
||||
<label className="login-field">
|
||||
<span>Tên phòng thi</span>
|
||||
<input value={roomName} onChange={(e) => setRoomName(e.target.value)} disabled={!editable} />
|
||||
</label>
|
||||
<label className="login-field">
|
||||
<span>Link trắc nghiệm</span>
|
||||
<input value={quizUrl} onChange={(e) => setQuizUrl(e.target.value)} placeholder="https://..." disabled={!editable} />
|
||||
</label>
|
||||
<label className="login-field">
|
||||
<span>Bắt đầu</span>
|
||||
<input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} disabled={!editable} />
|
||||
</label>
|
||||
<label className="login-field">
|
||||
<span>Kết thúc</span>
|
||||
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} disabled={!editable} />
|
||||
</label>
|
||||
{editable && (
|
||||
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
|
||||
{saving ? 'Đang lưu...' : 'Lưu thông tin'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configTab === 'apps' && (
|
||||
<div className="config-card config-card-flat">
|
||||
<div className="card-header-title">Ứng dụng được phép (khi thi)</div>
|
||||
<p className="config-card-desc">
|
||||
Từ khóa tiến trình hoặc tiêu đề được phép (ngăn cách bằng dấu phẩy).
|
||||
</p>
|
||||
<textarea
|
||||
className="app-textarea"
|
||||
placeholder="chrome, msedge, acrobat, wails, simple_care, client"
|
||||
value={allowedApps}
|
||||
onChange={(e) => setAllowedApps(e.target.value)}
|
||||
disabled={!editable}
|
||||
/>
|
||||
{editable && (
|
||||
<>
|
||||
<div className="config-suggestions">
|
||||
<span className="config-suggestions-label">Gợi ý nhanh:</span>
|
||||
<div className="config-suggestions-list">
|
||||
{EXAM_APP_SUGGESTIONS.map((kw) => (
|
||||
<span key={kw} className="app-suggestion-badge" onClick={() => addAppKeyword(kw)}>
|
||||
+ {kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-pool-open-row">
|
||||
<div>
|
||||
<span className="config-suggestions-label">Kho ứng dụng (toàn hệ thống)</span>
|
||||
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
|
||||
App bị chặn từ mọi lớp — mở kho để tìm và thêm nhanh.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
|
||||
Mở kho & tìm kiếm
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
|
||||
{saving ? 'Đang lưu...' : 'Lưu cấu hình apps'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configTab === 'papers' && (
|
||||
<div className="config-card config-card-flat">
|
||||
<div className="card-header-title">Gói đề & gửi cho sinh viên</div>
|
||||
<p className="config-card-desc">
|
||||
Mỗi gói đề gồm <strong>1 file PDF</strong> (đề chính) và <strong>tài nguyên kèm</strong> (ảnh, zip, doc…). Chia ngẫu nhiên sẽ gán cả gói cho từng sinh viên.
|
||||
</p>
|
||||
{!prepEditable && (
|
||||
<p className="config-card-desc" style={{ color: 'var(--danger)' }}>
|
||||
Không thể thêm gói đề — phòng đã khóa hoặc đã tới giờ thi.
|
||||
</p>
|
||||
)}
|
||||
{prepEditable && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem', marginBottom: '0.75rem' }}>
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder="Tên gói đề (VD: Gói A)"
|
||||
value={paperTitle}
|
||||
onChange={(e) => setPaperTitle(e.target.value)}
|
||||
/>
|
||||
<label className="login-field">
|
||||
<span>PDF đề chính (bắt buộc)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => setPackagePdf(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-field">
|
||||
<span>Tài nguyên kèm (tùy chọn, nhiều file)</span>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => setPackageResources(Array.from(e.target.files || []))}
|
||||
/>
|
||||
</label>
|
||||
{packageResources.length > 0 && (
|
||||
<p className="config-card-desc" style={{ margin: 0 }}>
|
||||
{packageResources.length} tài nguyên: {packageResources.map((f) => f.name).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%' }}
|
||||
disabled={creatingPackage || !packagePdf}
|
||||
onClick={createPackage}
|
||||
>
|
||||
{creatingPackage ? 'Đang tạo...' : 'Tạo gói đề'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{papers.length === 0 ? (
|
||||
<p className="config-card-desc">Chưa có gói đề. Tạo gói gồm PDF + tài nguyên.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
|
||||
{papers.map((p) => (
|
||||
<div key={p.id} style={{ border: '1px solid var(--border-light)', borderRadius: '8px', padding: '0.75rem', background: 'var(--bg-subtle)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'flex-start' }}>
|
||||
<strong>{p.title}</strong>
|
||||
<span className="badge badge-muted">Gói đề</span>
|
||||
</div>
|
||||
<ul className="config-card-desc" style={{ margin: '0.5rem 0 0', paddingLeft: '1.1rem' }}>
|
||||
<li>📄 main.pdf (đề chính)</li>
|
||||
{(p.resources || []).length === 0 ? (
|
||||
<li style={{ color: 'var(--text-muted)' }}>Chưa có tài nguyên kèm</li>
|
||||
) : (
|
||||
(p.resources || []).map((r) => (
|
||||
<li key={r.id}>📎 {r.fileName}</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
{prepEditable && (
|
||||
<div style={{ display: 'flex', gap: '0.35rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
||||
+ Tài nguyên
|
||||
<input type="file" multiple hidden onChange={(e) => uploadResource(p.id, e)} />
|
||||
</label>
|
||||
<button type="button" className="btn btn-ghost btn-sm learning-btn-danger" onClick={() => apiExam.deletePaper(examId, p.id).then(load)}>
|
||||
Xóa gói
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{papers.length > 0 && students.length > 0 && prepEditable && (
|
||||
<button type="button" className="btn btn-secondary" style={{ width: '100%', marginTop: '0.65rem' }} onClick={assignRandomPackages}>
|
||||
Chia gói đề ngẫu nhiên
|
||||
</button>
|
||||
)}
|
||||
<div className="divider" style={{ opacity: 0.3 }} />
|
||||
<p className="config-card-desc">Gửi gói đề sau khi đẩy phòng thi và chia gói.</p>
|
||||
{(displayStatus === 'ready' || displayStatus === 'active') ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<button type="button" className="btn btn-primary" onClick={() => sendPapers(false)}>Gửi gói đề ngay</button>
|
||||
<input type="datetime-local" className="search-input" value={sendSchedule} onChange={(e) => setSendSchedule(e.target.value)} />
|
||||
<button type="button" className="btn btn-secondary" disabled={!sendSchedule} onClick={() => sendPapers(true)}>Hẹn giờ gửi</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="config-card-desc">Đẩy phòng thi trước khi gửi gói đề.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="right-panel workspace-content-panel">
|
||||
<div className="workspace-panel-toolbar">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary workspace-config-toggle ${configOpen ? 'active' : ''}`}
|
||||
onClick={() => setConfigOpen((v) => !v)}
|
||||
>
|
||||
{configOpen ? 'Ẩn cấu hình' : 'Cấu hình phòng thi'}
|
||||
</button>
|
||||
<div className="tab-btn-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'roster' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSubTab('roster')}
|
||||
>
|
||||
Sơ đồ sinh viên
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'detail' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSubTab('detail')}
|
||||
>
|
||||
Chi tiết thi
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'submissions' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSubTab('submissions')}
|
||||
>
|
||||
Bài nộp ({submissions.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{activeSubTab !== 'submissions' && (
|
||||
<div className="search-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Tìm sinh viên..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<span className="search-icon">🔍</span>
|
||||
</div>
|
||||
)}
|
||||
{prepEditable && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); }}
|
||||
>
|
||||
+ Thêm sinh viên
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} />
|
||||
</div>
|
||||
|
||||
<div className={`workspace-panel-body ${activeSubTab !== 'roster' ? 'workspace-panel-fill' : ''}`}>
|
||||
{activeSubTab === 'roster' ? (
|
||||
students.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
<div className="empty-state-icon">👤</div>
|
||||
<h2>Chưa có sinh viên</h2>
|
||||
<p>Thêm sinh viên vào phòng thi để bắt đầu giám sát.</p>
|
||||
{prepEditable && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); }}
|
||||
>
|
||||
+ Thêm sinh viên
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : filteredStudents.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
<div className="empty-state-icon">🔍</div>
|
||||
<h2>Không tìm thấy sinh viên</h2>
|
||||
<p>Hãy thử tìm kiếm với từ khóa khác.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="student-status-grid">
|
||||
{filteredStudents.map((s) => {
|
||||
const isOnline = onlineIds.includes(s.studentRkId);
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`student-status-card student-status-card-clickable ${isOnline ? 'online' : 'offline'}`}
|
||||
onClick={() => setSelectedStudent(toStudentItem(s))}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedStudent(toStudentItem(s)); }}
|
||||
>
|
||||
<StudentAvatar fullName={s.fullName} avatar={s.avatar} isOnline={isOnline} size={52} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{s.fullName}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.78rem', color: 'var(--accent)', fontFamily: 'monospace', fontWeight: 600 }}>
|
||||
{s.studentCode}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', marginTop: '0.4rem' }}>
|
||||
<span className={isOnline ? 'pulse-dot-online' : 'status-badge-offline'} />
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: isOnline ? 'var(--success)' : 'var(--text-muted)' }}>
|
||||
{isOnline ? 'ONLINE' : 'OFFLINE'}
|
||||
</span>
|
||||
</div>
|
||||
{(s.paperTitle || s.submitted) && (
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '0.35rem' }}>
|
||||
{s.paperTitle && <span>{s.paperTitle}</span>}
|
||||
{s.submitted && <span style={{ color: 'var(--success)', marginLeft: s.paperTitle ? '0.35rem' : 0 }}>· Đã nộp</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : activeSubTab === 'detail' ? (
|
||||
<div className="session-logs-panel">
|
||||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
||||
{students.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<h2>Chưa có sinh viên</h2>
|
||||
<p>Thêm sinh viên để xem chi tiết đề gán và trạng thái nộp bài.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sinh viên</th>
|
||||
<th>Mã SV</th>
|
||||
<th>Online</th>
|
||||
<th>Gói đề</th>
|
||||
<th>Gửi gói</th>
|
||||
<th>Nộp bài</th>
|
||||
<th style={{ textAlign: 'right' }}>Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredStudents.map((s) => {
|
||||
const isOnline = onlineIds.includes(s.studentRkId);
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{s.fullName}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{s.email}</div>
|
||||
</td>
|
||||
<td><code>{s.studentCode}</code></td>
|
||||
<td style={{ color: isOnline ? 'var(--success)' : 'var(--text-muted)', fontWeight: 700 }}>
|
||||
{isOnline ? 'Online' : 'Offline'}
|
||||
</td>
|
||||
<td>{s.paperTitle || '—'}</td>
|
||||
<td>{s.paperSentAt ? fmtTime(s.paperSentAt) : s.paperScheduledAt ? `Hẹn ${fmtTime(s.paperScheduledAt)}` : '—'}</td>
|
||||
<td>{s.submitted ? <span className="badge badge-success">Đã nộp</span> : '—'}</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={() => setSelectedStudent(toStudentItem(s))}>
|
||||
Giám sát
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary btn-sm" style={{ marginLeft: '0.35rem' }} onClick={() => openStudentChat(s)}>
|
||||
Nhắn tin
|
||||
</button>
|
||||
{prepEditable && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||
style={{ marginLeft: '0.35rem' }}
|
||||
onClick={() => apiExam.removeStudent(examId, s.studentRkId).then(load)}
|
||||
>
|
||||
Xóa
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-logs-panel">
|
||||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
||||
{submissions.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
<div className="empty-state-icon">📦</div>
|
||||
<h2>Chưa có bài nộp</h2>
|
||||
<p>Sinh viên nộp bài qua app Simple Care sẽ hiện tại đây.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sinh viên</th>
|
||||
<th>File</th>
|
||||
<th>Thời gian</th>
|
||||
<th style={{ textAlign: 'right' }}>Tải</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{submissions.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{s.fullName}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{s.studentCode}</div>
|
||||
</td>
|
||||
<td style={{ fontSize: '0.85rem' }}>{s.fileName}</td>
|
||||
<td style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{fmtTime(s.createdAt)}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => downloadSub(s.id, s.fileName)}>Tải</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedStudent && (
|
||||
<StudentDetailModal
|
||||
student={selectedStudent}
|
||||
isOnline={onlineIds.includes(selectedStudent.rkId)}
|
||||
onClose={() => setSelectedStudent(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{studentPickerOpen && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-container" style={{ maxWidth: '560px', width: '92%' }}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title">Thêm sinh viên</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
Tìm theo mã lớp, họ tên, mã sinh viên
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary close-btn" onClick={() => setStudentPickerOpen(false)}>Đóng</button>
|
||||
</div>
|
||||
<div style={{ padding: '0 0 1rem' }}>
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search"
|
||||
placeholder="Tìm sinh viên..."
|
||||
value={searchQ}
|
||||
onChange={(e) => setSearchQ(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ maxHeight: 'min(55vh, 420px)', overflowY: 'auto', marginTop: '1rem' }}>
|
||||
{searchQ.trim() === '' ? (
|
||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Gõ ít nhất vài ký tự để tìm.</p>
|
||||
) : searchHits.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Không tìm thấy sinh viên phù hợp.</p>
|
||||
) : (
|
||||
<ul className="learning-add-list">
|
||||
{searchHits.map((h) => {
|
||||
const added = enrolledIds.has(h.studentRkId);
|
||||
return (
|
||||
<li key={h.studentRkId} className="learning-add-item">
|
||||
<div>
|
||||
<div className="learning-class-code">{h.studentCode}</div>
|
||||
<div className="learning-class-meta">{h.fullName}</div>
|
||||
{(h.classCodes || h.email) && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.2rem' }}>
|
||||
{[h.classCodes, h.email].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={added} onClick={() => addStudent(h.studentRkId)}>
|
||||
{added ? 'Đã thêm' : 'Thêm'}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AppPoolModal
|
||||
open={appPoolOpen}
|
||||
onClose={() => setAppPoolOpen(false)}
|
||||
onSelect={addAppKeyword}
|
||||
allowedApps={allowedApps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
149
management/src/components/ExamsTab.tsx
Normal file
149
management/src/components/ExamsTab.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { apiExam, type ExamRoomItem } from '../api';
|
||||
import { openExam } from './NavHistoryBar';
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('vi-VN');
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(st: string) {
|
||||
if (st === 'draft') return { text: 'Tạm thời', cls: 'badge-muted' };
|
||||
if (st === 'ready') return { text: 'Sẵn sàng', cls: 'badge-info' };
|
||||
if (st === 'active') return { text: 'Đang thi', cls: 'badge-success' };
|
||||
if (st === 'cancelled') return { text: 'Đã hủy', cls: 'badge-warning' };
|
||||
if (st === 'ended') return { text: 'Đã kết thúc', cls: 'badge-muted' };
|
||||
return { text: st, cls: 'badge-muted' };
|
||||
}
|
||||
|
||||
function toLocalInput(iso?: string) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function localInputToISO(v: string) {
|
||||
if (!v) return '';
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
|
||||
export const ExamsTab: React.FC = () => {
|
||||
const [rooms, setRooms] = useState<ExamRoomItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [start, setStart] = useState('');
|
||||
const [end, setEnd] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiExam.list();
|
||||
setRooms(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const create = async () => {
|
||||
if (!name.trim() || !start || !end) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const room = await apiExam.create({
|
||||
name: name.trim(),
|
||||
startTime: localInputToISO(start),
|
||||
endTime: localInputToISO(end),
|
||||
});
|
||||
setShowCreate(false);
|
||||
setName('');
|
||||
openExam(room.id, room.name);
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Lỗi');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header page-header--row">
|
||||
<div>
|
||||
<h1 className="page-title">Phòng thi</h1>
|
||||
<p className="page-desc">Tạo phòng thi, chia đề ngẫu nhiên, gửi đề và thu bài từ sinh viên.</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>+ Tạo phòng thi</button>
|
||||
</header>
|
||||
|
||||
{showCreate && (
|
||||
<div className="card" style={{ padding: '1.25rem' }}>
|
||||
<h2 className="section-title">Phòng thi mới</h2>
|
||||
<div className="form-grid" style={{ maxWidth: 520 }}>
|
||||
<label className="login-field">
|
||||
<span>Tên phòng thi</span>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Thi cuối kỳ Java" />
|
||||
</label>
|
||||
<label className="login-field">
|
||||
<span>Bắt đầu</span>
|
||||
<input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} />
|
||||
</label>
|
||||
<label className="login-field">
|
||||
<span>Kết thúc</span>
|
||||
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>Tạo</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setShowCreate(false)}>Hủy</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card table-card">
|
||||
{loading ? (
|
||||
<div className="table-empty">Đang tải...</div>
|
||||
) : rooms.length === 0 ? (
|
||||
<div className="table-empty">Chưa có phòng thi nào.</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tên</th>
|
||||
<th>Thời gian</th>
|
||||
<th>SV / Đề</th>
|
||||
<th>Trạng thái</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((r) => {
|
||||
const st = statusLabel(r.displayStatus || r.status);
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td><strong>{r.name}</strong></td>
|
||||
<td style={{ fontSize: '0.82rem' }}>{fmtTime(r.startTime)} — {fmtTime(r.endTime)}</td>
|
||||
<td>{r.studentCount} SV · {r.paperCount} đề</td>
|
||||
<td><span className={`badge ${st.cls}`}>{st.text}</span></td>
|
||||
<td>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => openExam(r.id, r.name)}>Mở</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { toLocalInput, localInputToISO, fmtTime };
|
||||
@@ -29,7 +29,9 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
||||
|
||||
const handlePillClick = (entry: NavEntry) => {
|
||||
if (entry.kind === 'class' && entry.classId) {
|
||||
navigate(entry.tab, entry.classId, entry.label);
|
||||
navigate(entry.tab, entry.classId, entry.label, 'class');
|
||||
} else if (entry.kind === 'exam' && entry.examId) {
|
||||
navigate(entry.tab, entry.examId, entry.label, 'exam');
|
||||
} else {
|
||||
navigate(entry.tab);
|
||||
}
|
||||
@@ -39,7 +41,10 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
||||
if (entry.kind === 'class' && route.classId) {
|
||||
return entry.classId === route.classId;
|
||||
}
|
||||
return entry.kind === 'tab' && !route.classId && entry.tab === route.tab;
|
||||
if (entry.kind === 'exam' && route.examId) {
|
||||
return entry.examId === route.examId;
|
||||
}
|
||||
return entry.kind === 'tab' && !route.classId && !route.examId && entry.tab === route.tab;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -103,7 +108,11 @@ export function useRoute() {
|
||||
}
|
||||
|
||||
export function openClass(tab: TabId, classId: number, className: string) {
|
||||
navigate(tab, classId, className);
|
||||
navigate(tab, classId, className, 'class');
|
||||
}
|
||||
|
||||
export function openExam(examId: number, examName: string) {
|
||||
navigate('exams', examId, examName, 'exam');
|
||||
}
|
||||
|
||||
export function openTab(tab: TabId) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** Whitelist mặc định khi kích hoạt lớp — khớp tên tiến trình hoặc tiêu đề cửa sổ. */
|
||||
export const DEFAULT_ALLOWED_APPS = 'chrome,idea64,vscode,wails,simple_care,client';
|
||||
|
||||
export const DEFAULT_EXAM_ALLOWED_APPS = 'chrome,msedge,edge,acrobat,acrord32,foxit,wails,simple_care,client';
|
||||
|
||||
export const BASE_APP_SUGGESTIONS = ['chrome', 'vscode', 'idea64', 'wails', 'simple_care', 'client', 'cursor', 'goland', 'teams', 'zoom'];
|
||||
|
||||
@@ -797,6 +797,12 @@ input:checked + .slider:before {
|
||||
border: 1px solid rgba(187, 33, 38, 0.2);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: #fff8e6;
|
||||
color: #b45309;
|
||||
border: 1px solid rgba(180, 83, 9, 0.25);
|
||||
}
|
||||
|
||||
.courses-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -3729,3 +3735,514 @@ input:checked + .slider:before {
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
/* ── Exam workspace ── */
|
||||
.exam-workspace {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.exam-workspace-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exam-workspace-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.exam-workspace-alert {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.exam-workspace-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.exam-section-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.exam-section-grid--bottom {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.exam-section-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.exam-section {
|
||||
padding: 1.15rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exam-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.exam-section-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.exam-section-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.exam-section-toolbar .search-input {
|
||||
min-width: 140px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.exam-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.exam-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.exam-section-students {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.exam-students-panel {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.exam-students-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 1.15rem 1.25rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exam-online-stat {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.exam-online-count {
|
||||
color: var(--success);
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.exam-online-sep {
|
||||
margin: 0 0.2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.exam-students-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.exam-students-search {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.exam-students-search .search-input {
|
||||
width: 100%;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.exam-students-body {
|
||||
padding: 1rem 1.25rem 1.15rem;
|
||||
max-height: min(52vh, 520px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.exam-student-grid {
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
|
||||
.exam-student-card {
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.exam-student-card-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.exam-student-card-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.exam-student-card-code {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent);
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.exam-student-card-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.exam-student-online-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.exam-student-offline-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.exam-student-card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.exam-student-tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-light);
|
||||
color: var(--text-secondary);
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.exam-student-tag--muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.exam-student-tag--ok {
|
||||
background: var(--success-light);
|
||||
color: var(--success);
|
||||
border-color: rgba(13, 159, 110, 0.25);
|
||||
}
|
||||
|
||||
.exam-student-tag--sent {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
border-color: rgba(187, 33, 38, 0.2);
|
||||
}
|
||||
|
||||
.exam-student-card-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exam-student-chat-btn {
|
||||
font-size: 1rem !important;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.exam-students-table-scroll {
|
||||
max-height: min(46vh, 460px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.exam-students-table-scroll .data-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 0 var(--border-light);
|
||||
}
|
||||
|
||||
.exam-students-table-scroll .data-table th {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.exam-submissions-scroll .data-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.exam-student-row-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.exam-student-row-clickable:hover td {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.exam-row-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.exam-row-status.online {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.exam-row-status.offline {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.exam-student-row-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exam-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.exam-table-wrap--short {
|
||||
max-height: 240px;
|
||||
}
|
||||
|
||||
.exam-table-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 2rem 1rem !important;
|
||||
}
|
||||
|
||||
.exam-cell-muted {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.exam-empty-state {
|
||||
text-align: center;
|
||||
padding: 1.5rem 1rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.exam-empty-state span {
|
||||
font-size: 1.75rem;
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.exam-empty-state--compact {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.exam-paper-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.exam-paper-item {
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.exam-paper-item-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.exam-paper-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.exam-paper-add-res {
|
||||
margin-top: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.exam-send-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.exam-send-row .search-input {
|
||||
width: auto;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.exam-student-overlay {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.exam-student-modal {
|
||||
max-width: min(920px, 96vw);
|
||||
width: 100%;
|
||||
max-height: min(88vh, 760px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.exam-student-modal-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exam-student-modal-desc {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.exam-student-modal-body {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.exam-student-modal-search .search-input {
|
||||
width: 100%;
|
||||
padding: 0.7rem 2.5rem 0.7rem 0.85rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.exam-student-hits {
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
max-height: min(62vh, 560px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.exam-student-hits-empty {
|
||||
text-align: center;
|
||||
padding: 2.5rem 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.exam-student-hits-empty span {
|
||||
font-size: 2rem;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.exam-student-hit {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.exam-student-hit:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 10px rgba(187, 33, 38, 0.08);
|
||||
}
|
||||
|
||||
.exam-student-hit-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exam-student-hit-info strong {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.exam-student-hit-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.exam-student-hit-meta code {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.exam-student-hit {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.exam-student-hit .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'network' | 'email-domains' | 'profile';
|
||||
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'network' | 'email-domains' | 'profile';
|
||||
|
||||
export interface NavEntry {
|
||||
id: string;
|
||||
kind: 'tab' | 'class';
|
||||
kind: 'tab' | 'class' | 'exam';
|
||||
tab: TabId;
|
||||
classId?: number;
|
||||
examId?: number;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -14,6 +15,7 @@ export const TAB_LABELS: Record<TabId, string> = {
|
||||
classes: 'Lớp học',
|
||||
students: 'Sinh viên',
|
||||
learning: 'Giám sát & Lịch học',
|
||||
exams: 'Phòng thi',
|
||||
network: 'Quản lý mạng',
|
||||
'email-domains': 'Đuôi email',
|
||||
profile: 'Tài khoản của tôi',
|
||||
@@ -23,28 +25,34 @@ const HISTORY_KEY = 'sc_nav_history';
|
||||
const MAX_HISTORY = 10;
|
||||
|
||||
function isTabId(value: string | null): value is TabId {
|
||||
return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'network' || value === 'email-domains' || value === 'profile';
|
||||
return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'exams' || value === 'network' || value === 'email-domains' || value === 'profile';
|
||||
}
|
||||
|
||||
export function parseRoute(): { tab: TabId; classId: number | null } {
|
||||
export function parseRoute(): { tab: TabId; classId: number | null; examId: number | null } {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const tabParam = params.get('tab');
|
||||
let tab: TabId = isTabId(tabParam) ? tabParam : 'dashboard';
|
||||
if (tabParam === 'accounts') tab = 'email-domains';
|
||||
const classIdRaw = params.get('classId');
|
||||
const classId = classIdRaw ? Number(classIdRaw) : null;
|
||||
const examIdRaw = params.get('examId');
|
||||
const examId = examIdRaw ? Number(examIdRaw) : null;
|
||||
return {
|
||||
tab,
|
||||
classId: classId && !Number.isNaN(classId) ? classId : null,
|
||||
examId: examId && !Number.isNaN(examId) ? examId : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUrl(tab: TabId, classId?: number | null): string {
|
||||
export function buildUrl(tab: TabId, classId?: number | null, examId?: number | null): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('tab', tab);
|
||||
if (classId) {
|
||||
params.set('classId', String(classId));
|
||||
}
|
||||
if (examId) {
|
||||
params.set('examId', String(examId));
|
||||
}
|
||||
return `/?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -63,13 +71,15 @@ function writeHistory(entries: NavEntry[]) {
|
||||
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, MAX_HISTORY)));
|
||||
}
|
||||
|
||||
function entryKey(kind: NavEntry['kind'], tab: TabId, classId?: number) {
|
||||
return kind === 'class' ? `class:${classId}` : `tab:${tab}`;
|
||||
function entryKey(kind: NavEntry['kind'], tab: TabId, id?: number) {
|
||||
if (kind === 'class') return `class:${id}`;
|
||||
if (kind === 'exam') return `exam:${id}`;
|
||||
return `tab:${tab}`;
|
||||
}
|
||||
|
||||
export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
|
||||
const history = readHistory();
|
||||
const id = entryKey(entry.kind, entry.tab, entry.classId);
|
||||
const id = entryKey(entry.kind, entry.tab, entry.classId ?? entry.examId);
|
||||
const next: NavEntry = {
|
||||
...entry,
|
||||
id,
|
||||
@@ -79,12 +89,14 @@ export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
|
||||
writeHistory([next, ...filtered]);
|
||||
}
|
||||
|
||||
export function navigate(tab: TabId, classId?: number | null, className?: string) {
|
||||
const url = buildUrl(tab, classId);
|
||||
export function navigate(tab: TabId, id?: number | null, label?: string, kind: 'class' | 'exam' = 'class') {
|
||||
const url = kind === 'exam'
|
||||
? buildUrl(tab, null, id)
|
||||
: buildUrl(tab, id);
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
if (classId && className) {
|
||||
pushNav({ kind: 'class', tab, classId, label: className });
|
||||
if (id && label) {
|
||||
pushNav({ kind, tab, classId: kind === 'class' ? id : undefined, examId: kind === 'exam' ? id : undefined, label });
|
||||
} else {
|
||||
pushNav({ kind: 'tab', tab, label: TAB_LABELS[tab] });
|
||||
}
|
||||
@@ -97,7 +109,9 @@ export function goBack(fallbackTab: TabId = 'classes') {
|
||||
const current = parseRoute();
|
||||
const currentId = current.classId
|
||||
? entryKey('class', current.tab, current.classId)
|
||||
: entryKey('tab', current.tab);
|
||||
: current.examId
|
||||
? entryKey('exam', current.tab, current.examId)
|
||||
: entryKey('tab', current.tab);
|
||||
|
||||
const remaining = history.filter((h) => h.id !== currentId);
|
||||
writeHistory(remaining);
|
||||
@@ -106,7 +120,9 @@ export function goBack(fallbackTab: TabId = 'classes') {
|
||||
if (previous) {
|
||||
const url = previous.kind === 'class' && previous.classId
|
||||
? buildUrl(previous.tab, previous.classId)
|
||||
: buildUrl(previous.tab);
|
||||
: previous.kind === 'exam' && previous.examId
|
||||
? buildUrl(previous.tab, null, previous.examId)
|
||||
: buildUrl(previous.tab);
|
||||
window.history.pushState({}, '', url);
|
||||
} else {
|
||||
window.history.pushState({}, '', buildUrl(fallbackTab));
|
||||
|
||||
@@ -57,6 +57,11 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.EmailDomain{},
|
||||
&models.PasswordResetToken{},
|
||||
&models.ChatMessage{},
|
||||
&models.ExamRoom{},
|
||||
&models.ExamPaper{},
|
||||
&models.ExamPaperResource{},
|
||||
&models.ExamRoomStudent{},
|
||||
&models.ExamSubmission{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
52
server/internal/db/exam_active.go
Normal file
52
server/internal/db/exam_active.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"server/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ActiveExamInfo — phòng thi đang diễn ra cho sinh viên
|
||||
type ActiveExamInfo struct {
|
||||
Room models.ExamRoom
|
||||
Enrollment models.ExamRoomStudent
|
||||
Paper *models.ExamPaper
|
||||
}
|
||||
|
||||
// FindActiveExamForStudent trả về phòng thi nếu sinh viên đang trong khung giờ thi.
|
||||
func FindActiveExamForStudent(db *gorm.DB, studentRkID int64) *ActiveExamInfo {
|
||||
if studentRkID <= 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
var enrollment models.ExamRoomStudent
|
||||
err := db.Table("exam_room_students AS ers").
|
||||
Select("ers.*").
|
||||
Joins("JOIN exam_rooms er ON er.id = ers.exam_room_id").
|
||||
Where("ers.student_rk_id = ? AND er.status = ? AND er.start_time <= ? AND er.end_time >= ?",
|
||||
studentRkID, models.ExamStatusReady, now, now).
|
||||
Order("er.start_time DESC").
|
||||
First(&enrollment).Error
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var room models.ExamRoom
|
||||
if err := db.First(&room, enrollment.ExamRoomID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
info := &ActiveExamInfo{Room: room, Enrollment: enrollment}
|
||||
if enrollment.AssignedPaperID != nil {
|
||||
var paper models.ExamPaper
|
||||
if err := db.First(&paper, *enrollment.AssignedPaperID).Error; err == nil {
|
||||
info.Paper = &paper
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// IsStudentInActiveExam kiểm tra nhanh sinh viên có đang thi không.
|
||||
func IsStudentInActiveExam(db *gorm.DB, studentRkID int64) bool {
|
||||
return FindActiveExamForStudent(db, studentRkID) != nil
|
||||
}
|
||||
63
server/internal/db/exam_status.go
Normal file
63
server/internal/db/exam_status.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"server/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ExamDisplayStatus trạng thái hiển thị (tính từ DB status + thời gian).
|
||||
func ExamDisplayStatus(room models.ExamRoom, now time.Time) string {
|
||||
switch room.Status {
|
||||
case models.ExamStatusCancelled:
|
||||
return models.ExamStatusCancelled
|
||||
case models.ExamStatusEnded:
|
||||
return models.ExamStatusEnded
|
||||
case models.ExamStatusDraft:
|
||||
return models.ExamStatusDraft
|
||||
case models.ExamStatusReady:
|
||||
if now.After(room.EndTime) {
|
||||
return models.ExamStatusEnded
|
||||
}
|
||||
if !now.Before(room.StartTime) {
|
||||
return "active"
|
||||
}
|
||||
return models.ExamStatusReady
|
||||
default:
|
||||
return models.ExamStatusDraft
|
||||
}
|
||||
}
|
||||
|
||||
func ExamRoomEditable(room models.ExamRoom) bool {
|
||||
return room.Status == models.ExamStatusDraft
|
||||
}
|
||||
|
||||
// ExamRoomPrepEditable — chỉnh gói đề, sinh viên trước khi thi bắt đầu.
|
||||
func ExamRoomPrepEditable(room models.ExamRoom, now time.Time) bool {
|
||||
if room.Status == models.ExamStatusDraft {
|
||||
return true
|
||||
}
|
||||
return room.Status == models.ExamStatusReady && now.Before(room.StartTime)
|
||||
}
|
||||
|
||||
func ExamRoomCanUnpublish(room models.ExamRoom, now time.Time) bool {
|
||||
return room.Status == models.ExamStatusReady && now.Before(room.StartTime)
|
||||
}
|
||||
|
||||
func ExamRoomIsLive(room models.ExamRoom, now time.Time) bool {
|
||||
return ExamDisplayStatus(room, now) == "active"
|
||||
}
|
||||
|
||||
func ExamRoomCanCancel(room models.ExamRoom, now time.Time) bool {
|
||||
return ExamRoomIsLive(room, now)
|
||||
}
|
||||
|
||||
// ProcessExamRoomLifecycle đánh dấu phòng ready đã quá giờ kết thúc.
|
||||
func ProcessExamRoomLifecycle(db *gorm.DB) {
|
||||
now := time.Now()
|
||||
_ = db.Model(&models.ExamRoom{}).
|
||||
Where("status = ? AND end_time < ?", models.ExamStatusReady, now).
|
||||
Update("status", models.ExamStatusEnded).Error
|
||||
}
|
||||
1047
server/internal/handlers/handlers_exam.go
Normal file
1047
server/internal/handlers/handlers_exam.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -225,6 +225,24 @@ func GetAllowedAppsHandler(db *gorm.DB) fiber.Handler {
|
||||
|
||||
studentIDStr := c.Query("studentId", "0")
|
||||
studentID, _ := strconv.ParseInt(studentIDStr, 10, 64)
|
||||
|
||||
// Phòng thi ưu tiên hơn lịch học
|
||||
if studentID > 0 {
|
||||
if examInfo := internalDb.FindActiveExamForStudent(db, studentID); examInfo != nil {
|
||||
keywords := strings.TrimSpace(examInfo.Room.AllowedApps)
|
||||
if keywords == "" {
|
||||
keywords = defaultExamAllowedApps
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"classRkId": rkID,
|
||||
"keywords": keywords,
|
||||
"exit": false,
|
||||
"examMode": true,
|
||||
"examRoomId": examInfo.Room.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if studentID > 0 {
|
||||
resolvedClassID := internalDb.FindActiveClassForStudent(db, studentID)
|
||||
if resolvedClassID > 0 {
|
||||
|
||||
@@ -109,7 +109,7 @@ func GetStudentStatusHandler(db *gorm.DB) fiber.Handler {
|
||||
monitorLabel = fmt.Sprintf("Đang giám sát — Ca %d", currentPeriod)
|
||||
}
|
||||
}
|
||||
// monitorMode = "exam" — dành cho phòng thi (sẽ bổ sung sau)
|
||||
// monitorMode = "exam" — xử lý ở nhánh FindActiveExamForStudent phía trên
|
||||
|
||||
sessionMap := map[int]models.StudentSession{}
|
||||
attMap := map[int]models.AttendanceResult{}
|
||||
@@ -169,6 +169,47 @@ func GetStudentStatusHandler(db *gorm.DB) fiber.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// Ưu tiên phòng thi nếu sinh viên đang trong khung giờ thi
|
||||
if examInfo := internalDb.FindActiveExamForStudent(db, studentID); examInfo != nil {
|
||||
examKeywords := strings.TrimSpace(examInfo.Room.AllowedApps)
|
||||
if examKeywords == "" {
|
||||
examKeywords = defaultExamAllowedApps
|
||||
}
|
||||
paperSent := examInfo.Enrollment.PaperSentAt != nil
|
||||
var subCount int64
|
||||
_ = db.Model(&models.ExamSubmission{}).Where("exam_room_id = ? AND student_rk_id = ?", examInfo.Room.ID, studentID).Count(&subCount).Error
|
||||
examPayload := fiber.Map{
|
||||
"examRoomId": examInfo.Room.ID,
|
||||
"examName": examInfo.Room.Name,
|
||||
"startTime": examInfo.Room.StartTime,
|
||||
"endTime": examInfo.Room.EndTime,
|
||||
"quizUrl": examInfo.Room.QuizURL,
|
||||
"paperSent": paperSent,
|
||||
"submitted": subCount > 0,
|
||||
}
|
||||
if paperSent && examInfo.Paper != nil {
|
||||
examPayload["paperId"] = examInfo.Paper.ID
|
||||
examPayload["paperTitle"] = examInfo.Paper.Title
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"sessionDate": today,
|
||||
"monitorMode": "exam",
|
||||
"monitorLabel": fmt.Sprintf("Phòng thi: %s", examInfo.Room.Name),
|
||||
"classRkId": classID,
|
||||
"className": className,
|
||||
"classCode": classCode,
|
||||
"currentPeriod": 0,
|
||||
"currentCourseName": "",
|
||||
"currentShiftStart": "",
|
||||
"currentShiftEnd": "",
|
||||
"inScheduleNow": false,
|
||||
"blockerActive": examKeywords != "",
|
||||
"allowedKeywords": examKeywords,
|
||||
"shifts": shifts,
|
||||
"exam": examPayload,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"sessionDate": today,
|
||||
"monitorMode": monitorMode,
|
||||
|
||||
70
server/internal/models/exam.go
Normal file
70
server/internal/models/exam.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
ExamStatusDraft = "draft"
|
||||
ExamStatusReady = "ready"
|
||||
ExamStatusEnded = "ended"
|
||||
ExamStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// ExamRoom — phòng thi độc lập với lớp học
|
||||
type ExamRoom struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Name string `gorm:"column:name;size:255;not null" json:"name"`
|
||||
StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"`
|
||||
EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"`
|
||||
AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"`
|
||||
QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"`
|
||||
Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"`
|
||||
}
|
||||
|
||||
func (ExamRoom) TableName() string { return "exam_rooms" }
|
||||
|
||||
// ExamPaper — gói đề (1 PDF chính + tài nguyên kèm theo)
|
||||
type ExamPaper struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ExamRoomID uint `gorm:"column:exam_room_id;not null;index" json:"examRoomId"`
|
||||
Title string `gorm:"column:title;size:128;not null" json:"title"`
|
||||
PdfPath string `gorm:"column:pdf_path;size:512;not null" json:"pdfPath"`
|
||||
SortOrder int `gorm:"column:sort_order;default:0" json:"sortOrder"`
|
||||
}
|
||||
|
||||
func (ExamPaper) TableName() string { return "exam_papers" }
|
||||
|
||||
// ExamPaperResource — file tài nguyên kèm đề (đuôi bất kỳ)
|
||||
type ExamPaperResource struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ExamPaperID uint `gorm:"column:exam_paper_id;not null;index" json:"examPaperId"`
|
||||
FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"`
|
||||
FileName string `gorm:"column:file_name;size:255;not null" json:"fileName"`
|
||||
}
|
||||
|
||||
func (ExamPaperResource) TableName() string { return "exam_paper_resources" }
|
||||
|
||||
// ExamRoomStudent — sinh viên trong phòng thi
|
||||
type ExamRoomStudent struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ExamRoomID uint `gorm:"column:exam_room_id;not null;uniqueIndex:idx_exam_room_student,priority:1" json:"examRoomId"`
|
||||
StudentRkID int64 `gorm:"column:student_rk_id;not null;uniqueIndex:idx_exam_room_student,priority:2" json:"studentRkId"`
|
||||
AssignedPaperID *uint `gorm:"column:assigned_paper_id;index" json:"assignedPaperId,omitempty"`
|
||||
PaperSentAt *time.Time `gorm:"column:paper_sent_at" json:"paperSentAt,omitempty"`
|
||||
PaperScheduledAt *time.Time `gorm:"column:paper_scheduled_at;index" json:"paperScheduledAt,omitempty"`
|
||||
}
|
||||
|
||||
func (ExamRoomStudent) TableName() string { return "exam_room_students" }
|
||||
|
||||
// ExamSubmission — bài nộp (zip folder) của sinh viên
|
||||
type ExamSubmission struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExamRoomID uint `gorm:"column:exam_room_id;not null;index" json:"examRoomId"`
|
||||
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
|
||||
FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"`
|
||||
FileName string `gorm:"column:file_name;size:255;not null" json:"fileName"`
|
||||
}
|
||||
|
||||
func (ExamSubmission) TableName() string { return "exam_submissions" }
|
||||
@@ -47,6 +47,16 @@ func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *WsHub) PushExamToStudent(studentRkID int64, data map[string]any) {
|
||||
h.mu.RLock()
|
||||
client, ok := h.students[studentRkID]
|
||||
h.mu.RUnlock()
|
||||
if !ok || client == nil {
|
||||
return
|
||||
}
|
||||
_ = client.Conn.WriteJSON(SocketMsg{Event: "exam:paper-sent", Data: data})
|
||||
}
|
||||
|
||||
func (h *WsHub) PushChatToStudent(studentRkID int64, data map[string]any) {
|
||||
h.mu.RLock()
|
||||
client, ok := h.students[studentRkID]
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"server/internal/db"
|
||||
"server/internal/handlers"
|
||||
@@ -52,7 +53,8 @@ func main() {
|
||||
mailer := mail.LoadConfigFromEnv()
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Simple Care Sync Backend",
|
||||
AppName: "Simple Care Sync Backend",
|
||||
BodyLimit: 100 * 1024 * 1024, // 100MB — upload PDF gói đề
|
||||
})
|
||||
|
||||
app.Use(cors.New(cors.Config{
|
||||
@@ -89,7 +91,10 @@ func main() {
|
||||
api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB))
|
||||
api.Get("/student/chat/messages", handlers.StudentListChatHandler(gormDB))
|
||||
api.Get("/student/chat/conversations", handlers.StudentListChatConversationsHandler(gormDB))
|
||||
api.Post("/student/chat/messages", handlers.StudentSendChatHandler(gormDB))
|
||||
api.Get("/student/exam", handlers.GetStudentExamHandler(gormDB))
|
||||
api.Get("/student/exam/paper-files", handlers.GetStudentExamPaperFilesHandler(gormDB))
|
||||
api.Get("/student/exam/download", handlers.DownloadStudentExamFileHandler(gormDB))
|
||||
api.Post("/student/exam/submit", handlers.SubmitStudentExamHandler(gormDB))
|
||||
|
||||
// Health check (public)
|
||||
api.Get("/health", func(c *fiber.Ctx) error {
|
||||
@@ -152,6 +157,35 @@ func main() {
|
||||
staff.Delete("/admin/email-domains/:id", handlers.DeleteEmailDomainHandler(gormDB))
|
||||
staff.Get("/admin/staff", handlers.ListStaffHandler(gormDB))
|
||||
|
||||
// Phòng thi
|
||||
staff.Get("/exam-rooms", handlers.ListExamRoomsHandler(gormDB))
|
||||
staff.Post("/exam-rooms", handlers.CreateExamRoomHandler(gormDB))
|
||||
staff.Get("/exam-rooms/search-students", handlers.SearchExamStudentsHandler(gormDB))
|
||||
staff.Get("/exam-rooms/:id", handlers.GetExamRoomHandler(gormDB))
|
||||
staff.Get("/exam-rooms/:id/online-students", handlers.GetExamRoomOnlineStudentsHandler(gormDB))
|
||||
staff.Patch("/exam-rooms/:id", handlers.UpdateExamRoomHandler(gormDB))
|
||||
staff.Delete("/exam-rooms/:id", handlers.DeleteExamRoomHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/students", handlers.AddExamRoomStudentsHandler(gormDB))
|
||||
staff.Delete("/exam-rooms/:id/students/:studentRkId", handlers.RemoveExamRoomStudentHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/papers", handlers.UploadExamPaperHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/papers/:paperId/resources", handlers.UploadExamPaperResourceHandler(gormDB))
|
||||
staff.Delete("/exam-rooms/:id/papers/:paperId", handlers.DeleteExamPaperHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/publish", handlers.PublishExamRoomHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/assign-random", handlers.RandomAssignExamPapersHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/send-papers", handlers.SendExamPapersHandler(gormDB))
|
||||
staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB))
|
||||
staff.Get("/exam-rooms/:id/submissions/:subId/download", handlers.DownloadExamSubmissionHandler(gormDB))
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
handlers.ProcessScheduledExamSends(gormDB)
|
||||
}
|
||||
}()
|
||||
|
||||
port := getEnv("PORT", "8080")
|
||||
log.Printf("Server starting on port %s...", port)
|
||||
if err := app.Listen(":" + port); err != nil {
|
||||
|
||||
BIN
server/uploads/exams/1/papers/2/main.pdf
Normal file
BIN
server/uploads/exams/1/papers/2/main.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user