diff --git a/client/app.go b/client/app.go index f84685d..03d6860 100644 --- a/client/app.go +++ b/client/app.go @@ -10,12 +10,13 @@ import ( "encoding/json" "errors" "fmt" + "html" "io" "log" "mime/multipart" "net/http" + "net/url" "os" - "os/exec" "path/filepath" "strings" "sync" @@ -304,12 +305,68 @@ func (a *App) startLocalServer() { Handler: mux, } + mux.HandleFunc("/exam-view", func(w http.ResponseWriter, r *http.Request) { + target := strings.TrimSpace(r.URL.Query().Get("url")) + title := strings.TrimSpace(r.URL.Query().Get("title")) + if target == "" { + http.Error(w, "missing url", http.StatusBadRequest) + return + } + if title == "" { + title = "Xem trong Simple Care" + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprintf(w, examViewHTML, html.EscapeString(title), html.EscapeString(title), html.EscapeString(target)) + }) + mux.HandleFunc("/exam-close", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + if r.Method == "OPTIONS" { + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + return + } + go runtime.WindowReloadApp(a.ctx) + w.Write([]byte("ok")) + }) + go func() { _ = server.ListenAndServe() }() log.Println("[LOCALSERVER] Callback server started on http://127.0.0.1:34115") } +const examViewHTML = ` + + + + +%s + + + +
+ +%s +
+
+ +
+ + +` + // loadSession nạp session từ file cục bộ func (a *App) loadSession() { a.mu.Lock() @@ -1215,72 +1272,210 @@ func (a *App) SendChatMessage(body string) error { return nil } -func (a *App) OpenExamQuiz() error { +func (a *App) examStudentContext() (int64, *StudentExamSnapshot, error) { a.mu.Lock() - var url string - if a.dashboard.Exam != nil { - url = strings.TrimSpace(a.dashboard.Exam.QuizURL) + defer a.mu.Unlock() + if a.student == nil { + return 0, nil, errors.New("chưa đăng nhập") } - a.mu.Unlock() - if url == "" { - return errors.New("Phòng thi chưa cấu hình link trắc nghiệm") + if a.dashboard.Exam == nil { + return 0, nil, errors.New("không trong giờ thi") } - return openPrivateBrowser(url) + return a.student.StudentID, a.dashboard.Exam, nil } -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}, +func (a *App) openExamWebView(targetURL, title string) error { + targetURL = strings.TrimSpace(targetURL) + if targetURL == "" { + return errors.New("không có nội dung để mở") } - 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") + wrapper := fmt.Sprintf( + "http://127.0.0.1:34115/exam-view?url=%s&title=%s", + url.QueryEscape(targetURL), + url.QueryEscape(title), + ) + runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper)) + return nil } -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) +func (a *App) GetExamPaperViewURL() (string, error) { + studentID, exam, err := a.examStudentContext() if err != nil { - return err + return "", err + } + if !exam.PaperSent { + return "", errors.New("Giảng viên chưa gửi đề") + } + return fmt.Sprintf("http://127.0.0.1:8080/api/student/exam/download?studentRkId=%d&kind=pdf", studentID), nil +} + +func (a *App) GetExamQuizViewURL() (string, error) { + _, exam, err := a.examStudentContext() + if err != nil { + return "", err + } + quizURL := strings.TrimSpace(exam.QuizURL) + if quizURL == "" { + return "", errors.New("Phòng thi chưa cấu hình link trắc nghiệm") + } + return quizURL, nil +} + +func (a *App) GetExamPaperFiles() (map[string]any, error) { + studentID, exam, err := a.examStudentContext() + if err != nil { + return nil, err + } + if !exam.PaperSent { + return nil, errors.New("Giảng viên chưa gửi đề") + } + apiURL := fmt.Sprintf("http://127.0.0.1:8080/api/student/exam/paper-files?studentRkId=%d", studentID) + resp, err := http.Get(apiURL) + if err != nil { + return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return errors.New("không tải được đề") + return nil, errors.New("không tải được danh sách tài nguyên") } - 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) + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + base := "http://127.0.0.1:8080" + if pdf, ok := payload["pdfUrl"].(string); ok && pdf != "" && !strings.HasPrefix(pdf, "http") { + payload["pdfUrl"] = base + pdf + } + if raw, ok := payload["resources"].([]any); ok { + for i, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + if u, ok := m["url"].(string); ok && u != "" && !strings.HasPrefix(u, "http") { + m["url"] = base + u + } + raw[i] = m + } + payload["resources"] = raw + } + return payload, nil +} + +func (a *App) OpenExamPaper() error { + viewURL, err := a.GetExamPaperViewURL() if err != nil { return err } - if _, err := io.Copy(f, resp.Body); err != nil { - f.Close() + title := "Đề thi" + a.mu.Lock() + if a.dashboard.Exam != nil && strings.TrimSpace(a.dashboard.Exam.PaperTitle) != "" { + title = strings.TrimSpace(a.dashboard.Exam.PaperTitle) + } + a.mu.Unlock() + return a.openExamWebView(viewURL, title) +} + +func (a *App) OpenExamQuiz() error { + viewURL, err := a.GetExamQuizViewURL() + if err != nil { return err } + return a.openExamWebView(viewURL, "Làm trắc nghiệm") +} + +func (a *App) OpenExamResource(fileID uint) error { + studentID, _, err := a.examStudentContext() + if err != nil { + return err + } + if fileID == 0 { + return errors.New("tài nguyên không hợp lệ") + } + viewURL := fmt.Sprintf( + "http://127.0.0.1:8080/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d", + studentID, fileID, + ) + title := "Tài nguyên đề thi" + files, err := a.GetExamPaperFiles() + if err == nil { + if raw, ok := files["resources"].([]any); ok { + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + id, _ := m["id"].(float64) + if uint(id) == fileID { + if name, ok := m["fileName"].(string); ok && name != "" { + title = name + } + break + } + } + } + } + return a.openExamWebView(viewURL, title) +} + +func (a *App) DownloadExamResource(fileID uint) (string, error) { + studentID, exam, err := a.examStudentContext() + if err != nil { + return "", err + } + if fileID == 0 { + return "", errors.New("tài nguyên không hợp lệ") + } + fileName := fmt.Sprintf("resource_%d", fileID) + files, err := a.GetExamPaperFiles() + if err == nil { + if raw, ok := files["resources"].([]any); ok { + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + id, _ := m["id"].(float64) + if uint(id) == fileID { + if name, ok := m["fileName"].(string); ok && name != "" { + fileName = filepath.Base(name) + } + break + } + } + } + } + dlURL := fmt.Sprintf( + "http://127.0.0.1:8080/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d", + studentID, fileID, + ) + 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 tài nguyên") + } + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + dir := filepath.Join(configDir, "SimpleCare", "ExamResources", fmt.Sprintf("room_%d", exam.ExamRoomID)) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + dest := filepath.Join(dir, fileName) + 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() + return dest, nil } func (a *App) SubmitExamWork() (string, error) { diff --git a/client/frontend/src/app.css b/client/frontend/src/app.css index e783546..ad00a4a 100644 --- a/client/frontend/src/app.css +++ b/client/frontend/src/app.css @@ -415,6 +415,55 @@ body { color: var(--text-muted); } +.exam-resources { + margin-top: 0.9rem; + padding-top: 0.85rem; + border-top: 1px dashed #c4b5fd; +} + +.exam-resources-title { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #6d28d9; + margin-bottom: 0.5rem; +} + +.exam-resource-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + padding: 0.45rem 0; + border-bottom: 1px solid #ede9fe; +} + +.exam-resource-row:last-child { + border-bottom: none; +} + +.exam-resource-name { + font-size: 0.84rem; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.exam-resource-btns { + display: flex; + gap: 0.35rem; + flex-shrink: 0; +} + +.btn-sm { + padding: 0.35rem 0.65rem; + font-size: 0.78rem; +} + .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 3ad1ffc..2953ac3 100644 --- a/client/frontend/src/main.js +++ b/client/frontend/src/main.js @@ -178,6 +178,40 @@ function renderShiftsTable(shifts) { `; } +let examPaperFiles = null; + +function renderExamResources() { + if (!examPaperFiles?.resources?.length) return ''; + const rows = examPaperFiles.resources.map((r) => ` +
+ ${escapeHtml(r.fileName || 'Tài nguyên')} +
+ + +
+
+ `).join(''); + return ` +
+
Tài nguyên kèm đề
+ ${rows} +
+ `; +} + +async function loadExamPaperFiles() { + const ex = stats.exam; + if (!ex || stats.monitorMode !== 'exam' || !ex.paperSent) { + examPaperFiles = null; + return; + } + try { + examPaperFiles = await window.go.main.App.GetExamPaperFiles(); + } catch { + examPaperFiles = null; + } +} + function renderExamPanel() { const ex = stats.exam; if (!ex || stats.monitorMode !== 'exam') return ''; @@ -195,12 +229,13 @@ function renderExamPanel() {
📝 ${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.

+

Bạn đang trong giờ thi. Đề, link thi và tài nguyên mở trong app — không dùng trình duyệt ngoài.

${paperBtn} ${quizBtn} ${submitBtn}
+ ${renderExamResources()} `; } @@ -221,16 +256,35 @@ function wireExamPanel() { alert(`Đã nộp bài: ${name}`); const fresh = await window.go.main.App.GetStats(); stats = { ...stats, ...fresh }; + await loadExamPaperFiles(); updateExamPanel(); } catch (e) { alert(e?.message || e || 'Nộp bài thất bại'); } }); + document.querySelectorAll('.btn-exam-res-view').forEach((btn) => { + btn.addEventListener('click', () => { + const id = Number(btn.getAttribute('data-id')); + window.go.main.App.OpenExamResource(id).catch((e) => alert(e?.message || e)); + }); + }); + document.querySelectorAll('.btn-exam-res-dl').forEach((btn) => { + btn.addEventListener('click', async () => { + const id = Number(btn.getAttribute('data-id')); + try { + const path = await window.go.main.App.DownloadExamResource(id); + alert(`Đã tải về:\n${path}`); + } catch (e) { + alert(e?.message || e || 'Tải thất bại'); + } + }); + }); } -function updateExamPanel() { +async function updateExamPanel() { const host = document.getElementById('exam-panel-host'); if (!host) return; + await loadExamPaperFiles(); host.innerHTML = renderExamPanel(); wireExamPanel(); } @@ -485,7 +539,7 @@ function renderChatMessages(silent = false) { } function escapeHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>'); + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } async function sendStudentChat() { diff --git a/client/frontend/wailsjs/go/main/App.d.ts b/client/frontend/wailsjs/go/main/App.d.ts index bc69f32..2988c00 100644 --- a/client/frontend/wailsjs/go/main/App.d.ts +++ b/client/frontend/wailsjs/go/main/App.d.ts @@ -5,12 +5,20 @@ export function CheckLoginStatus():Promise; export function ClearChatUnread():Promise; +export function DownloadExamResource(arg1:number):Promise; + export function GetChatConversations():Promise>>; export function GetChatMessages(arg1:number):Promise>>; export function GetChatUnread():Promise; +export function GetExamPaperFiles():Promise>; + +export function GetExamPaperViewURL():Promise; + +export function GetExamQuizViewURL():Promise; + export function GetStats():Promise>; export function GetStudentInfo():Promise>; @@ -23,6 +31,8 @@ export function OpenExamPaper():Promise; export function OpenExamQuiz():Promise; +export function OpenExamResource(arg1:number):Promise; + export function SendChatMessage(arg1:string):Promise; export function SendWebcamFrame(arg1:string):Promise; diff --git a/client/frontend/wailsjs/go/main/App.js b/client/frontend/wailsjs/go/main/App.js index fb104af..e0d6634 100644 --- a/client/frontend/wailsjs/go/main/App.js +++ b/client/frontend/wailsjs/go/main/App.js @@ -10,6 +10,10 @@ export function ClearChatUnread() { return window['go']['main']['App']['ClearChatUnread'](); } +export function DownloadExamResource(arg1) { + return window['go']['main']['App']['DownloadExamResource'](arg1); +} + export function GetChatConversations() { return window['go']['main']['App']['GetChatConversations'](); } @@ -22,6 +26,18 @@ export function GetChatUnread() { return window['go']['main']['App']['GetChatUnread'](); } +export function GetExamPaperFiles() { + return window['go']['main']['App']['GetExamPaperFiles'](); +} + +export function GetExamPaperViewURL() { + return window['go']['main']['App']['GetExamPaperViewURL'](); +} + +export function GetExamQuizViewURL() { + return window['go']['main']['App']['GetExamQuizViewURL'](); +} + export function GetStats() { return window['go']['main']['App']['GetStats'](); } @@ -46,6 +62,10 @@ export function OpenExamQuiz() { return window['go']['main']['App']['OpenExamQuiz'](); } +export function OpenExamResource(arg1) { + return window['go']['main']['App']['OpenExamResource'](arg1); +} + export function SendChatMessage(arg1) { return window['go']['main']['App']['SendChatMessage'](arg1); } diff --git a/server/uploads/exams/2/papers/3/main.pdf b/server/uploads/exams/2/papers/3/main.pdf new file mode 100644 index 0000000..cffc71b Binary files /dev/null and b/server/uploads/exams/2/papers/3/main.pdf differ diff --git a/server/uploads/exams/2/papers/3/resources/gunny-admin-profiles-20260629-150338.json b/server/uploads/exams/2/papers/3/resources/gunny-admin-profiles-20260629-150338.json new file mode 100644 index 0000000..2bfc0ce --- /dev/null +++ b/server/uploads/exams/2/papers/3/resources/gunny-admin-profiles-20260629-150338.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "exportedAt": "2026-06-29T08:03:38.6066807Z", + "profiles": [ + { + "id": "4e08fbd2-15ff-4f37-ba79-44d9f633a248", + "name": "Gà Chill", + "createdAt": "2026-06-27T03:50:45.9698419Z", + "updatedAt": "2026-06-29T01:46:39.7112252Z", + "sshHost": "103.245.236.191", + "sshPort": 22, + "sshUser": "Administrator", + "sshPassword": "pass@123123aA@", + "remoteDbHost": "127.0.0.1", + "remoteDbPort": 1433, + "dbUser": "sa", + "dbPassword": "123123aA@", + "dbTank41": "Db_Tank41", + "dbTank": "Db_Tank", + "dbMember": "Db_Member", + "remoteRequestHost": "127.0.0.1", + "remoteRequestPort": 81, + "resourceUrl": "https://resource.gunnychill.net", + "deployRoot": "C:\\Gunny", + "devRootXml": "E:\\gunny3" + } + ] +} \ No newline at end of file diff --git a/server/uploads/exams/2/papers/3/resources/horse_icon.png b/server/uploads/exams/2/papers/3/resources/horse_icon.png new file mode 100644 index 0000000..d8ee5a8 Binary files /dev/null and b/server/uploads/exams/2/papers/3/resources/horse_icon.png differ