fix lam bai

This commit is contained in:
2026-06-30 12:37:55 +07:00
parent 0abf48ea2d
commit 795726aea5
8 changed files with 407 additions and 51 deletions

View File

@@ -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 = `<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Segoe UI,system-ui,sans-serif;background:#1e293b;color:#f8fafc;height:100vh;display:flex;flex-direction:column}
.bar{display:flex;align-items:center;gap:12px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0}
.bar button{background:#7c3aed;color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:14px;cursor:pointer;font-weight:600}
.bar button:hover{background:#6d28d9}
.bar span{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.frame-wrap{flex:1;min-height:0;background:#fff}
iframe{width:100%%;height:100%%;border:0;display:block}
</style>
</head>
<body>
<div class="bar">
<button type="button" onclick="goBack()">← Quay lại</button>
<span>%s</span>
</div>
<div class="frame-wrap">
<iframe src="%s" title="exam-content"></iframe>
</div>
<script>
function goBack(){
fetch('http://127.0.0.1:34115/exam-close').catch(function(){});
}
</script>
</body>
</html>`
// 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) {

View File

@@ -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);

View File

@@ -178,6 +178,40 @@ function renderShiftsTable(shifts) {
`;
}
let examPaperFiles = null;
function renderExamResources() {
if (!examPaperFiles?.resources?.length) return '';
const rows = examPaperFiles.resources.map((r) => `
<div class="exam-resource-row">
<span class="exam-resource-name" title="${escapeHtml(r.fileName || '')}">${escapeHtml(r.fileName || 'Tài nguyên')}</span>
<div class="exam-resource-btns">
<button type="button" class="btn btn-secondary btn-sm btn-exam-res-view" data-id="${r.id}">Xem</button>
<button type="button" class="btn btn-secondary btn-sm btn-exam-res-dl" data-id="${r.id}">Tải về</button>
</div>
</div>
`).join('');
return `
<div class="exam-resources">
<div class="exam-resources-title">Tài nguyên kèm đề</div>
${rows}
</div>
`;
}
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() {
<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>
<p class="exam-panel-desc">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.</p>
<div class="exam-panel-actions">
${paperBtn}
${quizBtn}
${submitBtn}
</div>
${renderExamResources()}
</div>
`;
}
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function sendStudentChat() {

View File

@@ -5,12 +5,20 @@ export function CheckLoginStatus():Promise<boolean>;
export function ClearChatUnread():Promise<void>;
export function DownloadExamResource(arg1:number):Promise<string>;
export function GetChatConversations():Promise<Array<Record<string, any>>>;
export function GetChatMessages(arg1:number):Promise<Array<Record<string, any>>>;
export function GetChatUnread():Promise<number>;
export function GetExamPaperFiles():Promise<Record<string, any>>;
export function GetExamPaperViewURL():Promise<string>;
export function GetExamQuizViewURL():Promise<string>;
export function GetStats():Promise<Record<string, any>>;
export function GetStudentInfo():Promise<Record<string, any>>;
@@ -23,6 +31,8 @@ export function OpenExamPaper():Promise<void>;
export function OpenExamQuiz():Promise<void>;
export function OpenExamResource(arg1:number):Promise<void>;
export function SendChatMessage(arg1:string):Promise<void>;
export function SendWebcamFrame(arg1:string):Promise<void>;

View File

@@ -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);
}