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']();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user