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