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 = () => (