diff --git a/client/app.go b/client/app.go index 773d125..f84685d 100644 --- a/client/app.go +++ b/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 + }) +} diff --git a/client/frontend/src/app.css b/client/frontend/src/app.css index 7295705..e783546 100644 --- a/client/frontend/src/app.css +++ b/client/frontend/src/app.css @@ -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); diff --git a/client/frontend/src/main.js b/client/frontend/src/main.js index 8f64d28..3ad1ffc 100644 --- a/client/frontend/src/main.js +++ b/client/frontend/src/main.js @@ -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 + ? `` + : `Chờ giảng viên gửi đề...`; + const quizBtn = ex.quizUrl + ? `` + : ''; + const submitBtn = ex.submitted + ? `✓ Đã nộp bài tự luận` + : ``; + return ` +
+
+
📝 ${ex.examName || 'Phòng thi'}
+
+

Bạn đang trong giờ thi. Làm bài theo hướng dẫn của giảng viên.

+
+ ${paperBtn} + ${quizBtn} + ${submitBtn} +
+
+ `; +} + +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() { +
${renderExamPanel()}
+
📡
@@ -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); diff --git a/client/frontend/wailsjs/go/main/App.d.ts b/client/frontend/wailsjs/go/main/App.d.ts index 59d3f96..bc69f32 100644 --- a/client/frontend/wailsjs/go/main/App.d.ts +++ b/client/frontend/wailsjs/go/main/App.d.ts @@ -19,8 +19,14 @@ export function Logout():Promise; export function NavigateToLogin():Promise; +export function OpenExamPaper():Promise; + +export function OpenExamQuiz():Promise; + export function SendChatMessage(arg1:string):Promise; export function SendWebcamFrame(arg1:string):Promise; +export function SubmitExamWork():Promise; + export function UnlockChatAudio():Promise; diff --git a/client/frontend/wailsjs/go/main/App.js b/client/frontend/wailsjs/go/main/App.js index 9febf7c..fb104af 100644 --- a/client/frontend/wailsjs/go/main/App.js +++ b/client/frontend/wailsjs/go/main/App.js @@ -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'](); } diff --git a/management/src/App.tsx b/management/src/App.tsx index a3a7a8a..19653b9 100644 --- a/management/src/App.tsx +++ b/management/src/App.tsx @@ -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 = () => ( ); +const IconExam = () => ( + + + + + +); + const IconNetwork = () => ( @@ -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 ( <>
Tổng quan
  • +
  • +
  • + + )} + {canUnpublish && ( + + )} + {canCancel && ( + + )} +
  • +
    + +
    +
    + Online / Sĩ số +
    +
    + {onlineCount} + / {students.length} SV +
    +
    + + + +
    + {configOpen && ( +
    setConfigOpen(false)} aria-hidden /> + )} + +