Compare commits

..

10 Commits

Author SHA1 Message Date
1d54acccbc fix add more student 2026-06-30 16:15:30 +07:00
3e53cb6664 fix ui 2026-06-30 16:11:12 +07:00
74b540cac2 clean ui 2026-06-30 15:53:01 +07:00
595aebd8a5 add view de 2026-06-30 15:34:59 +07:00
840ddcdce9 add github 2026-06-30 15:20:26 +07:00
38e22b3390 fix up git 2026-06-30 15:06:12 +07:00
11e5a76977 git feature 2026-06-30 14:46:19 +07:00
251c1b7573 tam thoi 2026-06-30 13:25:48 +07:00
1c2bfde005 tam 2026-06-30 13:08:18 +07:00
795726aea5 fix lam bai 2026-06-30 12:37:55 +07:00
68 changed files with 8069 additions and 441 deletions

View File

@@ -8,12 +8,15 @@ import (
"crypto/cipher" "crypto/cipher"
"crypto/rand" "crypto/rand"
"encoding/json" "encoding/json"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"html"
"io" "io"
"log" "log"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -84,9 +87,10 @@ type App struct {
ctx context.Context ctx context.Context
student *StudentData student *StudentData
mu sync.Mutex mu sync.Mutex
sessionPath string sessionPath string
statsPath string statsPath string
wsConn *websocket.Conn webviewDataPath string
wsConn *websocket.Conn
wsConnected bool wsConnected bool
wifiSSID string wifiSSID string
wifiBSSID string wifiBSSID string
@@ -109,6 +113,7 @@ type App struct {
monitoringTornDown bool monitoringTornDown bool
chatUnread int chatUnread int
replyStaffID uint replyStaffID uint
fetchAppsMu sync.Mutex
} }
type LocalStats struct { type LocalStats struct {
@@ -164,11 +169,14 @@ func NewApp() *App {
configDir = filepath.Dir(exePath) configDir = filepath.Dir(exePath)
} }
appDir := filepath.Join(configDir, "SimpleCare") appDir := filepath.Join(configDir, "SimpleCare")
webviewDir := filepath.Join(appDir, "WebView2")
_ = os.MkdirAll(appDir, 0755) _ = os.MkdirAll(appDir, 0755)
_ = os.MkdirAll(webviewDir, 0755)
return &App{ return &App{
sessionPath: filepath.Join(appDir, "student_session.json"), sessionPath: filepath.Join(appDir, "student_session.json"),
statsPath: filepath.Join(appDir, "student_stats.json"), statsPath: filepath.Join(appDir, "student_stats.json"),
webviewDataPath: webviewDir,
lastSyncTime: time.Now(), lastSyncTime: time.Now(),
expectingLogin: false, expectingLogin: false,
} }
@@ -292,6 +300,7 @@ func (a *App) startLocalServer() {
go a.refreshStudentData() go a.refreshStudentData()
// Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets // Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets
guard.SuppressFor(5 * time.Second)
runtime.WindowReloadApp(a.ctx) runtime.WindowReloadApp(a.ctx)
w.Write([]byte("ok")) w.Write([]byte("ok"))
} else { } else {
@@ -304,12 +313,71 @@ func (a *App) startLocalServer() {
Handler: mux, 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 func() {
guard.SuppressFor(5 * time.Second)
runtime.WindowReloadApp(a.ctx)
}()
w.Write([]byte("ok"))
})
go func() { go func() {
_ = server.ListenAndServe() _ = server.ListenAndServe()
}() }()
log.Println("[LOCALSERVER] Callback server started on http://127.0.0.1:34115") 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ộ // loadSession nạp session từ file cục bộ
func (a *App) loadSession() { func (a *App) loadSession() {
a.mu.Lock() a.mu.Lock()
@@ -432,6 +500,7 @@ func (a *App) NavigateToLogin() {
a.mu.Lock() a.mu.Lock()
a.expectingLogin = true a.expectingLogin = true
a.mu.Unlock() a.mu.Unlock()
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'") runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'")
} }
@@ -456,6 +525,7 @@ func (a *App) Logout() {
a.disconnectWS() a.disconnectWS()
a.stopScreenshotStream() a.stopScreenshotStream()
guard.SuppressFor(5 * time.Second)
runtime.WindowReloadApp(a.ctx) runtime.WindowReloadApp(a.ctx)
} }
@@ -849,6 +919,9 @@ func (a *App) rejectUnauthorizedWifi(ssid, bssid string) {
} }
func (a *App) fetchAllowedApps(classId int64) { func (a *App) fetchAllowedApps(classId int64) {
a.fetchAppsMu.Lock()
defer a.fetchAppsMu.Unlock()
a.mu.Lock() a.mu.Lock()
studentID := int64(0) studentID := int64(0)
if a.student != nil { if a.student != nil {
@@ -872,6 +945,12 @@ func (a *App) fetchAllowedApps(classId int64) {
} }
if err := json.NewDecoder(resp.Body).Decode(&res); err == nil { if err := json.NewDecoder(resp.Body).Decode(&res); err == nil {
if res.Exit { if res.Exit {
a.mu.Lock()
stillExam := a.dashboard.MonitorMode == "exam"
a.mu.Unlock()
if stillExam {
return
}
reason := res.Reason reason := res.Reason
if reason == "" { if reason == "" {
reason = "Không đáp ứng điều kiện học tập (chưa cấu hình ứng dụng được phép hoặc ngoài giờ học)." reason = "Không đáp ứng điều kiện học tập (chưa cấu hình ứng dụng được phép hoặc ngoài giờ học)."
@@ -921,6 +1000,7 @@ func (a *App) fetchStudentStatus(studentID int64) {
} }
a.mu.Lock() a.mu.Lock()
prevMode := a.dashboard.MonitorMode
a.dashboard = res a.dashboard = res
if res.MonitorLabel != "" { if res.MonitorLabel != "" {
a.statusMsg = res.MonitorLabel a.statusMsg = res.MonitorLabel
@@ -935,6 +1015,10 @@ func (a *App) fetchStudentStatus(studentID int64) {
a.statusMsg = res.MonitorLabel a.statusMsg = res.MonitorLabel
} }
a.mu.Unlock() a.mu.Unlock()
if prevMode != res.MonitorMode {
guard.SuppressFor(4 * time.Second)
}
} }
func (a *App) connectWS() { func (a *App) connectWS() {
@@ -1215,72 +1299,307 @@ func (a *App) SendChatMessage(body string) error {
return nil return nil
} }
func (a *App) OpenExamQuiz() error { func (a *App) examStudentContext() (int64, *StudentExamSnapshot, error) {
a.mu.Lock() a.mu.Lock()
var url string defer a.mu.Unlock()
if a.dashboard.Exam != nil { if a.student == nil {
url = strings.TrimSpace(a.dashboard.Exam.QuizURL) return 0, nil, errors.New("chưa đăng nhập")
} }
a.mu.Unlock() if a.dashboard.Exam == nil {
if url == "" { return 0, nil, errors.New("không trong giờ thi")
return errors.New("Phòng thi chưa cấu hình link trắc nghiệm")
} }
return openPrivateBrowser(url) return a.student.StudentID, a.dashboard.Exam, nil
} }
func openPrivateBrowser(url string) error { // ReturnToDashboard quay lại giao diện chính của app (giữ cookie WebView2).
tries := [][]string{ func (a *App) ReturnToDashboard() {
{"cmd", "/c", "start", "", "chrome", "--incognito", "--new-window", "--disk-cache-size=1", url}, guard.SuppressFor(5 * time.Second)
{"cmd", "/c", "start", "", "msedge", "--inprivate", "--new-window", url}, runtime.WindowReloadApp(a.ctx)
{"cmd", "/c", "start", "", url}, }
func isLocalExamURL(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" {
return false
} }
var lastErr error host := strings.ToLower(u.Hostname())
for _, args := range tries { return host == "127.0.0.1" || host == "localhost"
if err := exec.Command(args[0], args[1:]...).Start(); err != nil { }
lastErr = err
continue func (a *App) openExamWebView(targetURL, title string) error {
} targetURL = strings.TrimSpace(targetURL)
if targetURL == "" {
return errors.New("không có nội dung để mở")
}
if isLocalExamURL(targetURL) {
wrapper := fmt.Sprintf(
"http://127.0.0.1:34115/exam-view?url=%s&title=%s",
url.QueryEscape(targetURL),
url.QueryEscape(title),
)
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper))
return nil return nil
} }
if lastErr != nil { // Trang thi bên ngoài: mở trực tiếp trong WebView (first-party cookies → giữ phiên đăng nhập).
return lastErr guard.SuppressFor(5 * time.Second)
} runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL))
return errors.New("không mở được trình duyệt") return nil
} }
func (a *App) OpenExamPaper() error { func (a *App) GetExamPaperViewURL() (string, error) {
a.mu.Lock() studentID, exam, err := a.examStudentContext()
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 { 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&view=1", 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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") var payload map[string]any
_ = os.MkdirAll(dir, 0755) if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
dest := filepath.Join(dir, fmt.Sprintf("de_thi_%d.pdf", exam.ExamRoomID)) return nil, err
f, err := os.Create(dest) }
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
}
if u, ok := m["downloadUrl"].(string); ok && u != "" && !strings.HasPrefix(u, "http") {
m["downloadUrl"] = base + u
}
raw[i] = m
}
payload["resources"] = raw
}
return payload, nil
}
func (a *App) OpenExamPaper() error {
viewURL, err := a.GetExamPaperViewURL()
if err != nil { if err != nil {
return err return err
} }
if _, err := io.Copy(f, resp.Body); err != nil { title := "Đề thi"
f.Close() 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 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&view=1",
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 examResourceDownloadDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
candidates := []string{
filepath.Join(home, "Downloads"),
filepath.Join(home, "Desktop"),
home,
}
for _, dir := range candidates {
if st, err := os.Stat(dir); err == nil && st.IsDir() {
return dir, nil
}
}
return "", errors.New("không tìm thấy thư mục Downloads hoặc Desktop")
}
func uniqueFilePath(path string) string {
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
ext := filepath.Ext(path)
base := strings.TrimSuffix(filepath.Base(path), ext)
dir := filepath.Dir(path)
for i := 1; i < 100; i++ {
candidate := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext))
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}
return filepath.Join(dir, fmt.Sprintf("%s_%d%s", base, time.Now().Unix(), ext))
}
func openFolderInExplorer(filePath string) {
guard.SuppressFor(5 * time.Second)
dir := filepath.Dir(filePath)
_ = exec.Command("explorer", dir).Start()
}
func (a *App) DownloadExamResource(fileID uint) (string, 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ệ")
}
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")
}
dir, err := examResourceDownloadDir()
if err != nil {
return "", err
}
dest := uniqueFilePath(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() f.Close()
return exec.Command("cmd", "/c", "start", "", dest).Start() openFolderInExplorer(dest)
return dest, nil
}
// LoadExamViewFile tải file đề/tài nguyên qua Go (không mở URL trực tiếp trong WebView).
func (a *App) LoadExamViewFile(fileURL string) (map[string]any, error) {
fileURL = strings.TrimSpace(fileURL)
if !strings.HasPrefix(fileURL, "http://127.0.0.1:8080/api/student/exam/download") {
return nil, errors.New("URL không hợp lệ")
}
if _, _, err := a.examStudentContext(); err != nil {
return nil, err
}
resp, err := http.Get(fileURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("không tải được file")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
mime := strings.TrimSpace(resp.Header.Get("Content-Type"))
if mime == "" {
mime = "application/octet-stream"
}
// Bỏ charset nếu có
if i := strings.Index(mime, ";"); i >= 0 {
mime = strings.TrimSpace(mime[:i])
}
return map[string]any{
"data": base64.StdEncoding.EncodeToString(body),
"mime": mime,
}, nil
} }
func (a *App) SubmitExamWork() (string, error) { func (a *App) SubmitExamWork() (string, error) {
@@ -1388,8 +1707,8 @@ func zipFolder(srcDir, destZip string) error {
if err != nil { if err != nil {
return err return err
} }
defer rf.Close() _, copyErr := io.Copy(w, rf)
_, err = io.Copy(w, rf) rf.Close()
return err return copyErr
}) })
} }

View File

@@ -7,6 +7,9 @@
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": {
"pdfjs-dist": "^3.11.174"
},
"devDependencies": { "devDependencies": {
"vite": "^3.0.7" "vite": "^3.0.7"
} }
@@ -45,6 +48,202 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.0",
"https-proxy-agent": "^5.0.0",
"make-dir": "^3.1.0",
"node-fetch": "^2.6.7",
"nopt": "^5.0.0",
"npmlog": "^5.0.1",
"rimraf": "^3.0.2",
"semver": "^7.3.5",
"tar": "^6.1.11"
},
"bin": {
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"license": "ISC",
"optional": true
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/aproba": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
"license": "ISC",
"optional": true
},
"node_modules/are-we-there-yet": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"optional": true,
"dependencies": {
"delegates": "^1.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT",
"optional": true
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"license": "MIT",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/canvas": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz",
"integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.0",
"nan": "^2.17.0",
"simple-get": "^3.0.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=10"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"license": "ISC",
"optional": true,
"bin": {
"color-support": "bin.js"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT",
"optional": true
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"license": "ISC",
"optional": true
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"optional": true,
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decompress-response": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
"integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
"license": "MIT",
"optional": true,
"dependencies": {
"mimic-response": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT",
"optional": true
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT",
"optional": true
},
"node_modules/es-errors": { "node_modules/es-errors": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -433,6 +632,39 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
"license": "ISC",
"optional": true,
"dependencies": {
"minipass": "^3.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fs-minipass/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC",
"optional": true
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -458,6 +690,57 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/gauge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"optional": true,
"dependencies": {
"aproba": "^1.0.3 || ^2.0.0",
"color-support": "^1.1.2",
"console-control-strings": "^1.0.0",
"has-unicode": "^2.0.1",
"object-assign": "^4.1.1",
"signal-exit": "^3.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"wide-align": "^1.1.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"optional": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"license": "ISC",
"optional": true
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -471,6 +754,39 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"optional": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"optional": true,
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"optional": true
},
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.16.2", "version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
@@ -487,6 +803,132 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"license": "MIT",
"optional": true,
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/mimic-response": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"optional": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/minizlib": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
"license": "MIT",
"optional": true,
"dependencies": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/minizlib/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"license": "MIT",
"optional": true,
"bin": {
"mkdirp": "bin/cmd.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT",
"optional": true
},
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -506,6 +948,87 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
"license": "ISC",
"optional": true,
"dependencies": {
"abbrev": "1"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/npmlog": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"optional": true,
"dependencies": {
"are-we-there-yet": "^2.0.0",
"console-control-strings": "^1.1.0",
"gauge": "^3.0.0",
"set-blocking": "^2.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"optional": true,
"dependencies": {
"wrappy": "1"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-parse": { "node_modules/path-parse": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -513,6 +1036,29 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path2d-polyfill": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
"integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/pdfjs-dist": {
"version": "3.11.174",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"canvas": "^2.11.2",
"path2d-polyfill": "^2.0.1"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -549,6 +1095,21 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -571,6 +1132,23 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"optional": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "2.80.0", "version": "2.80.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
@@ -587,6 +1165,87 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC",
"optional": true
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC",
"optional": true
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/simple-get": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
"integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
"license": "MIT",
"optional": true,
"dependencies": {
"decompress-response": "^4.2.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -597,6 +1256,44 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"optional": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": { "node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -610,6 +1307,39 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"optional": true,
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT",
"optional": true
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT",
"optional": true
},
"node_modules/vite": { "node_modules/vite": {
"version": "3.2.11", "version": "3.2.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz",
@@ -659,6 +1389,48 @@
"optional": true "optional": true
} }
} }
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause",
"optional": true
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
"license": "ISC",
"optional": true,
"dependencies": {
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC",
"optional": true
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC",
"optional": true
} }
} }
} }

View File

@@ -9,5 +9,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^3.0.7" "vite": "^3.0.7"
},
"dependencies": {
"pdfjs-dist": "^3.11.174"
} }
} }

View File

@@ -1 +1 @@
5fbf12469d224a93954efecb5886e8a6 967eadb7b0ec47c936144695be4b0b15

View File

@@ -415,6 +415,196 @@ body {
color: var(--text-muted); 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-resources-hint {
margin: 0;
font-size: 0.82rem;
color: var(--text-muted);
}
.exam-viewer-overlay {
position: fixed;
inset: 0;
z-index: 10050;
background: rgba(15, 23, 42, 0.72);
display: none;
align-items: stretch;
justify-content: center;
padding: 0.75rem;
outline: none;
user-select: none;
}
.exam-viewer-overlay.is-open {
display: flex;
}
.exam-viewer-shell {
width: min(1100px, 100%);
margin: 0 auto;
background: #fff;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
max-height: calc(100vh - 1.5rem);
}
.exam-viewer-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 0.85rem;
background: #0f172a;
color: #f8fafc;
flex-shrink: 0;
}
.exam-viewer-title {
font-size: 0.9rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exam-viewer-body {
flex: 1;
min-height: 0;
background: #e2e8f0;
position: relative;
height: calc(100vh - 6.5rem);
}
.exam-viewer-content {
position: absolute;
inset: 0;
overflow: auto;
padding: 1rem;
background: #cbd5e1;
opacity: 0;
pointer-events: none;
}
.exam-viewer-content.is-ready {
opacity: 1;
pointer-events: auto;
}
.exam-viewer-loading,
.exam-viewer-error {
display: none;
position: absolute;
inset: 0;
z-index: 2;
align-items: center;
justify-content: center;
padding: 1.5rem;
color: #475569;
font-size: 0.92rem;
background: #e2e8f0;
}
.exam-viewer-loading.is-active,
.exam-viewer-error.is-active {
display: flex;
}
.exam-viewer-error {
color: #b91c1c;
}
.exam-viewer-error.is-active {
display: flex;
}
.exam-pdf-page-wrap {
display: flex;
justify-content: center;
margin: 0 auto 1rem;
}
.exam-pdf-page {
display: block;
max-width: 100%;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
pointer-events: none;
}
.exam-view-image {
display: block;
width: 100%;
max-width: 100%;
height: auto;
margin: 0 auto;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
pointer-events: none;
}
.exam-view-text {
max-width: 900px;
margin: 0 auto;
padding: 1rem 1.25rem;
background: #fff;
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.88rem;
line-height: 1.55;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.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 { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);

View File

@@ -0,0 +1,78 @@
import * as pdfjsLib from 'pdfjs-dist/build/pdf';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.js?url';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
function decodeBase64(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
return bytes;
}
export function detectExamViewKind(fileName, mime, bytes) {
if (bytes && bytes.length >= 4) {
if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) return 'pdf';
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image';
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image';
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image';
}
const name = String(fileName || '').toLowerCase();
const type = String(mime || '').toLowerCase();
if (type.includes('pdf') || name.endsWith('.pdf')) return 'pdf';
if (type.startsWith('image/') || /\.(png|jpe?g|gif|webp)$/.test(name)) return 'image';
if (type.startsWith('text/') || name.endsWith('.txt')) return 'text';
return 'unsupported';
}
export async function renderSecurePdf(container, bytes) {
container.innerHTML = '';
const pdf = await pdfjsLib.getDocument({ data: bytes }).promise;
const pad = 16;
const width = container.clientWidth || container.parentElement?.clientWidth || 900;
const maxWidth = Math.max(360, width - pad * 2);
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) {
const page = await pdf.getPage(pageNum);
const base = page.getViewport({ scale: 1 });
const scale = Math.min(1.6, maxWidth / base.width);
const viewport = page.getViewport({ scale });
const wrap = document.createElement('div');
wrap.className = 'exam-pdf-page-wrap';
const canvas = document.createElement('canvas');
canvas.className = 'exam-pdf-page';
canvas.width = viewport.width;
canvas.height = viewport.height;
wrap.appendChild(canvas);
container.appendChild(wrap);
await page.render({
canvasContext: canvas.getContext('2d'),
viewport,
}).promise;
}
}
export function renderSecureImage(container, bytes, mime) {
container.innerHTML = '';
const blob = new Blob([bytes], { type: mime || 'image/png' });
const img = document.createElement('img');
img.className = 'exam-view-image';
img.alt = 'Tài nguyên';
img.draggable = false;
img.src = URL.createObjectURL(blob);
container.appendChild(img);
}
export function renderSecureText(container, bytes) {
container.innerHTML = '';
const pre = document.createElement('pre');
pre.className = 'exam-view-text';
pre.textContent = new TextDecoder('utf-8').decode(bytes);
container.appendChild(pre);
}
export function decodeExamFilePayload(payload) {
return decodeBase64(payload.data || payload);
}

View File

@@ -1,6 +1,13 @@
import './style.css'; import './style.css';
import './app.css'; import './app.css';
import logoUrl from './assets/logo.jpeg'; import logoUrl from './assets/logo.jpeg';
import {
decodeExamFilePayload,
detectExamViewKind,
renderSecureImage,
renderSecurePdf,
renderSecureText,
} from './examViewer.js';
const brandLogoHtml = `<img src="${logoUrl}" alt="Simple Care" class="brand-logo-img" />`; const brandLogoHtml = `<img src="${logoUrl}" alt="Simple Care" class="brand-logo-img" />`;
@@ -68,9 +75,12 @@ function init() {
}); });
window.runtime.EventsOn('exam:paper-sent', () => { window.runtime.EventsOn('exam:paper-sent', () => {
if (loggedIn) { if (loggedIn) {
examPaperFiles = null;
examPaperFilesRoomId = 0;
lastExamPanelKey = '';
window.go.main.App.GetStats().then((s) => { window.go.main.App.GetStats().then((s) => {
stats = { ...stats, ...s }; stats = { ...stats, ...s };
updateExamPanel(); updateExamPanel(true);
}).catch(console.error); }).catch(console.error);
} }
}); });
@@ -87,6 +97,7 @@ async function checkLogin() {
startStatsTicker(); startStatsTicker();
ensureChatWidget(); ensureChatWidget();
updateChatBadge(); updateChatBadge();
if (stats.monitorMode === 'exam') updateExamPanel();
} else { } else {
loggedIn = false; loggedIn = false;
renderLoginPrompt(); renderLoginPrompt();
@@ -178,6 +189,201 @@ function renderShiftsTable(shifts) {
`; `;
} }
let examPaperFiles = null;
let examPaperFilesRoomId = 0;
let examPaperFilesLoading = false;
let lastExamPanelKey = '';
let examViewerObjectUrl = null;
function wireExamViewerGuards(overlay) {
overlay.addEventListener('contextmenu', (e) => e.preventDefault());
overlay.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if ((e.ctrlKey || e.metaKey) && (key === 'p' || key === 's')) {
e.preventDefault();
}
});
}
function ensureExamViewer() {
let overlay = document.getElementById('exam-viewer-overlay');
if (overlay) return overlay;
overlay = document.createElement('div');
overlay.id = 'exam-viewer-overlay';
overlay.className = 'exam-viewer-overlay';
overlay.innerHTML = `
<div class="exam-viewer-shell">
<div class="exam-viewer-bar">
<button type="button" class="btn btn-secondary" id="exam-viewer-close">← Quay lại</button>
<span class="exam-viewer-title" id="exam-viewer-title"></span>
</div>
<div class="exam-viewer-body" id="exam-viewer-body">
<div class="exam-viewer-content" id="exam-viewer-content"></div>
<div class="exam-viewer-loading" id="exam-viewer-loading">Đang tải...</div>
<div class="exam-viewer-error" id="exam-viewer-error"></div>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.querySelector('#exam-viewer-close').addEventListener('click', closeExamViewer);
wireExamViewerGuards(overlay);
return overlay;
}
function setExamViewerLoading(loading) {
const loadingEl = document.getElementById('exam-viewer-loading');
const contentEl = document.getElementById('exam-viewer-content');
const errorEl = document.getElementById('exam-viewer-error');
if (loadingEl) loadingEl.classList.toggle('is-active', loading);
if (contentEl) contentEl.classList.toggle('is-ready', !loading);
if (errorEl) errorEl.classList.remove('is-active');
}
function setExamViewerError(message) {
const loadingEl = document.getElementById('exam-viewer-loading');
const contentEl = document.getElementById('exam-viewer-content');
const errorEl = document.getElementById('exam-viewer-error');
if (loadingEl) loadingEl.classList.remove('is-active');
if (contentEl) contentEl.classList.remove('is-ready');
if (errorEl) {
errorEl.classList.add('is-active');
errorEl.textContent = message;
}
}
function resetExamViewerPanels() {
const loadingEl = document.getElementById('exam-viewer-loading');
const contentEl = document.getElementById('exam-viewer-content');
const errorEl = document.getElementById('exam-viewer-error');
if (loadingEl) loadingEl.classList.remove('is-active');
if (contentEl) contentEl.classList.remove('is-ready');
if (errorEl) errorEl.classList.remove('is-active');
}
function clearExamViewerContent() {
if (examViewerObjectUrl) {
URL.revokeObjectURL(examViewerObjectUrl);
examViewerObjectUrl = null;
}
const contentEl = document.getElementById('exam-viewer-content');
if (contentEl) contentEl.innerHTML = '';
}
async function showExamViewer(url, title, fileName = '') {
const overlay = ensureExamViewer();
document.getElementById('exam-viewer-title').textContent = title || 'Xem trong app';
overlay.classList.add('is-open');
overlay.focus();
clearExamViewerContent();
resetExamViewerPanels();
setExamViewerLoading(true);
const contentEl = document.getElementById('exam-viewer-content');
if (!contentEl) return;
try {
const payload = await window.go.main.App.LoadExamViewFile(url);
const bytes = decodeExamFilePayload(payload);
const mime = payload?.mime || '';
const kind = detectExamViewKind(fileName || title, mime, bytes);
if (kind === 'pdf') {
await new Promise((resolve) => requestAnimationFrame(resolve));
await renderSecurePdf(contentEl, bytes);
} else if (kind === 'image') {
renderSecureImage(contentEl, bytes, mime);
const img = contentEl.querySelector('img');
if (img?.src?.startsWith('blob:')) examViewerObjectUrl = img.src;
if (img) {
await new Promise((resolve, reject) => {
if (img.complete) resolve();
else {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Không hiển thị được ảnh'));
}
});
}
} else if (kind === 'text') {
renderSecureText(contentEl, bytes);
} else {
setExamViewerError('Chỉ hỗ trợ xem PDF, ảnh hoặc file text trong app.');
return;
}
setExamViewerLoading(false);
} catch (e) {
setExamViewerError(e?.message || e || 'Không mở được file');
}
}
function closeExamViewer() {
const overlay = document.getElementById('exam-viewer-overlay');
if (!overlay) return;
overlay.classList.remove('is-open');
clearExamViewerContent();
resetExamViewerPanels();
}
function renderExamResources() {
const ex = stats.exam;
if (!ex?.paperSent) return '';
if (examPaperFilesLoading && !examPaperFiles) {
return `
<div class="exam-resources">
<div class="exam-resources-title">📎 Tài nguyên kèm đề</div>
<p class="exam-resources-hint">Đang tải danh sách tài nguyên...</p>
</div>
`;
}
const resources = Array.isArray(examPaperFiles?.resources) ? examPaperFiles.resources : [];
if (!resources.length) {
return `
<div class="exam-resources">
<div class="exam-resources-title">📎 Tài nguyên kèm đề</div>
<p class="exam-resources-hint">Gói đề này không có tài nguyên đính kèm.</p>
</div>
`;
}
const rows = 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>
<button type="button" class="btn btn-secondary btn-sm btn-exam-res-dl" data-id="${r.id}">Tải về</button>
</div>
`).join('');
return `
<div class="exam-resources">
<div class="exam-resources-title">📎 Tài nguyên kèm đề (${resources.length})</div>
${rows}
</div>
`;
}
async function loadExamPaperFiles(force = false) {
const ex = stats.exam;
if (!ex || stats.monitorMode !== 'exam' || !ex.paperSent) {
examPaperFiles = null;
examPaperFilesRoomId = 0;
examPaperFilesLoading = false;
return;
}
if (!force && examPaperFiles && examPaperFilesRoomId === ex.examRoomId) {
return;
}
examPaperFilesLoading = true;
try {
examPaperFiles = await window.go.main.App.GetExamPaperFiles();
examPaperFilesRoomId = ex.examRoomId;
} catch (err) {
console.error('GetExamPaperFiles failed:', err);
examPaperFiles = { resources: [] };
examPaperFilesRoomId = ex.examRoomId;
} finally {
examPaperFilesLoading = false;
}
}
function renderExamPanel() { function renderExamPanel() {
const ex = stats.exam; const ex = stats.exam;
if (!ex || stats.monitorMode !== 'exam') return ''; if (!ex || stats.monitorMode !== 'exam') return '';
@@ -195,20 +401,27 @@ function renderExamPanel() {
<div class="card-title-row"> <div class="card-title-row">
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div> <div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
</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">Đề PDF và tài nguyên xem ngay trong app. Link trắc nghiệm mở trang thi — xong thì menu <strong>Simple Care → Về trang chính</strong> (Ctrl+H).</p>
<div class="exam-panel-actions"> <div class="exam-panel-actions">
${paperBtn} ${paperBtn}
${quizBtn} ${quizBtn}
${submitBtn} ${submitBtn}
</div> </div>
${renderExamResources()}
</div> </div>
`; `;
} }
function wireExamPanel() { function wireExamPanel() {
const ex = stats.exam;
const paper = document.getElementById('btn-exam-paper'); const paper = document.getElementById('btn-exam-paper');
if (paper) paper.addEventListener('click', () => { if (paper) paper.addEventListener('click', async () => {
window.go.main.App.OpenExamPaper().catch((e) => alert(e?.message || e)); try {
const url = await window.go.main.App.GetExamPaperViewURL();
showExamViewer(url, ex?.paperTitle || 'Đề thi', ex?.paperTitle || 'de.pdf');
} catch (e) {
alert(e?.message || e || 'Không mở được đề');
}
}); });
const quiz = document.getElementById('btn-exam-quiz'); const quiz = document.getElementById('btn-exam-quiz');
if (quiz) quiz.addEventListener('click', () => { if (quiz) quiz.addEventListener('click', () => {
@@ -221,20 +434,44 @@ function wireExamPanel() {
alert(`Đã nộp bài: ${name}`); alert(`Đã nộp bài: ${name}`);
const fresh = await window.go.main.App.GetStats(); const fresh = await window.go.main.App.GetStats();
stats = { ...stats, ...fresh }; stats = { ...stats, ...fresh };
await loadExamPaperFiles();
updateExamPanel(); updateExamPanel();
} catch (e) { } catch (e) {
alert(e?.message || e || 'Nộp bài thất bại'); alert(e?.message || e || 'Nộp bài thất bại');
} }
}); });
document.querySelectorAll('.btn-exam-res-dl').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = Number(btn.getAttribute('data-id'));
try {
await window.go.main.App.DownloadExamResource(id);
} catch (e) {
alert(e?.message || e || 'Tải thất bại');
}
});
});
} }
function updateExamPanel() { async function updateExamPanel(forceReloadFiles = false) {
const host = document.getElementById('exam-panel-host'); const host = document.getElementById('exam-panel-host');
if (!host) return; if (!host) return;
if (stats.monitorMode !== 'exam' || !stats.exam) {
host.innerHTML = '';
lastExamPanelKey = '';
return;
}
await loadExamPaperFiles(forceReloadFiles);
host.innerHTML = renderExamPanel(); host.innerHTML = renderExamPanel();
wireExamPanel(); wireExamPanel();
} }
function examPanelKey() {
const ex = stats.exam;
if (!ex) return '';
const resCount = Array.isArray(examPaperFiles?.resources) ? examPaperFiles.resources.length : -1;
return `${ex.examRoomId}:${ex.paperSent}:${ex.submitted}:${resCount}:${examPaperFilesLoading}`;
}
function renderDashboard() { function renderDashboard() {
if (!studentInfo) return; if (!studentInfo) return;
@@ -485,7 +722,7 @@ function renderChatMessages(silent = false) {
} }
function escapeHtml(s) { 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() { async function sendStudentChat() {
@@ -575,7 +812,17 @@ function startStatsTicker() {
} }
} }
updateExamPanel(); if (stats.monitorMode === 'exam' && stats.exam) {
const panelKey = examPanelKey();
if (panelKey !== lastExamPanelKey) {
lastExamPanelKey = panelKey;
updateExamPanel();
}
} else if (lastExamPanelKey !== '') {
lastExamPanelKey = '';
const host = document.getElementById('exam-panel-host');
if (host) host.innerHTML = '';
}
const onlineEl = document.getElementById('clock-online'); const onlineEl = document.getElementById('clock-online');
if (onlineEl) onlineEl.innerText = formatDuration(stats.onlineSecs || 0); if (onlineEl) onlineEl.innerText = formatDuration(stats.onlineSecs || 0);

View File

@@ -5,16 +5,26 @@ export function CheckLoginStatus():Promise<boolean>;
export function ClearChatUnread():Promise<void>; export function ClearChatUnread():Promise<void>;
export function DownloadExamResource(arg1:number):Promise<string>;
export function GetChatConversations():Promise<Array<Record<string, any>>>; export function GetChatConversations():Promise<Array<Record<string, any>>>;
export function GetChatMessages(arg1:number):Promise<Array<Record<string, any>>>; export function GetChatMessages(arg1:number):Promise<Array<Record<string, any>>>;
export function GetChatUnread():Promise<number>; 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 GetStats():Promise<Record<string, any>>;
export function GetStudentInfo():Promise<Record<string, any>>; export function GetStudentInfo():Promise<Record<string, any>>;
export function LoadExamViewFile(arg1:string):Promise<Record<string, any>>;
export function Logout():Promise<void>; export function Logout():Promise<void>;
export function NavigateToLogin():Promise<void>; export function NavigateToLogin():Promise<void>;
@@ -23,6 +33,10 @@ export function OpenExamPaper():Promise<void>;
export function OpenExamQuiz():Promise<void>; export function OpenExamQuiz():Promise<void>;
export function OpenExamResource(arg1:number):Promise<void>;
export function ReturnToDashboard():Promise<void>;
export function SendChatMessage(arg1:string):Promise<void>; export function SendChatMessage(arg1:string):Promise<void>;
export function SendWebcamFrame(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'](); return window['go']['main']['App']['ClearChatUnread']();
} }
export function DownloadExamResource(arg1) {
return window['go']['main']['App']['DownloadExamResource'](arg1);
}
export function GetChatConversations() { export function GetChatConversations() {
return window['go']['main']['App']['GetChatConversations'](); return window['go']['main']['App']['GetChatConversations']();
} }
@@ -22,6 +26,18 @@ export function GetChatUnread() {
return window['go']['main']['App']['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() { export function GetStats() {
return window['go']['main']['App']['GetStats'](); return window['go']['main']['App']['GetStats']();
} }
@@ -30,6 +46,10 @@ export function GetStudentInfo() {
return window['go']['main']['App']['GetStudentInfo'](); return window['go']['main']['App']['GetStudentInfo']();
} }
export function LoadExamViewFile(arg1) {
return window['go']['main']['App']['LoadExamViewFile'](arg1);
}
export function Logout() { export function Logout() {
return window['go']['main']['App']['Logout'](); return window['go']['main']['App']['Logout']();
} }
@@ -46,6 +66,14 @@ export function OpenExamQuiz() {
return window['go']['main']['App']['OpenExamQuiz'](); return window['go']['main']['App']['OpenExamQuiz']();
} }
export function OpenExamResource(arg1) {
return window['go']['main']['App']['OpenExamResource'](arg1);
}
export function ReturnToDashboard() {
return window['go']['main']['App']['ReturnToDashboard']();
}
export function SendChatMessage(arg1) { export function SendChatMessage(arg1) {
return window['go']['main']['App']['SendChatMessage'](arg1); return window['go']['main']['App']['SendChatMessage'](arg1);
} }

View File

@@ -249,6 +249,7 @@ var systemAllowed = map[string]bool{
"powershell.exe": true, "powershell.exe": true,
"conhost.exe": true, "conhost.exe": true,
"client.exe": true, // App Wails của ta "client.exe": true, // App Wails của ta
"simple_care_v1.0.exe": true,
"wails.exe": true, "wails.exe": true,
"msedgewebview2.exe": true, // WebView2 runtime của Wails "msedgewebview2.exe": true, // WebView2 runtime của Wails
"code.exe": true, // VSCode "code.exe": true, // VSCode

View File

@@ -2,5 +2,8 @@
package guard package guard
import "time"
func Start(onViolation func(reason string)) {} func Start(onViolation func(reason string)) {}
func Stop() {} func Stop() {}
func SuppressFor(_ time.Duration) {}

View File

@@ -72,15 +72,36 @@ type msg struct {
} }
var ( var (
guardOnce sync.Once guardOnce sync.Once
guardViolation func(string) guardViolation func(string)
guardStop chan struct{} guardStop chan struct{}
guardBaselineUser string guardBaselineUser string
guardBaselineSession uint32 guardBaselineSession uint32
desktopHook uintptr desktopHook uintptr
guardClassAtom uint16 guardClassAtom uint16
suppressMu sync.Mutex
suppressViolationsUntil time.Time
) )
// SuppressFor tạm không thoát app khi WebView/Explorer chuyển màn hình nội bộ.
func SuppressFor(d time.Duration) {
if d <= 0 {
return
}
suppressMu.Lock()
next := time.Now().Add(d)
if next.After(suppressViolationsUntil) {
suppressViolationsUntil = next
}
suppressMu.Unlock()
}
func violationsSuppressed() bool {
suppressMu.Lock()
defer suppressMu.Unlock()
return time.Now().Before(suppressViolationsUntil)
}
// Start giám sát môi trường Windows — vi phạm thì gọi onViolation (đổi user, đa màn hình, đổi desktop ảo). // Start giám sát môi trường Windows — vi phạm thì gọi onViolation (đổi user, đa màn hình, đổi desktop ảo).
func Start(onViolation func(reason string)) { func Start(onViolation func(reason string)) {
guardOnce.Do(func() { guardOnce.Do(func() {
@@ -122,7 +143,7 @@ func pollLoop() {
} }
func checkEnvironment() { func checkEnvironment() {
if guardViolation == nil { if guardViolation == nil || violationsSuppressed() {
return return
} }
if n := monitorCount(); n > 1 { if n := monitorCount(); n > 1 {
@@ -141,6 +162,10 @@ func checkEnvironment() {
} }
func triggerViolation(reason string) { func triggerViolation(reason string) {
if violationsSuppressed() {
log.Printf("[GUARD] suppressed: %s", reason)
return
}
if guardViolation != nil { if guardViolation != nil {
guardViolation(reason) guardViolation(reason)
} }

View File

@@ -4,8 +4,11 @@ import (
"embed" "embed"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/pkg/menu/keys"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/windows"
) )
//go:embed all:frontend/dist //go:embed all:frontend/dist
@@ -15,16 +18,26 @@ func main() {
// Create an instance of the app structure // Create an instance of the app structure
app := NewApp() app := NewApp()
appMenu := menu.NewMenu()
fileMenu := appMenu.AddSubmenu("Simple Care")
fileMenu.AddText("Về trang chính", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) {
app.ReturnToDashboard()
})
// Create application with options // Create application with options
err := wails.Run(&options.App{ err := wails.Run(&options.App{
Title: "Simple Care — Rikkei Education", Title: "Simple Care — Rikkei Education",
Width: 1024, Width: 1024,
Height: 768, Height: 768,
Menu: appMenu,
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
Assets: assets, Assets: assets,
}, },
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup, OnStartup: app.startup,
Windows: &windows.Options{
WebviewUserDataPath: app.webviewDataPath,
},
Bind: []interface{}{ Bind: []interface{}{
app, app,
}, },

View File

@@ -8,6 +8,7 @@
"name": "management", "name": "management",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"pdfjs-dist": "^3.11.174",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7"
}, },
@@ -349,6 +350,40 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.0",
"https-proxy-agent": "^5.0.0",
"make-dir": "^3.1.0",
"node-fetch": "^2.6.7",
"nopt": "^5.0.0",
"npmlog": "^5.0.1",
"rimraf": "^3.0.2",
"semver": "^7.3.5",
"tar": "^6.1.11"
},
"bin": {
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1150,6 +1185,58 @@
} }
} }
}, },
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"license": "ISC",
"optional": true
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/aproba": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
"license": "ISC",
"optional": true
},
"node_modules/are-we-there-yet": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"optional": true,
"dependencies": {
"delegates": "^1.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/babel-plugin-react-compiler": { "node_modules/babel-plugin-react-compiler": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
@@ -1160,6 +1247,13 @@
"@babel/types": "^7.26.0" "@babel/types": "^7.26.0"
} }
}, },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT",
"optional": true
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.40", "version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
@@ -1173,6 +1267,17 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"license": "MIT",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.4", "version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
@@ -1228,6 +1333,56 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/canvas": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz",
"integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.0",
"nan": "^2.17.0",
"simple-get": "^3.0.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=10"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"license": "ISC",
"optional": true,
"bin": {
"color-support": "bin.js"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT",
"optional": true
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"license": "ISC",
"optional": true
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1246,7 +1401,7 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -1260,11 +1415,31 @@
} }
} }
}, },
"node_modules/decompress-response": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
"integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
"license": "MIT",
"optional": true,
"dependencies": {
"mimic-response": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT",
"optional": true
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -1277,6 +1452,13 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT",
"optional": true
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1305,6 +1487,46 @@
} }
} }
}, },
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
"license": "ISC",
"optional": true,
"dependencies": {
"minipass": "^3.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fs-minipass/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs-minipass/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC",
"optional": true
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC",
"optional": true
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1320,6 +1542,28 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/gauge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"optional": true,
"dependencies": {
"aproba": "^1.0.3 || ^2.0.0",
"color-support": "^1.1.2",
"console-control-strings": "^1.0.0",
"has-unicode": "^2.0.1",
"object-assign": "^4.1.1",
"signal-exit": "^3.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"wide-align": "^1.1.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -1330,6 +1574,78 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"optional": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"license": "ISC",
"optional": true
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"optional": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"optional": true,
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"optional": true
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1646,13 +1962,119 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"license": "MIT",
"optional": true,
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-response": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"optional": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/minizlib": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
"license": "MIT",
"optional": true,
"dependencies": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/minizlib/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/minizlib/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC",
"optional": true
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"license": "MIT",
"optional": true,
"bin": {
"mkdirp": "bin/cmd.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -1672,6 +2094,27 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.50", "version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
@@ -1682,6 +2125,56 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
"license": "ISC",
"optional": true,
"dependencies": {
"abbrev": "1"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/npmlog": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"optional": true,
"dependencies": {
"are-we-there-yet": "^2.0.0",
"console-control-strings": "^1.1.0",
"gauge": "^3.0.0",
"set-blocking": "^2.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"optional": true,
"dependencies": {
"wrappy": "1"
}
},
"node_modules/oxlint": { "node_modules/oxlint": {
"version": "1.71.0", "version": "1.71.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz",
@@ -1731,6 +2224,39 @@
} }
} }
}, },
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path2d-polyfill": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
"integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/pdfjs-dist": {
"version": "3.11.174",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"canvas": "^2.11.2",
"path2d-polyfill": "^2.0.1"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1801,6 +2327,38 @@
"react": "^19.2.7" "react": "^19.2.7"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"optional": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
@@ -1835,6 +2393,27 @@
"@rolldown/binding-win32-x64-msvc": "1.1.3" "@rolldown/binding-win32-x64-msvc": "1.1.3"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1845,12 +2424,59 @@
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC",
"optional": true
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC",
"optional": true
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/simple-get": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
"integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
"license": "MIT",
"optional": true,
"dependencies": {
"decompress-response": "^4.2.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1861,6 +2487,70 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"optional": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"optional": true,
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tar/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC",
"optional": true
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1878,6 +2568,13 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT",
"optional": true
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1938,6 +2635,13 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT",
"optional": true
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
@@ -2016,6 +2720,41 @@
} }
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause",
"optional": true
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
"license": "ISC",
"optional": true,
"dependencies": {
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC",
"optional": true
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"pdfjs-dist": "^3.11.174",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7"
}, },

View File

@@ -1,17 +1,27 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { DashboardTab } from './components/DashboardTab'; import { DashboardTab } from './components/DashboardTab';
import { ClassesTab } from './components/ClassesTab'; import { ClassesTab } from './components/ClassesTab';
import { StudentsTab } from './components/StudentsTab'; import { StudentsTab } from './components/StudentsTab';
import { LearningTab } from './components/LearningTab'; import { LearningTab } from './components/LearningTab';
import { ExamsTab } from './components/ExamsTab'; import { ExamsTab } from './components/ExamsTab';
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace'; import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
import { NetworkTab } from './components/NetworkTab'; import { SystemTab } from './components/SystemTab';
import { EmailDomainsTab, MyAccountTab } from './components/AccountsTab'; import { MyAccountTab } from './components/AccountsTab';
import { ChatWidget } from './components/ChatWidget'; import { ChatWidget } from './components/ChatWidget';
import { StaffChatSocket } from './hooks/useStaffChatSocket'; import { StaffChatSocket } from './hooks/useStaffChatSocket';
import { ClassWorkspace } from './components/ClassWorkspace'; import { ClassWorkspace } from './components/ClassWorkspace';
import { NavHistoryBar, useRoute } from './components/NavHistoryBar'; import { NavHistoryBar, useRoute } from './components/NavHistoryBar';
import { goBack, navigate, parseRoute, pushNav, TAB_LABELS, type TabId } from './navigation'; import {
goBack,
navigate,
navigateSystem,
parseRoute,
pushNav,
SYSTEM_SECTION_LABELS,
TAB_LABELS,
type SystemSection,
type TabId,
} from './navigation';
import { useAuth } from './auth/AuthContext'; import { useAuth } from './auth/AuthContext';
const IconDashboard = () => ( const IconDashboard = () => (
@@ -51,36 +61,54 @@ const IconExam = () => (
</svg> </svg>
); );
const IconNetwork = () => ( const IconSystem = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.55a11 11 0 0 1 14.08 0" /> <circle cx="12" cy="12" r="3" />
<path d="M1.42 9a16 16 0 0 1 21.16 0" /> <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<circle cx="12" cy="20" r="1" />
</svg> </svg>
); );
const IconEmail = () => ( const IconMenu = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" /> <line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg> </svg>
); );
const SYSTEM_NAV: { section: SystemSection }[] = [
{ section: 'organization' },
{ section: 'network' },
{ section: 'templates' },
];
function App() { function App() {
const route = useRoute(); const route = useRoute();
const { staff, logout } = useAuth(); const { staff, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => { useEffect(() => {
const initial = parseRoute(); const initial = parseRoute();
if (initial.classId || initial.examId) { if (initial.classId || initial.examId) {
return; return;
} }
pushNav({ kind: 'tab', tab: initial.tab, label: TAB_LABELS[initial.tab] }); const label =
initial.tab === 'system'
? `Hệ thống · ${SYSTEM_SECTION_LABELS[initial.systemSection]}`
: TAB_LABELS[initial.tab];
pushNav({
kind: 'tab',
tab: initial.tab,
systemSection: initial.tab === 'system' ? initial.systemSection : undefined,
label,
});
}, []); }, []);
useEffect(() => {
setSidebarOpen(false);
}, [route.tab, route.classId, route.examId, route.systemSection]);
const setActiveTab = (tab: string) => { const setActiveTab = (tab: string) => {
navigate(tab as TabId); navigate(tab as TabId);
setSidebarOpen(false);
}; };
const handleBackFromWorkspace = () => { const handleBackFromWorkspace = () => {
@@ -89,9 +117,39 @@ function App() {
const inWorkspace = Boolean(route.classId || route.examId); const inWorkspace = Boolean(route.classId || route.examId);
const classesActive = route.tab === 'classes';
const learningActive = route.tab === 'learning';
const examsActive = route.tab === 'exams' || route.examId != null;
const systemActive = route.tab === 'system' && !inWorkspace;
const goSystem = (section: SystemSection) => {
navigateSystem(section);
setSidebarOpen(false);
};
const navBtn = (active: boolean) => `nav-btn${active ? ' active' : ''}`;
return ( return (
<> <>
<aside className="sidebar"> <button
type="button"
className="mobile-menu-btn"
aria-label="Mở menu"
onClick={() => setSidebarOpen(true)}
>
<IconMenu />
</button>
{sidebarOpen && (
<button
type="button"
className="sidebar-backdrop"
aria-label="Đóng menu"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside className={`sidebar${sidebarOpen ? ' sidebar--open' : ''}`}>
<div className="brand"> <div className="brand">
<img src="/logo.jpeg" alt="Simple Care" className="brand-logo-img" /> <img src="/logo.jpeg" alt="Simple Care" className="brand-logo-img" />
<div className="brand-text"> <div className="brand-text">
@@ -105,8 +163,8 @@ function App() {
<div className="nav-header">Tổng quan</div> <div className="nav-header">Tổng quan</div>
<li className="nav-item"> <li className="nav-item">
<button <button
className={`nav-btn ${route.tab === 'dashboard' && !inWorkspace ? 'active' : ''}`} className={navBtn(route.tab === 'dashboard' && !inWorkspace)}
onClick={() => navigate('dashboard')} onClick={() => setActiveTab('dashboard')}
> >
<span className="nav-icon"><IconDashboard /></span> <span className="nav-icon"><IconDashboard /></span>
Tổng quan Tổng quan
@@ -115,18 +173,15 @@ function App() {
<div className="nav-header">CSDL liên kết</div> <div className="nav-header">CSDL liên kết</div>
<li className="nav-item"> <li className="nav-item">
<button <button className={navBtn(classesActive)} onClick={() => setActiveTab('classes')}>
className={`nav-btn ${route.tab === 'classes' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('classes')}
>
<span className="nav-icon"><IconClass /></span> <span className="nav-icon"><IconClass /></span>
Lớp học Lớp học
</button> </button>
</li> </li>
<li className="nav-item"> <li className="nav-item">
<button <button
className={`nav-btn ${route.tab === 'students' && !inWorkspace ? 'active' : ''}`} className={navBtn(route.tab === 'students' && !inWorkspace)}
onClick={() => navigate('students')} onClick={() => setActiveTab('students')}
> >
<span className="nav-icon"><IconStudent /></span> <span className="nav-icon"><IconStudent /></span>
Sinh viên Sinh viên
@@ -135,43 +190,40 @@ function App() {
<div className="nav-header">Quản học tập</div> <div className="nav-header">Quản học tập</div>
<li className="nav-item"> <li className="nav-item">
<button <button className={navBtn(learningActive)} onClick={() => setActiveTab('learning')}>
className={`nav-btn ${route.tab === 'learning' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('learning')}
>
<span className="nav-icon"><IconLearning /></span> <span className="nav-icon"><IconLearning /></span>
Giám sát & Lịch học Phòng Học
</button> </button>
</li> </li>
<li className="nav-item"> <li className="nav-item">
<button <button className={navBtn(examsActive)} onClick={() => setActiveTab('exams')}>
className={`nav-btn ${route.tab === 'exams' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('exams')}
>
<span className="nav-icon"><IconExam /></span> <span className="nav-icon"><IconExam /></span>
Phòng thi Phòng thi
</button> </button>
</li> </li>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'network' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('network')}
>
<span className="nav-icon"><IconNetwork /></span>
Quản mạng
</button>
</li>
<div className="nav-header">Hệ thống</div> <div className="nav-header">Hệ thống</div>
<li className="nav-item"> <li className="nav-item">
<button <button
className={`nav-btn ${route.tab === 'email-domains' && !inWorkspace ? 'active' : ''}`} className={navBtn(systemActive)}
onClick={() => navigate('email-domains')} onClick={() => goSystem('organization')}
> >
<span className="nav-icon"><IconEmail /></span> <span className="nav-icon"><IconSystem /></span>
Đuôi email Quản hệ thống
</button> </button>
</li> </li>
<ul className="nav-sub">
{SYSTEM_NAV.map(({ section }) => (
<li key={section} className="nav-item">
<button
className={navBtn(systemActive && route.systemSection === section)}
onClick={() => goSystem(section)}
>
{SYSTEM_SECTION_LABELS[section]}
</button>
</li>
))}
</ul>
</ul> </ul>
</nav> </nav>
@@ -179,7 +231,7 @@ function App() {
<button <button
type="button" type="button"
className={`sidebar-user-btn ${route.tab === 'profile' && !inWorkspace ? 'active' : ''}`} className={`sidebar-user-btn ${route.tab === 'profile' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('profile')} onClick={() => setActiveTab('profile')}
title="Tài khoản của tôi" title="Tài khoản của tôi"
> >
<span className="sidebar-user-name"> <span className="sidebar-user-name">
@@ -213,8 +265,7 @@ function App() {
{route.tab === 'students' && <StudentsTab />} {route.tab === 'students' && <StudentsTab />}
{route.tab === 'learning' && <LearningTab />} {route.tab === 'learning' && <LearningTab />}
{route.tab === 'exams' && <ExamsTab />} {route.tab === 'exams' && <ExamsTab />}
{route.tab === 'network' && <NetworkTab />} {route.tab === 'system' && <SystemTab />}
{route.tab === 'email-domains' && <EmailDomainsTab />}
{route.tab === 'profile' && <MyAccountTab />} {route.tab === 'profile' && <MyAccountTab />}
</> </>
)} )}

View File

@@ -81,6 +81,63 @@ export const apiAuth = {
}, },
}; };
export interface GitHubStatus {
connected: boolean;
githubLogin?: string;
connectedAt?: string;
scope?: string;
canDeleteRepos?: boolean;
}
export const apiGitHub = {
status: async (): Promise<GitHubStatus> => {
const res = await staffFetch('/auth/github/status');
if (!res.ok) await parseError(res, 'Không tải trạng thái GitHub');
return res.json();
},
authorizeUrl: async (): Promise<{ authorizeUrl: string }> => {
const res = await staffFetch('/auth/github/authorize');
if (!res.ok) await parseError(res, 'Không bắt đầu OAuth GitHub');
return res.json();
},
disconnect: async () => {
const res = await staffFetch('/auth/github', { method: 'DELETE' });
if (!res.ok) await parseError(res, 'Ngắt kết nối GitHub thất bại');
return res.json();
},
};
export interface GitHubRepoItem {
key: string;
ownerLogin: string;
repoName: string;
repoHtmlUrl: string;
private?: boolean;
examRoomId?: number;
examRoomName?: string;
studentLabel?: string;
publishMode?: 'room' | 'student' | '';
createdAt: string;
updatedAt?: string;
fromSimpleCare?: boolean;
}
export const apiGitHubRepos = {
list: async (): Promise<{ data: GitHubRepoItem[]; total: number; githubLogin?: string }> => {
const res = await staffFetch('/auth/github/repos');
if (!res.ok) await parseError(res, 'Không tải danh sách repo');
return res.json();
},
bulkDelete: async (repos: string[]) => {
const res = await staffFetch('/auth/github/repos/bulk-delete', {
method: 'POST',
body: JSON.stringify({ repos }),
});
if (!res.ok) await parseError(res, 'Xóa repo thất bại');
return res.json() as Promise<{ ok: boolean; deleted: number; failures?: string[]; message: string }>;
},
};
export interface EmailDomainItem { export interface EmailDomainItem {
id: number; id: number;
domain: string; domain: string;
@@ -445,6 +502,38 @@ export const apiFetchAppPool = async (q = '', limit = 50): Promise<{ data: AppPo
return res.json(); return res.json();
}; };
export interface AppTemplateItem {
id: number;
name: string;
description: string;
keywords: string;
createdAt: string;
updatedAt: string;
}
export const apiAppTemplates = {
list: async (): Promise<{ data: AppTemplateItem[] }> => {
const res = await staffFetch('/app-templates');
if (!res.ok) throw new Error('Không tải được khung ứng dụng');
return res.json();
},
create: async (body: { name: string; description?: string; keywords: string }) => {
const res = await staffFetch('/app-templates', { method: 'POST', body: JSON.stringify(body) });
if (!res.ok) await parseError(res, 'Không tạo được khung');
return res.json();
},
update: async (id: number, body: { name?: string; description?: string; keywords?: string }) => {
const res = await staffFetch(`/app-templates/${id}`, { method: 'PATCH', body: JSON.stringify(body) });
if (!res.ok) await parseError(res, 'Không cập nhật được khung');
return res.json();
},
delete: async (id: number) => {
const res = await staffFetch(`/app-templates/${id}`, { method: 'DELETE' });
if (!res.ok) await parseError(res, 'Không xóa được khung');
return res.json();
},
};
export interface WifiPoolItem { export interface WifiPoolItem {
id: number; id: number;
ssid: string; ssid: string;
@@ -560,6 +649,21 @@ export const apiUpdateAttendanceStatus = async (
return res.json(); return res.json();
}; };
export const apiUpdateAttendanceBulkStatus = async (
rkId: number,
payload: { date: string; period: number; studentRkIds: number[]; status: number }
) => {
const res = await staffFetch(`/classes/${rkId}/attendance/bulk-status`, {
method: 'PUT',
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Failed to update attendance status');
}
return res.json();
};
export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => { export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => {
const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, { const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, {
method: 'POST', method: 'POST',
@@ -586,6 +690,9 @@ export interface ExamRoomItem {
endTime: string; endTime: string;
allowedApps: string; allowedApps: string;
quizUrl: string; quizUrl: string;
gitRepoUrl?: string;
gitBranch?: string;
gitPublishUrl?: string;
status: 'draft' | 'ready' | 'ended' | 'cancelled'; status: 'draft' | 'ready' | 'ended' | 'cancelled';
studentCount: number; studentCount: number;
paperCount: number; paperCount: number;
@@ -630,6 +737,15 @@ export interface ExamSubmission {
createdAt: string; createdAt: string;
fullName: string; fullName: string;
studentCode: string; studentCode: string;
gitRepoUrl?: string;
gitPublishUrl?: string;
}
function decodeBase64ToArrayBuffer(b64: string): ArrayBuffer {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out.buffer;
} }
export const apiExam = { export const apiExam = {
@@ -736,6 +852,38 @@ export const apiExam = {
if (!res.ok) await parseError(res, 'Xóa gói đề thất bại'); if (!res.ok) await parseError(res, 'Xóa gói đề thất bại');
return res.json(); return res.json();
}, },
fetchPaperPdfBytes: async (examId: number, paperId: number): Promise<ArrayBuffer> => {
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/view`);
if (!res.ok) await parseError(res, 'Không mở được đề PDF');
const json = await res.json() as { data: string };
if (!json.data) throw new Error('Server không trả dữ liệu PDF');
return decodeBase64ToArrayBuffer(json.data);
},
fetchPaperResourceBytes: async (examId: number, paperId: number, resourceId: number): Promise<ArrayBuffer> => {
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/resources/${resourceId}/view`);
if (!res.ok) await parseError(res, 'Không tải tài nguyên');
const json = await res.json() as { data: string };
if (!json.data) throw new Error('Server không trả dữ liệu file');
return decodeBase64ToArrayBuffer(json.data);
},
downloadPaperFile: async (examId: number, paperId: number, opts?: { resourceId?: number; fileName?: string }) => {
const params = new URLSearchParams();
if (opts?.resourceId) {
params.set('kind', 'resource');
params.set('fileId', String(opts.resourceId));
} else {
params.set('kind', 'pdf');
}
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/download?${params}`);
if (!res.ok) await parseError(res, 'Tải file thất bại');
const buf = await res.arrayBuffer();
const url = URL.createObjectURL(new Blob([buf]));
const a = document.createElement('a');
a.href = url;
a.download = opts?.fileName || 'de-thi.pdf';
a.click();
URL.revokeObjectURL(url);
},
assignRandom: async (id: number) => { assignRandom: async (id: number) => {
const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' }); const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' });
if (!res.ok) await parseError(res, 'Chia gói đề thất bại'); if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
@@ -752,4 +900,53 @@ export const apiExam = {
return res.json() as Promise<{ data: ExamSubmission[] }>; return res.json() as Promise<{ data: ExamSubmission[] }>;
}, },
downloadSubmission: (id: number, subId: number) => `${API_BASE}/exam-rooms/${id}/submissions/${subId}/download`, downloadSubmission: (id: number, subId: number) => `${API_BASE}/exam-rooms/${id}/submissions/${subId}/download`,
downloadAllSubmissions: async (id: number, fallbackName: string) => {
let res: Response;
try {
res = await staffFetch(`/exam-rooms/${id}/submissions/download-all`);
} catch {
throw new Error('Không kết nối được server (Failed to fetch). Kiểm tra server đang chạy và thử lại.');
}
if (!res.ok) await parseError(res, 'Tải bài nộp gộp thất bại');
const blob = await res.blob();
if (!blob.size) throw new Error('File ZIP trống — có thể bài nộp trên server bị thiếu file');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fallbackName.replace(/[<>:"/\\|?*]+/g, '_')}_bai_nop.zip`;
a.click();
URL.revokeObjectURL(url);
},
saveGitSettings: async (id: number, payload: { gitRepoUrl?: string; gitBranch?: string }) => {
const res = await staffFetch(`/exam-rooms/${id}/git-settings`, { method: 'PATCH', body: JSON.stringify(payload) });
if (!res.ok) await parseError(res, 'Lưu cấu hình Git thất bại');
return res.json() as Promise<{ gitRepoUrl: string; gitBranch: string; gitPublishUrl?: string }>;
},
publishSubmissionsGit: async (id: number) => {
const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git`, { method: 'POST' });
if (!res.ok) {
const errData = await res.json().catch(() => ({} as { error?: string; warnings?: string[] }));
const msg = errData.error || 'Đẩy lên Git thất bại';
if (errData.warnings?.length) throw new Error(`${msg}${errData.warnings.join('; ')}`);
throw new Error(msg);
}
return res.json() as Promise<{ ok: boolean; url: string; gitPublishUrl: string; openUrl?: string; message: string }>;
},
publishSubmissionsGitStudents: async (id: number) => {
const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git-students`, { method: 'POST' });
if (!res.ok) {
const errData = await res.json().catch(() => ({} as { error?: string; warnings?: string[] }));
const msg = errData.error || 'Đẩy repo từng SV thất bại';
if (errData.warnings?.length) throw new Error(`${msg}${errData.warnings.join('; ')}`);
throw new Error(msg);
}
return res.json() as Promise<{
ok: boolean;
published: number;
openUrl?: string;
message: string;
warnings?: string[];
studentRepos?: { studentRkId: number; studentCode: string; fullName: string; url: string }[];
}>;
},
}; };

View File

@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { apiAdmin, type EmailDomainItem } from '../api'; import { useGitHubConnection } from '../hooks/useGitHubConnection';
import { GitHubReposPanel } from './GitHubReposPanel';
import { OrganizationSection } from './OrganizationSection';
export function ChangePasswordPage({ forced }: { forced?: boolean }) { export function ChangePasswordPage({ forced }: { forced?: boolean }) {
const { changePassword, logout } = useAuth(); const { changePassword, logout } = useAuth();
@@ -65,84 +67,35 @@ export function ChangePasswordPage({ forced }: { forced?: boolean }) {
} }
export function EmailDomainsTab() { export function EmailDomainsTab() {
const [domains, setDomains] = useState<EmailDomainItem[]>([]);
const [newDomain, setNewDomain] = useState('');
const load = async () => {
const res = await apiAdmin.listEmailDomains();
setDomains(res.data);
};
useEffect(() => {
load().catch(console.error);
}, []);
const addDomain = async () => {
if (!newDomain.trim()) return;
await apiAdmin.addEmailDomain(newDomain.trim());
setNewDomain('');
await load();
};
const removeDomain = async (id: number) => {
if (!confirm('Xóa đuôi email này?')) return;
await apiAdmin.deleteEmailDomain(id);
await load();
};
return ( return (
<div className="page-stack"> <div className="tab-page">
<header className="page-header"> <header className="page-header">
<h1 className="page-title">Đuôi email</h1> <h1 className="page-title">Quản tổ chức</h1>
<p className="page-desc">Cấu hình đuôi email đưc phép đăng đăng nhập.</p>
</header> </header>
<OrganizationSection />
<div className="card" style={{ padding: '1.25rem' }}>
<h2 className="section-title">Đuôi email đưc phép</h2>
<p className="page-desc" style={{ marginTop: 0 }}>
Nếu chưa bản ghi nào, hệ thống chấp nhận mọi đuôi email. Thêm đuôi đ giới hạn truy cập.
</p>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<input
placeholder="vd: rikkeiacademy.com"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
style={{ flex: 1 }}
/>
<button type="button" className="btn btn-primary" onClick={addDomain}>Thêm</button>
</div>
<ul className="domain-list">
{domains.length === 0 && <li className="domain-empty">Chưa cấu hình chấp nhận tất cả đuôi email</li>}
{domains.map((d) => (
<li key={d.id} className="domain-item">
<span>@{d.domain}</span>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => removeDomain(d.id)}>Xóa</button>
</li>
))}
</ul>
</div>
</div> </div>
); );
} }
export function MyAccountTab() { export function MyAccountTab() {
const { staff, changePassword } = useAuth(); const { staff, changePassword } = useAuth();
const gh = useGitHubConnection();
const [oldPw, setOldPw] = useState(''); const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState(''); const [newPw, setNewPw] = useState('');
const [msg, setMsg] = useState(''); const [pwMsg, setPwMsg] = useState('');
const [err, setErr] = useState(''); const [pwErr, setPwErr] = useState('');
const submitPw = async (e: React.FormEvent) => { const submitPw = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setErr(''); setPwErr('');
setMsg(''); setPwMsg('');
try { try {
await changePassword(oldPw, newPw); await changePassword(oldPw, newPw);
setMsg('Đã đổi mật khẩu'); setPwMsg('Đã đổi mật khẩu');
setOldPw(''); setOldPw('');
setNewPw(''); setNewPw('');
} catch (e: any) { } catch (e: any) {
setErr(e?.message || 'Lỗi'); setPwErr(e?.message || 'Lỗi');
} }
}; };
@@ -152,7 +105,7 @@ export function MyAccountTab() {
<div className="page-stack"> <div className="page-stack">
<header className="page-header"> <header className="page-header">
<h1 className="page-title">Tài khoản của tôi</h1> <h1 className="page-title">Tài khoản của tôi</h1>
<p className="page-desc">Thông tin đăng nhập bảo mật nhân.</p> <p className="page-desc">Thông tin đăng nhập, GitHub bảo mật nhân.</p>
</header> </header>
<div className="card" style={{ padding: '1.25rem' }}> <div className="card" style={{ padding: '1.25rem' }}>
@@ -161,6 +114,57 @@ export function MyAccountTab() {
<p style={{ margin: 0, color: 'var(--text-muted)' }}>{staff?.email}</p> <p style={{ margin: 0, color: 'var(--text-muted)' }}>{staff?.email}</p>
</div> </div>
<div className="card account-github-card" style={{ padding: '1.25rem' }}>
<h2 className="section-title">GitHub</h2>
<p className="page-desc" style={{ marginTop: 0 }}>
Kết nối tài khoản GitHub của bạn đ đy bài nộp phòng thi lên repo riêng. Mỗi giáo viên dùng GitHub của mình không dùng chung token server.
</p>
<div className="exam-git-oauth-row">
{gh.connected ? (
<>
<span className="exam-git-connected">
Đã kết nối: <strong>@{gh.githubLogin}</strong>
</span>
<button type="button" className="btn btn-secondary btn-sm" onClick={gh.disconnect}>
Ngắt kết nối
</button>
{!gh.canDeleteRepos && (
<button type="button" className="btn btn-primary btn-sm" disabled={gh.connecting} onClick={gh.connect}>
{gh.connecting ? 'Đang mở GitHub...' : 'Cấp quyền xóa repo'}
</button>
)}
</>
) : (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={gh.connecting}
onClick={gh.connect}
>
{gh.connecting ? 'Đang mở GitHub...' : 'Kết nối GitHub'}
</button>
)}
</div>
{gh.connected && !gh.canDeleteRepos && (
<div className="login-error" style={{ marginTop: '0.75rem' }}>
Token hiện tại chưa quyền <strong>delete_repo</strong> không xóa đưc repo. Bấm <strong>Cấp quyền xóa repo</strong> (hoặc ngắt kết nối rồi kết nối lại).
</div>
)}
{gh.error && <div className="login-error">{gh.error}</div>}
{gh.message && <div className="login-success">{gh.message}</div>}
{!gh.connected && (
<p className="exam-git-hint">
Sau khi kết nối, vào <strong>Phòng thi</strong> tab <strong>Bài nộp</strong> đy Git (gộp hoặc từng sinh viên).
</p>
)}
{gh.connected && (
<>
<h3 className="section-subtitle" style={{ marginTop: '1rem' }}>Repo đã tạo</h3>
<GitHubReposPanel connected={gh.connected} canDeleteRepos={gh.canDeleteRepos} />
</>
)}
</div>
<div className="card" style={{ padding: '1.25rem' }}> <div className="card" style={{ padding: '1.25rem' }}>
<h2 className="section-title">Đi mật khẩu</h2> <h2 className="section-title">Đi mật khẩu</h2>
<form onSubmit={submitPw} className="login-form" style={{ maxWidth: 400 }}> <form onSubmit={submitPw} className="login-form" style={{ maxWidth: 400 }}>
@@ -172,8 +176,8 @@ export function MyAccountTab() {
<span>Mật khẩu mới</span> <span>Mật khẩu mới</span>
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} required minLength={8} /> <input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} required minLength={8} />
</label> </label>
{err && <div className="login-error">{err}</div>} {pwErr && <div className="login-error">{pwErr}</div>}
{msg && <div className="login-success">{msg}</div>} {pwMsg && <div className="login-success">{pwMsg}</div>}
<button type="submit" className="btn btn-primary">Lưu</button> <button type="submit" className="btn btn-primary">Lưu</button>
</form> </form>
</div> </div>

View File

@@ -0,0 +1,131 @@
import React, { useCallback, useEffect, useState } from 'react';
import { apiAppTemplates, type AppTemplateItem } from '../api';
import { countKeywords } from '../utils/appKeywords';
interface AppTemplatePickerModalProps {
open: boolean;
onClose: () => void;
onSelect: (keywords: string) => void;
allowedApps: string;
}
export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
open,
onClose,
onSelect,
allowedApps,
}) => {
const [items, setItems] = useState<AppTemplateItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const load = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await apiAppTemplates.list();
setItems(res.data || []);
} catch (e: any) {
setItems([]);
setError(e?.message || 'Không tải được khung ứng dụng');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
load();
}, [open, load]);
useEffect(() => {
if (!open) {
setSearch('');
setError(null);
}
}, [open]);
if (!open) return null;
const q = search.trim().toLowerCase();
const filtered = q
? items.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
t.description?.toLowerCase().includes(q) ||
t.keywords.toLowerCase().includes(q),
)
: items;
return (
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
<div className="modal-container app-pool-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2 className="modal-title" style={{ margin: 0 }}>Chọn khung ng dụng</h2>
<p className="app-pool-modal-sub">
Ghép bộ keyword đã lưu vào danh sách ng dụng đưc phép. Quản khung tại Hệ thống Khung ng dụng.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Đóng
</button>
</div>
<div className="app-pool-toolbar">
<input
type="search"
className="app-pool-search"
placeholder="Tìm theo tên, mô tả, keyword..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading}>
{loading ? 'Đang tải...' : 'Làm mới'}
</button>
</div>
<div className="app-pool-body">
{error ? (
<div className="discovered-apps-empty discovered-apps-error">{error}</div>
) : loading && items.length === 0 ? (
<div className="app-pool-status">Đang tải...</div>
) : filtered.length === 0 ? (
<div className="app-pool-status">
{q ? `Không tìm thấy "${search}"` : 'Chưa có khung nào. Tạo tại Hệ thống → Khung ứng dụng.'}
</div>
) : (
<div className="template-picker-list">
{filtered.map((tpl) => (
<div key={tpl.id} className="template-picker-card">
<div className="template-picker-card-head">
<strong>{tpl.name}</strong>
<span className="template-picker-count">{countKeywords(tpl.keywords)} keyword</span>
</div>
{tpl.description && <p className="template-picker-desc">{tpl.description}</p>}
<code className="template-picker-kw">{tpl.keywords}</code>
<button
type="button"
className="btn btn-primary btn-sm template-picker-apply"
onClick={() => {
onSelect(tpl.keywords);
onClose();
}}
>
Áp dụng khung
</button>
</div>
))}
</div>
)}
</div>
<div className="app-pool-footer">
Đang {countKeywords(allowedApps)} keyword trong whitelist hiện tại
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,210 @@
import { useEffect, useState } from 'react';
import { apiAppTemplates, type AppTemplateItem } from '../api';
import { AppPoolModal } from './AppPoolModal';
import { countKeywords, mergeKeywordCSV } from '../utils/appKeywords';
export function AppTemplatesSection() {
const [items, setItems] = useState<AppTemplateItem[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<AppTemplateItem | null>(null);
const [creating, setCreating] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [keywords, setKeywords] = useState('');
const [busy, setBusy] = useState(false);
const [poolOpen, setPoolOpen] = useState(false);
const [error, setError] = useState('');
const load = async () => {
setLoading(true);
try {
const res = await apiAppTemplates.list();
setItems(res.data || []);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const openCreate = () => {
setCreating(true);
setEditing(null);
setName('');
setDescription('');
setKeywords('');
setError('');
};
const openEdit = (tpl: AppTemplateItem) => {
setEditing(tpl);
setCreating(false);
setName(tpl.name);
setDescription(tpl.description || '');
setKeywords(tpl.keywords);
setError('');
};
const closeForm = () => {
setCreating(false);
setEditing(null);
setError('');
};
const save = async () => {
if (!name.trim()) {
setError('Nhập tên khung.');
return;
}
if (!keywords.trim()) {
setError('Thêm ít nhất một keyword.');
return;
}
setBusy(true);
setError('');
try {
if (editing) {
await apiAppTemplates.update(editing.id, {
name: name.trim(),
description: description.trim(),
keywords,
});
} else {
await apiAppTemplates.create({
name: name.trim(),
description: description.trim(),
keywords,
});
}
closeForm();
await load();
} catch (e: any) {
setError(e?.message || 'Lỗi lưu khung');
} finally {
setBusy(false);
}
};
const remove = async (tpl: AppTemplateItem) => {
if (!confirm(`Xóa khung "${tpl.name}"?`)) return;
try {
await apiAppTemplates.delete(tpl.id);
if (editing?.id === tpl.id) closeForm();
await load();
} catch (e: any) {
alert(e?.message || 'Không xóa được');
}
};
const addPoolKeyword = (kw: string) => {
setKeywords((prev) => mergeKeywordCSV(prev, kw));
};
const showForm = creating || editing;
return (
<div className="system-section">
<div className="system-block">
<div className="system-block-head">
<div>
<h2 className="system-block-title">Khung ng dụng</h2>
<p className="system-block-desc">
Tạo bộ keyword whitelist dùng chung ghép từ kho app hoặc tự nhập. Lớp học phòng thi thể áp dụng nhanh thay chỉ chọn từng app trong kho.
</p>
</div>
<button type="button" className="btn btn-primary" onClick={openCreate}>
+ Tạo khung
</button>
</div>
{showForm && (
<div className="system-form-card">
<h3 className="system-form-title">{editing ? 'Sửa khung' : 'Khung mới'}</h3>
<div className="system-form-grid">
<label className="login-field">
<span>Tên khung</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="VD: Java IDE, Thi cuối kỳ..."
/>
</label>
<label className="login-field system-form-full">
<span> tả (tuỳ chọn)</span>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Ghi chú ngắn cho giáo viên"
/>
</label>
<label className="login-field system-form-full">
<span>Keywords (phân tách bằng dấu phẩy)</span>
<textarea
className="app-textarea"
rows={4}
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
placeholder="chrome, vscode, cursor, goland, client"
/>
</label>
</div>
<div className="system-form-actions">
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
Thêm từ kho app
</button>
<div className="system-form-actions-right">
<button type="button" className="btn btn-ghost" onClick={closeForm}>
Hủy
</button>
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>
{busy ? 'Đang lưu...' : 'Lưu khung'}
</button>
</div>
</div>
{error && <div className="login-error" style={{ marginTop: '0.75rem' }}>{error}</div>}
</div>
)}
{loading ? (
<div className="system-empty">Đang tải...</div>
) : items.length === 0 ? (
<div className="system-empty">
Chưa khung nào. Bấm <strong>Tạo khung</strong> đ ghép keyword từ kho hoặc tự ghi.
</div>
) : (
<div className="template-grid">
{items.map((tpl) => (
<article key={tpl.id} className="template-card">
<div className="template-card-head">
<h3>{tpl.name}</h3>
<span className="template-card-badge">{countKeywords(tpl.keywords)} kw</span>
</div>
{tpl.description && <p className="template-card-desc">{tpl.description}</p>}
<code className="template-card-kw">{tpl.keywords}</code>
<div className="template-card-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={() => openEdit(tpl)}>
Sửa
</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(tpl)}>
Xóa
</button>
</div>
</article>
))}
</div>
)}
</div>
<AppPoolModal
open={poolOpen}
onClose={() => setPoolOpen(false)}
onSelect={addPoolKeyword}
allowedApps={keywords}
/>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import {
apiFetchAttendanceShifts, apiFetchAttendanceShifts,
apiPushAttendanceQLDT, apiPushAttendanceQLDT,
apiUpdateAttendanceStatus, apiUpdateAttendanceStatus,
apiUpdateAttendanceBulkStatus,
attendanceStatusClass, attendanceStatusClass,
type AttendanceRow, type AttendanceRow,
type AttendanceShiftInfo, type AttendanceShiftInfo,
@@ -34,6 +35,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pushing, setPushing] = useState(false); const [pushing, setPushing] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedStudentRkIds, setSelectedStudentRkIds] = useState<number[]>([]);
const loadShifts = useCallback(async () => { const loadShifts = useCallback(async () => {
try { try {
@@ -53,6 +55,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
const res = await apiFetchAttendance(classId, date, period); const res = await apiFetchAttendance(classId, date, period);
setRows(res.data || []); setRows(res.data || []);
setShiftInfo(res.shift || null); setShiftInfo(res.shift || null);
setSelectedStudentRkIds([]); // Clear selection when data changes
} catch (e: any) { } catch (e: any) {
alert(e.message || 'Không tải được điểm danh'); alert(e.message || 'Không tải được điểm danh');
} finally { } finally {
@@ -83,6 +86,12 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
return counts; return counts;
}, [rows]); }, [rows]);
const toggleStudentSelection = useCallback((studentRkId: number) => {
setSelectedStudentRkIds(prev =>
prev.includes(studentRkId) ? prev.filter(id => id !== studentRkId) : [...prev, studentRkId]
);
}, []);
const handleStatusChange = async (studentRkId: number, status: number) => { const handleStatusChange = async (studentRkId: number, status: number) => {
try { try {
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status }); await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
@@ -92,6 +101,25 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
} }
}; };
const handleBulkStatusChange = async (status: number) => {
if (selectedStudentRkIds.length === 0) return;
try {
setLoading(true);
await apiUpdateAttendanceBulkStatus(classId, {
date,
period,
studentRkIds: selectedStudentRkIds,
status,
});
setSelectedStudentRkIds([]);
await loadAttendance();
} catch (e: any) {
alert(e.message || 'Cập nhật hàng loạt thất bại');
} finally {
setLoading(false);
}
};
const handlePushQLDT = async () => { const handlePushQLDT = async () => {
if (!shiftInfo?.courseId) { if (!shiftInfo?.courseId) {
alert('Ca học này chưa chọn môn học. Vui lòng cấu hình trong lịch học.'); alert('Ca học này chưa chọn môn học. Vui lòng cấu hình trong lịch học.');
@@ -213,6 +241,35 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
Sửa trạng thái thủ công sẽ đưc khóa hệ thống tự tính sẽ không ghi đè. Sửa trạng thái thủ công sẽ đưc khóa hệ thống tự tính sẽ không ghi đè.
</p> </p>
{selectedStudentRkIds.length > 0 && (
<div className="attendance-bulk-actions">
<span className="attendance-bulk-title">
Đang chọn {selectedStudentRkIds.length} sinh viên:
</span>
<div className="attendance-bulk-btns">
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
<button
key={opt.value}
type="button"
className={`btn btn-secondary ${attendanceStatusClass(opt.value)}`}
style={{ padding: '0.25rem 0.75rem', fontSize: '0.875rem', border: '1px solid var(--att-border)' }}
onClick={() => handleBulkStatusChange(opt.value)}
>
Gắn &quot;{opt.label}&quot;
</button>
))}
</div>
<button
type="button"
className="btn btn-muted attendance-bulk-btn-close"
style={{ padding: '0.25rem 0.75rem', fontSize: '0.875rem' }}
onClick={() => setSelectedStudentRkIds([])}
>
Hủy chọn
</button>
</div>
)}
<div className="attendance-table-scroll table-wrapper"> <div className="attendance-table-scroll table-wrapper">
{loading ? ( {loading ? (
<div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div> <div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div>
@@ -220,6 +277,22 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
<table className="data-table attendance-table"> <table className="data-table attendance-table">
<thead> <thead>
<tr> <tr>
<th style={{ width: '45px', textAlign: 'center' }}>
<input
type="checkbox"
checked={filteredRows.length > 0 && filteredRows.every(r => selectedStudentRkIds.includes(r.studentRkId))}
onChange={(e) => {
if (e.target.checked) {
const newIds = new Set(selectedStudentRkIds);
filteredRows.forEach(r => newIds.add(r.studentRkId));
setSelectedStudentRkIds(Array.from(newIds));
} else {
const filteredIds = filteredRows.map(r => r.studentRkId);
setSelectedStudentRkIds(selectedStudentRkIds.filter(id => !filteredIds.includes(id)));
}
}}
/>
</th>
<th>Sinh viên</th> <th>Sinh viên</th>
<th>Online</th> <th>Online</th>
<th>Trạng thái</th> <th>Trạng thái</th>
@@ -229,53 +302,73 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
<tbody> <tbody>
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
<tr> <tr>
<td colSpan={4} style={{ textAlign: 'center', color: 'var(--text-muted)' }}> <td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
{rows.length === 0 ? 'Chưa có dữ liệu điểm danh' : 'Không tìm thấy sinh viên phù hợp'} {rows.length === 0 ? 'Chưa có dữ liệu điểm danh' : 'Không tìm thấy sinh viên phù hợp'}
</td> </td>
</tr> </tr>
) : ( ) : (
filteredRows.map(row => ( filteredRows.map(row => {
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)}> const isSelected = selectedStudentRkIds.includes(row.studentRkId);
<td> return (
<div className="attendance-student-name">{row.fullName}</div> <tr key={row.studentRkId} className={attendanceStatusClass(row.status)}>
<div className="attendance-student-meta"> <td
<code>{row.studentCode}</code> style={{ textAlign: 'center', cursor: 'pointer' }}
{row.email && <span>{row.email}</span>} onClick={() => toggleStudentSelection(row.studentRkId)}
</div>
</td>
<td>
<span className="attendance-online-mins">{row.onlineMinutes}</span>
<span className="attendance-online-unit">phút</span>
</td>
<td>
<select
className={`attendance-status-select ${attendanceStatusClass(row.status)}`}
value={row.status}
onChange={e => handleStatusChange(row.studentRkId, Number(e.target.value))}
> >
{ATTENDANCE_STATUS_OPTIONS.map(opt => ( <input
<option key={opt.value} value={opt.value}>{opt.label}</option> type="checkbox"
))} checked={isSelected}
</select> onChange={() => {}}
</td> onClick={(e) => {
<td> e.stopPropagation();
<div className="attendance-notes"> toggleStudentSelection(row.studentRkId);
{row.pushedToQldtAt ? ( }}
<span className="attendance-qldt-tag attendance-qldt-tag--ok" title={row.pushedToQldtAt}> />
QLĐT </td>
</span> <td
) : ( style={{ cursor: 'pointer' }}
<span className="attendance-qldt-tag attendance-qldt-tag--pending">Chưa QLĐT</span> onClick={() => toggleStudentSelection(row.studentRkId)}
)} >
{row.statusEditedByTeacher && ( <div className="attendance-student-name">{row.fullName}</div>
<span className="attendance-lock-tag" title="Giáo viên đã sửa — không bị ghi đè"> <div className="attendance-student-meta">
Đã khóa <code>{row.studentCode}</code>
</span> {row.email && <span>{row.email}</span>}
)} </div>
</div> </td>
</td> <td>
</tr> <span className="attendance-online-mins">{row.onlineMinutes}</span>
)) <span className="attendance-online-unit">phút</span>
</td>
<td>
<select
className={`attendance-status-select ${attendanceStatusClass(row.status)}`}
value={row.status}
onChange={e => handleStatusChange(row.studentRkId, Number(e.target.value))}
>
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</td>
<td>
<div className="attendance-notes">
{row.pushedToQldtAt ? (
<span className="attendance-qldt-tag attendance-qldt-tag--ok" title={row.pushedToQldtAt}>
QLĐT
</span>
) : (
<span className="attendance-qldt-tag attendance-qldt-tag--pending">Chưa QLĐT</span>
)}
{row.statusEditedByTeacher && (
<span className="attendance-lock-tag" title="Giáo viên đã sửa — không bị ghi đè">
Đã khóa
</span>
)}
</div>
</td>
</tr>
);
})
)} )}
</tbody> </tbody>
</table> </table>

View File

@@ -22,6 +22,8 @@ import { AttendancePanel } from './AttendancePanel';
import { StudentAvatar } from './StudentAvatar'; import { StudentAvatar } from './StudentAvatar';
import { StudentDetailModal } from './StudentDetailModal'; import { StudentDetailModal } from './StudentDetailModal';
import { AppPoolModal } from './AppPoolModal'; import { AppPoolModal } from './AppPoolModal';
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
import { mergeKeywordCSV } from '../utils/appKeywords';
interface ClassWorkspaceProps { interface ClassWorkspaceProps {
classId: number; classId: number;
@@ -52,6 +54,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
const [configOpen, setConfigOpen] = useState(false); const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule'); const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
const [appPoolOpen, setAppPoolOpen] = useState(false); const [appPoolOpen, setAppPoolOpen] = useState(false);
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const loadAllData = async () => { const loadAllData = async () => {
try { try {
@@ -177,6 +180,10 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
}); });
}; };
const applyTemplate = (keywords: string) => {
setAllowedApps((prev) => mergeKeywordCSV(prev, keywords));
};
const handleSaveAllowedApps = async () => { const handleSaveAllowedApps = async () => {
try { try {
setSavingApps(true); setSavingApps(true);
@@ -329,18 +336,27 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
</div> </div>
<div className="app-pool-open-row"> <div className="app-pool-open-row">
<div> <div>
<span className="config-suggestions-label">Kho ng dụng (toàn hệ thống)</span> <span className="config-suggestions-label">Kho & khung ng dụng</span>
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}> <p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
App bị chặn từ mọi lớp mở kho đ tìm thêm nhanh. Thêm từng app trong kho hoặc áp dụng cả bộ keyword đã lưu.
</p> </p>
</div> </div>
<button <div className="app-pool-open-actions">
type="button" <button
className="btn btn-secondary app-pool-open-btn" type="button"
onClick={() => setAppPoolOpen(true)} className="btn btn-secondary app-pool-open-btn"
> onClick={() => setTemplatePickerOpen(true)}
Mở kho & tìm kiếm >
</button> Chọn khung
</button>
<button
type="button"
className="btn btn-secondary app-pool-open-btn"
onClick={() => setAppPoolOpen(true)}
>
Mở kho
</button>
</div>
</div> </div>
<button <button
className="btn btn-primary" className="btn btn-primary"
@@ -539,6 +555,12 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
onSelect={addAppKeyword} onSelect={addAppKeyword}
allowedApps={allowedApps} allowedApps={allowedApps}
/> />
<AppTemplatePickerModal
open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}
onSelect={applyTemplate}
allowedApps={allowedApps}
/>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from 'react';
import { renderExamPdfPages } from '../utils/renderExamPdf';
interface Props {
title: string;
bytes: Uint8Array | null;
loading?: boolean;
onClose: () => void;
}
export function ExamPaperPdfModal({ title, bytes, loading, onClose }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [err, setErr] = useState('');
useEffect(() => {
if (!bytes || !containerRef.current) return;
let cancelled = false;
setErr('');
renderExamPdfPages(containerRef.current, bytes).catch((e: unknown) => {
if (!cancelled) {
setErr(e instanceof Error ? e.message : 'Không hiển thị được PDF');
}
});
return () => { cancelled = true; };
}, [bytes]);
return (
<div className="modal-overlay exam-pdf-viewer-overlay" onClick={onClose}>
<div className="exam-pdf-viewer-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header exam-pdf-viewer-header">
<h2 className="modal-title">{title}</h2>
<button type="button" className="modal-close-btn" onClick={onClose} aria-label="Đóng">×</button>
</div>
<div className="exam-pdf-viewer-scroll" ref={containerRef}>
{loading && <p className="exam-pdf-viewer-status">Đang tải đ...</p>}
{err && <p className="login-error" style={{ padding: '1rem' }}>{err}</p>}
{!loading && !bytes && !err && (
<p className="exam-pdf-viewer-status">Không dữ liệu PDF</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
apiExam, apiExam,
staffFetch, staffFetch,
@@ -7,10 +7,14 @@ import {
type ExamSubmission, type ExamSubmission,
type StudentItem, type StudentItem,
} from '../api'; } from '../api';
import { useGitHubConnection } from '../hooks/useGitHubConnection';
import { navigate, pushNav } from '../navigation';
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants'; import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
import { pushNav } from '../navigation';
import { openStaffChat } from '../chatEvents'; import { openStaffChat } from '../chatEvents';
import { AppPoolModal } from './AppPoolModal'; import { AppPoolModal } from './AppPoolModal';
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
import { mergeKeywordCSV } from '../utils/appKeywords';
import { ExamPaperPdfModal } from './ExamPaperPdfModal';
import { NavHistoryBar } from './NavHistoryBar'; import { NavHistoryBar } from './NavHistoryBar';
import { StudentAvatar } from './StudentAvatar'; import { StudentAvatar } from './StudentAvatar';
import { StudentDetailModal } from './StudentDetailModal'; import { StudentDetailModal } from './StudentDetailModal';
@@ -49,6 +53,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [end, setEnd] = useState(''); const [end, setEnd] = useState('');
const [allowedApps, setAllowedApps] = useState(DEFAULT_EXAM_ALLOWED_APPS); const [allowedApps, setAllowedApps] = useState(DEFAULT_EXAM_ALLOWED_APPS);
const [quizUrl, setQuizUrl] = useState(''); const [quizUrl, setQuizUrl] = useState('');
const [gitPublishUrl, setGitPublishUrl] = useState('');
const [bundlingSubs, setBundlingSubs] = useState(false);
const [publishingGit, setPublishingGit] = useState(false);
const [publishingGitStudents, setPublishingGitStudents] = useState(false);
const gh = useGitHubConnection();
const [editable, setEditable] = useState(false); const [editable, setEditable] = useState(false);
const [prepEditable, setPrepEditable] = useState(false); const [prepEditable, setPrepEditable] = useState(false);
const [displayStatus, setDisplayStatus] = useState('draft'); const [displayStatus, setDisplayStatus] = useState('draft');
@@ -60,6 +69,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]); const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
const [searchQ, setSearchQ] = useState(''); const [searchQ, setSearchQ] = useState('');
const [searchHits, setSearchHits] = useState<{ studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[]>([]); const [searchHits, setSearchHits] = useState<{ studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[]>([]);
const [selectedPickerRkIds, setSelectedPickerRkIds] = useState<number[]>([]);
const [paperTitle, setPaperTitle] = useState(''); const [paperTitle, setPaperTitle] = useState('');
const [packagePdf, setPackagePdf] = useState<File | null>(null); const [packagePdf, setPackagePdf] = useState<File | null>(null);
const [packageResources, setPackageResources] = useState<File[]>([]); const [packageResources, setPackageResources] = useState<File[]>([]);
@@ -68,6 +78,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [err, setErr] = useState(''); const [err, setErr] = useState('');
const [appPoolOpen, setAppPoolOpen] = useState(false); const [appPoolOpen, setAppPoolOpen] = useState(false);
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const [studentPickerOpen, setStudentPickerOpen] = useState(false); const [studentPickerOpen, setStudentPickerOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -77,6 +88,10 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions'>('roster'); const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions'>('roster');
const [configOpen, setConfigOpen] = useState(false); const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info'); const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
const [papersModalOpen, setPapersModalOpen] = useState(false);
const [pdfViewer, setPdfViewer] = useState<{ title: string; bytes: Uint8Array | null } | null>(null);
const [pdfLoading, setPdfLoading] = useState(false);
const [inlineResource, setInlineResource] = useState<{ url: string; title: string; isImage: boolean } | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -87,6 +102,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
setEnd(toLocalInput(data.room.endTime)); setEnd(toLocalInput(data.room.endTime));
setAllowedApps(data.room.allowedApps || DEFAULT_EXAM_ALLOWED_APPS); setAllowedApps(data.room.allowedApps || DEFAULT_EXAM_ALLOWED_APPS);
setQuizUrl(data.room.quizUrl || ''); setQuizUrl(data.room.quizUrl || '');
setGitPublishUrl(data.room.gitPublishUrl || '');
setEditable(data.editable); setEditable(data.editable);
setPrepEditable(data.prepEditable ?? data.editable); setPrepEditable(data.prepEditable ?? data.editable);
setDisplayStatus(data.displayStatus); setDisplayStatus(data.displayStatus);
@@ -110,6 +126,25 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
useEffect(() => { load().catch(console.error); }, [load]); useEffect(() => { load().catch(console.error); }, [load]);
const closePdfViewer = useCallback(() => {
setPdfViewer(null);
setPdfLoading(false);
}, []);
const closeInlineResource = useCallback(() => {
setInlineResource((prev) => {
if (prev?.url) URL.revokeObjectURL(prev.url);
return null;
});
}, []);
const inlineResourceRef = useRef(inlineResource);
inlineResourceRef.current = inlineResource;
useEffect(() => () => {
if (inlineResourceRef.current?.url) URL.revokeObjectURL(inlineResourceRef.current.url);
}, []);
const fetchOnline = useCallback(async () => { const fetchOnline = useCallback(async () => {
try { try {
const res = await apiExam.fetchOnlineStudents(examId); const res = await apiExam.fetchOnlineStudents(examId);
@@ -146,6 +181,10 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
}); });
}; };
const applyTemplate = (keywords: string) => {
setAllowedApps((prev) => mergeKeywordCSV(prev, keywords));
};
const saveRoom = async () => { const saveRoom = async () => {
setErr(''); setMsg(''); setErr(''); setMsg('');
setSaving(true); setSaving(true);
@@ -172,6 +211,39 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
await fetchOnline(); await fetchOnline();
}; };
const toggleHitSelection = (studentRkId: number) => {
setSelectedPickerRkIds((prev) =>
prev.includes(studentRkId) ? prev.filter((id) => id !== studentRkId) : [...prev, studentRkId]
);
};
const addSelectedStudents = async () => {
if (selectedPickerRkIds.length === 0) return;
try {
await apiExam.addStudents(examId, selectedPickerRkIds);
setSelectedPickerRkIds([]);
setStudentPickerOpen(false);
await load();
await fetchOnline();
} catch (e: any) {
alert(e?.message || 'Thêm sinh viên thất bại');
}
};
const toggleSelectAllHits = () => {
const enrolledIds = new Set(students.map((s) => s.studentRkId));
const unaddedHits = searchHits.filter((h) => !enrolledIds.has(h.studentRkId));
const allUnaddedIds = unaddedHits.map((h) => h.studentRkId);
if (allUnaddedIds.length === 0) return;
const areAllSelected = allUnaddedIds.every((id) => selectedPickerRkIds.includes(id));
if (areAllSelected) {
setSelectedPickerRkIds((prev) => prev.filter((id) => !allUnaddedIds.includes(id)));
} else {
setSelectedPickerRkIds((prev) => Array.from(new Set([...prev, ...allUnaddedIds])));
}
};
const createPackage = async () => { const createPackage = async () => {
if (!packagePdf) { if (!packagePdf) {
setErr('Chọn file PDF cho gói đề'); setErr('Chọn file PDF cho gói đề');
@@ -230,6 +302,117 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const viewPaperPdf = async (paperId: number, title?: string) => {
setErr('');
const paperTitle = title || papers.find((p) => p.id === paperId)?.title || 'Đề thi';
setPapersModalOpen(false);
setPdfLoading(true);
setPdfViewer({ title: paperTitle, bytes: null });
try {
const buf = await apiExam.fetchPaperPdfBytes(examId, paperId);
setPdfViewer({ title: paperTitle, bytes: new Uint8Array(buf) });
} catch (e: any) {
closePdfViewer();
setErr(e?.message || 'Không mở được đề PDF');
} finally {
setPdfLoading(false);
}
};
const viewPaperResource = async (paperId: number, resourceId: number, fileName: string) => {
setErr('');
if (/\.pdf$/i.test(fileName)) {
setPapersModalOpen(false);
setPdfLoading(true);
setPdfViewer({ title: fileName, bytes: null });
try {
const buf = await apiExam.fetchPaperResourceBytes(examId, paperId, resourceId);
setPdfViewer({ title: fileName, bytes: new Uint8Array(buf) });
} catch (e: any) {
closePdfViewer();
setErr(e?.message || 'Không mở được PDF');
} finally {
setPdfLoading(false);
}
return;
}
closeInlineResource();
try {
const buf = await apiExam.fetchPaperResourceBytes(examId, paperId, resourceId);
const isImage = /\.(png|jpe?g|gif|webp)$/i.test(fileName);
const mime = /\.png$/i.test(fileName) ? 'image/png'
: /\.jpe?g$/i.test(fileName) ? 'image/jpeg'
: /\.gif$/i.test(fileName) ? 'image/gif'
: 'image/webp';
const url = URL.createObjectURL(new Blob([buf], { type: mime }));
setInlineResource({ url, title: fileName, isImage });
} catch (e: any) {
setErr(e?.message || 'Không mở được tài nguyên');
}
};
const downloadPaperResource = async (paperId: number, resourceId: number, fileName: string) => {
setErr('');
try {
await apiExam.downloadPaperFile(examId, paperId, { resourceId, fileName });
} catch (e: any) {
setErr(e?.message || 'Không tải được tài nguyên');
}
};
const downloadAllSubs = async () => {
setErr(''); setMsg('');
setBundlingSubs(true);
try {
await apiExam.downloadAllSubmissions(examId, roomName || `phong_thi_${examId}`);
setMsg('Đã tải ZIP gộp — giải nén sẽ thấy từng folder bài làm theo sinh viên.');
} catch (e: any) {
setErr(e?.message || 'Tải thất bại');
} finally {
setBundlingSubs(false);
}
};
const publishToGit = async () => {
if (!gh.connected) {
setErr('Kết nối GitHub trong Tài khoản của tôi (sidebar dưới cùng) trước khi đẩy.');
return;
}
setErr(''); setMsg('');
setPublishingGit(true);
try {
const res = await apiExam.publishSubmissionsGit(examId);
const openUrl = res.openUrl || res.gitPublishUrl || res.url;
setGitPublishUrl(openUrl);
setMsg(res.message || 'Đã đẩy bài nộp lên GitHub');
if (openUrl) window.open(openUrl, '_blank', 'noopener,noreferrer');
} catch (e: any) {
setErr(e?.message || 'Đẩy Git thất bại');
} finally {
setPublishingGit(false);
}
};
const publishToGitStudents = async () => {
if (!gh.connected) {
setErr('Kết nối GitHub trong Tài khoản của tôi trước khi đẩy.');
return;
}
setErr(''); setMsg('');
setPublishingGitStudents(true);
try {
const res = await apiExam.publishSubmissionsGitStudents(examId);
setMsg(res.message || 'Đã tạo repo từng sinh viên');
if (res.openUrl) window.open(res.openUrl, '_blank', 'noopener,noreferrer');
const subs = await apiExam.listSubmissions(examId);
setSubmissions(subs.data);
} catch (e: any) {
setErr(e?.message || 'Đẩy Git thất bại');
} finally {
setPublishingGitStudents(false);
}
};
const handlePublish = async () => { const handlePublish = async () => {
setErr(''); setMsg(''); setErr(''); setMsg('');
try { try {
@@ -357,6 +540,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
{canCancel && ( {canCancel && (
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button> <button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button>
)} )}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ
</button>
)}
</div> </div>
</div> </div>
@@ -408,6 +596,31 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<span>Link trắc nghiệm</span> <span>Link trắc nghiệm</span>
<input value={quizUrl} onChange={(e) => setQuizUrl(e.target.value)} placeholder="https://..." disabled={!editable} /> <input value={quizUrl} onChange={(e) => setQuizUrl(e.target.value)} placeholder="https://..." disabled={!editable} />
</label> </label>
<div className="exam-git-settings">
<p className="exam-git-status-line">
GitHub:{' '}
{gh.connected ? (
<strong>@{gh.githubLogin}</strong>
) : (
<>
<span className="exam-git-not-connected">chưa kết nối</span>
{' — '}
<button type="button" className="link-btn" onClick={() => navigate('profile')}>
Kết nối trong Tài khoản
</button>
</>
)}
</p>
<p className="exam-git-hint">
Bấm <strong>Đy lên Git</strong> sẽ tự tạo repo theo tên phòng thi mỗi sinh viên một folder, giống ZIP tải về.
</p>
{gitPublishUrl && (
<p className="exam-git-last">
Repo gần nhất:{' '}
<a href={gitPublishUrl} target="_blank" rel="noreferrer">{gitPublishUrl}</a>
</p>
)}
</div>
<label className="login-field"> <label className="login-field">
<span>Bắt đu</span> <span>Bắt đu</span>
<input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} disabled={!editable} /> <input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} disabled={!editable} />
@@ -451,14 +664,19 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</div> </div>
<div className="app-pool-open-row"> <div className="app-pool-open-row">
<div> <div>
<span className="config-suggestions-label">Kho ng dụng (toàn hệ thống)</span> <span className="config-suggestions-label">Kho & khung ng dụng</span>
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}> <p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
App bị chặn từ mọi lớp mở kho đ tìm thêm nhanh. Thêm từng app trong kho hoặc áp dụng cả bộ keyword đã lưu.
</p> </p>
</div> </div>
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}> <div className="app-pool-open-actions">
Mở kho & tìm kiếm <button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setTemplatePickerOpen(true)}>
</button> Chọn khung
</button>
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
Mở kho
</button>
</div>
</div> </div>
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}> <button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
{saving ? 'Đang lưu...' : 'Lưu cấu hình apps'} {saving ? 'Đang lưu...' : 'Lưu cấu hình apps'}
@@ -525,9 +743,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
{papers.map((p) => ( {papers.map((p) => (
<div key={p.id} style={{ border: '1px solid var(--border-light)', borderRadius: '8px', padding: '0.75rem', background: 'var(--bg-subtle)' }}> <div key={p.id} style={{ border: '1px solid var(--border-light)', borderRadius: '8px', padding: '0.75rem', background: 'var(--bg-subtle)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'flex-start' }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<strong>{p.title}</strong> <strong>{p.title}</strong>
<span className="badge badge-muted">Gói đ</span> <div style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => viewPaperPdf(p.id)}>Xem PDF</button>
<span className="badge badge-muted">Gói đ</span>
</div>
</div> </div>
<ul className="config-card-desc" style={{ margin: '0.5rem 0 0', paddingLeft: '1.1rem' }}> <ul className="config-card-desc" style={{ margin: '0.5rem 0 0', paddingLeft: '1.1rem' }}>
<li>📄 main.pdf (đ chính)</li> <li>📄 main.pdf (đ chính)</li>
@@ -535,7 +756,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<li style={{ color: 'var(--text-muted)' }}>Chưa tài nguyên kèm</li> <li style={{ color: 'var(--text-muted)' }}>Chưa tài nguyên kèm</li>
) : ( ) : (
(p.resources || []).map((r) => ( (p.resources || []).map((r) => (
<li key={r.id}>📎 {r.fileName}</li> <li key={r.id}>
📎 {r.fileName}{' '}
<button type="button" className="link-btn" onClick={() => downloadPaperResource(p.id, r.id, r.fileName)}>
Tải
</button>
</li>
)) ))
)} )}
</ul> </ul>
@@ -628,11 +854,16 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<button <button
type="button" type="button"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); }} onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); setSelectedPickerRkIds([]); }}
> >
+ Thêm sinh viên + Thêm sinh viên
</button> </button>
)} )}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ ({papers.length})
</button>
)}
</div> </div>
</div> </div>
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} /> <div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} />
@@ -649,7 +880,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); }} onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); setSelectedPickerRkIds([]); }}
> >
+ Thêm sinh viên + Thêm sinh viên
</button> </button>
@@ -735,7 +966,19 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<td style={{ color: isOnline ? 'var(--success)' : 'var(--text-muted)', fontWeight: 700 }}> <td style={{ color: isOnline ? 'var(--success)' : 'var(--text-muted)', fontWeight: 700 }}>
{isOnline ? 'Online' : 'Offline'} {isOnline ? 'Online' : 'Offline'}
</td> </td>
<td>{s.paperTitle || '—'}</td> <td>
{s.paperTitle || '—'}
{s.assignedPaperId && (
<button
type="button"
className="link-btn"
style={{ marginLeft: '0.35rem' }}
onClick={() => viewPaperPdf(s.assignedPaperId!)}
>
Xem đ
</button>
)}
</td>
<td>{s.paperSentAt ? fmtTime(s.paperSentAt) : s.paperScheduledAt ? `Hẹn ${fmtTime(s.paperScheduledAt)}` : '—'}</td> <td>{s.paperSentAt ? fmtTime(s.paperSentAt) : s.paperScheduledAt ? `Hẹn ${fmtTime(s.paperScheduledAt)}` : '—'}</td>
<td>{s.submitted ? <span className="badge badge-success">Đã nộp</span> : '—'}</td> <td>{s.submitted ? <span className="badge badge-success">Đã nộp</span> : '—'}</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}> <td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
@@ -766,6 +1009,56 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</div> </div>
) : ( ) : (
<div className="session-logs-panel"> <div className="session-logs-panel">
{submissions.length > 0 && (
<div className="exam-submissions-toolbar">
{papers.length > 0 && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => setPapersModalOpen(true)}
>
📄 Xem đ phòng thi
</button>
)}
<button
type="button"
className="btn btn-primary btn-sm"
disabled={bundlingSubs}
onClick={downloadAllSubs}
>
{bundlingSubs ? 'Đang gói ZIP...' : '📦 Tải tất cả (ZIP gộp)'}
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={publishingGit || !gh.connected}
onClick={publishToGit}
title="1 repo — mỗi sinh viên một folder"
>
{publishingGit ? 'Đang đẩy...' : '🔗 Git gộp (1 repo)'}
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={publishingGitStudents || !gh.connected}
onClick={publishToGitStudents}
title="Mỗi sinh viên một repo GitHub riêng"
>
{publishingGitStudents ? 'Đang tạo repo...' : '👤 Git từng SV'}
</button>
{!gh.connected && (
<span className="exam-submissions-toolbar-hint">
<button type="button" className="link-btn" onClick={() => navigate('profile')}>
Kết nối GitHub
</button>
{' '}trong Tài khoản của tôi trước khi đy.
</span>
)}
<span className="exam-submissions-toolbar-hint">
<strong>Git gộp</strong>: 1 repo, folder từng SV · <strong>Git từng SV</strong>: mỗi người 1 repo.
</span>
</div>
)}
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}> <div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
{submissions.length === 0 ? ( {submissions.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}> <div className="empty-state" style={{ minHeight: '300px' }}>
@@ -779,6 +1072,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<tr> <tr>
<th>Sinh viên</th> <th>Sinh viên</th>
<th>File</th> <th>File</th>
<th>Git</th>
<th>Thời gian</th> <th>Thời gian</th>
<th style={{ textAlign: 'right' }}>Tải</th> <th style={{ textAlign: 'right' }}>Tải</th>
</tr> </tr>
@@ -791,6 +1085,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{s.studentCode}</div> <div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{s.studentCode}</div>
</td> </td>
<td style={{ fontSize: '0.85rem' }}>{s.fileName}</td> <td style={{ fontSize: '0.85rem' }}>{s.fileName}</td>
<td style={{ fontSize: '0.8rem' }}>
{s.gitPublishUrl ? (
<a href={s.gitPublishUrl} target="_blank" rel="noreferrer">Repo</a>
) : '—'}
</td>
<td style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{fmtTime(s.createdAt)}</td> <td style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{fmtTime(s.createdAt)}</td>
<td style={{ textAlign: 'right' }}> <td style={{ textAlign: 'right' }}>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => downloadSub(s.id, s.fileName)}>Tải</button> <button type="button" className="btn btn-secondary btn-sm" onClick={() => downloadSub(s.id, s.fileName)}>Tải</button>
@@ -807,6 +1106,81 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</div> </div>
</div> </div>
{pdfViewer && (
<ExamPaperPdfModal
title={pdfViewer.title}
bytes={pdfViewer.bytes}
loading={pdfLoading}
onClose={closePdfViewer}
/>
)}
{inlineResource && (
<div className="modal-overlay exam-pdf-viewer-overlay" onClick={closeInlineResource}>
<div className="exam-pdf-viewer-modal exam-inline-resource-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header exam-pdf-viewer-header">
<h2 className="modal-title">{inlineResource.title}</h2>
<button type="button" className="modal-close-btn" onClick={closeInlineResource} aria-label="Đóng">×</button>
</div>
<div className="exam-inline-resource-body">
{inlineResource.isImage ? (
<img src={inlineResource.url} alt={inlineResource.title} className="exam-inline-resource-image" />
) : (
<iframe src={inlineResource.url} title={inlineResource.title} className="exam-pdf-viewer-frame" />
)}
</div>
</div>
</div>
)}
{papersModalOpen && (
<div className="modal-overlay" onClick={() => setPapersModalOpen(false)}>
<div className="modal-container exam-papers-view-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2 className="modal-title">Đ thi {roomName}</h2>
<p className="exam-student-modal-desc">{papers.length} gói đ trong phòng thi</p>
</div>
<button type="button" className="modal-close-btn" onClick={() => setPapersModalOpen(false)} aria-label="Đóng">×</button>
</div>
<div className="exam-papers-view-body">
{papers.map((p) => (
<div key={p.id} className="exam-paper-view-card">
<div className="exam-paper-view-head">
<strong>{p.title}</strong>
<button type="button" className="btn btn-primary btn-sm" onClick={(e) => { e.preventDefault(); void viewPaperPdf(p.id); }}>
Xem PDF đ
</button>
</div>
<ul className="exam-paper-view-resources">
<li>📄 Đ chính (PDF)</li>
{(p.resources || []).length === 0 ? (
<li className="text-muted">Không tài nguyên kèm</li>
) : (
(p.resources || []).map((r) => (
<li key={r.id}>
📎 {r.fileName}
<span className="exam-paper-view-actions">
{/\.(pdf|png|jpe?g|gif|webp|txt)$/i.test(r.fileName) && (
<button type="button" className="link-btn" onClick={() => viewPaperResource(p.id, r.id, r.fileName)}>
Xem
</button>
)}
<button type="button" className="link-btn" onClick={() => downloadPaperResource(p.id, r.id, r.fileName)}>
Tải
</button>
</span>
</li>
))
)}
</ul>
</div>
))}
</div>
</div>
</div>
)}
{selectedStudent && ( {selectedStudent && (
<StudentDetailModal <StudentDetailModal
student={selectedStudent} student={selectedStudent}
@@ -816,55 +1190,137 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
)} )}
{studentPickerOpen && ( {studentPickerOpen && (
<div className="modal-overlay"> <div className="modal-overlay exam-student-overlay" onClick={() => setStudentPickerOpen(false)}>
<div className="modal-container" style={{ maxWidth: '560px', width: '92%' }}> <div className="modal-container exam-student-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header exam-student-modal-header">
<div> <div>
<h2 className="modal-title">Thêm sinh viên</h2> <h2 className="modal-title">Thêm sinh viên</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}> <p className="exam-student-modal-desc">Tìm theo lớp, họ tên, sinh viên</p>
Tìm theo lớp, họ tên, sinh viên
</p>
</div> </div>
<button type="button" className="btn btn-secondary close-btn" onClick={() => setStudentPickerOpen(false)}>Đóng</button> <button type="button" className="modal-close-btn" onClick={() => setStudentPickerOpen(false)} aria-label="Đóng">
×
</button>
</div> </div>
<div style={{ padding: '0 0 1rem' }}> <div className="exam-student-modal-body">
<input <div className="exam-student-modal-search search-input-wrap" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
type="search" <input
className="app-pool-search" type="search"
placeholder="Tìm sinh viên..." className="search-input"
value={searchQ} placeholder="Tìm sinh viên..."
onChange={(e) => setSearchQ(e.target.value)} value={searchQ}
autoFocus onChange={(e) => {
/> setSearchQ(e.target.value);
<div style={{ maxHeight: 'min(55vh, 420px)', overflowY: 'auto', marginTop: '1rem' }}> setSelectedPickerRkIds([]);
{searchQ.trim() === '' ? ( }}
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}> ít nhất vài tự đ tìm.</p> autoFocus
) : searchHits.length === 0 ? ( style={{ flex: 1 }}
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Không tìm thấy sinh viên phù hợp.</p> />
) : ( {searchQ.trim() !== '' && searchHits.some((h) => !enrolledIds.has(h.studentRkId)) && (
<ul className="learning-add-list"> <button
{searchHits.map((h) => { type="button"
const added = enrolledIds.has(h.studentRkId); className="btn btn-secondary btn-sm"
return ( onClick={toggleSelectAllHits}
<li key={h.studentRkId} className="learning-add-item"> style={{ whiteSpace: 'nowrap' }}
<div> >
<div className="learning-class-code">{h.studentCode}</div> {searchHits.filter((h) => !enrolledIds.has(h.studentRkId)).every((h) => selectedPickerRkIds.includes(h.studentRkId))
<div className="learning-class-meta">{h.fullName}</div> ? 'Bỏ chọn tất cả'
{(h.classCodes || h.email) && ( : 'Chọn tất cả'}
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.2rem' }}> </button>
{[h.classCodes, h.email].filter(Boolean).join(' · ')}
</div>
)}
</div>
<button type="button" className="btn btn-primary btn-sm" disabled={added} onClick={() => addStudent(h.studentRkId)}>
{added ? 'Đã thêm' : 'Thêm'}
</button>
</li>
);
})}
</ul>
)} )}
</div> </div>
<div className="exam-student-hits">
{searchQ.trim() === '' ? (
<div className="exam-student-hits-empty">
<span>🔍</span>
<p> ít nhất vài tự đ tìm sinh viên.</p>
</div>
) : searchHits.length === 0 ? (
<div className="exam-student-hits-empty">
<span>📭</span>
<p>Không tìm thấy sinh viên phù hợp.</p>
</div>
) : (
searchHits.map((h) => {
const added = enrolledIds.has(h.studentRkId);
const isSelected = selectedPickerRkIds.includes(h.studentRkId);
const initials = h.fullName
.split(/\s+/)
.filter(Boolean)
.slice(-2)
.map((w) => w[0]?.toUpperCase() || '')
.join('');
return (
<div
key={h.studentRkId}
className={`exam-student-hit ${added ? 'exam-student-hit--added' : ''} ${isSelected ? 'exam-student-hit--selected' : ''}`}
style={{ cursor: added ? 'default' : 'pointer' }}
onClick={() => {
if (!added) toggleHitSelection(h.studentRkId);
}}
>
<div
style={{ display: 'flex', alignItems: 'center' }}
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={added || isSelected}
disabled={added}
onChange={() => {
if (!added) toggleHitSelection(h.studentRkId);
}}
/>
</div>
<div className="exam-student-hit-avatar" aria-hidden="true">{initials || '?'}</div>
<div className="exam-student-hit-info">
<strong>{h.fullName}</strong>
<div className="exam-student-hit-meta">
SV: <code>{h.studentCode}</code>
{h.classCodes ? <> · Lớp: {h.classCodes}</> : null}
{h.email ? <> · {h.email}</> : null}
</div>
</div>
<button
type="button"
className={`btn btn-sm ${added ? 'btn-secondary' : 'btn-primary'}`}
disabled={added}
onClick={(e) => {
e.stopPropagation();
addStudent(h.studentRkId);
}}
>
{added ? '✓ Đã thêm' : '+ Thêm'}
</button>
</div>
);
})
)}
</div>
</div>
<div className="modal-footer exam-student-modal-footer" style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
padding: '0.85rem 1.25rem',
borderTop: '1px solid var(--border-color, #dee2e6)',
background: 'var(--bg-subtle, #f8f9fa)',
borderRadius: '0 0 var(--radius-lg) var(--radius-lg)'
}}>
<button
type="button"
className="btn btn-secondary"
onClick={() => setStudentPickerOpen(false)}
>
Đóng
</button>
<button
type="button"
className="btn btn-primary"
disabled={selectedPickerRkIds.length === 0}
onClick={addSelectedStudents}
>
Thêm {selectedPickerRkIds.length > 0 ? `(${selectedPickerRkIds.length})` : ''} sinh viên đã chọn
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -876,6 +1332,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
onSelect={addAppKeyword} onSelect={addAppKeyword}
allowedApps={allowedApps} allowedApps={allowedApps}
/> />
<AppTemplatePickerModal
open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}
onSelect={applyTemplate}
allowedApps={allowedApps}
/>
</div> </div>
); );
}; };

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { apiExam, type ExamRoomItem } from '../api'; import { apiExam, type ExamRoomItem } from '../api';
import { openExam } from './NavHistoryBar'; import { openExam } from './NavHistoryBar';
@@ -39,6 +39,7 @@ export const ExamsTab: React.FC = () => {
const [start, setStart] = useState(''); const [start, setStart] = useState('');
const [end, setEnd] = useState(''); const [end, setEnd] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [search, setSearch] = useState('');
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
@@ -52,7 +53,24 @@ export const ExamsTab: React.FC = () => {
} }
}; };
useEffect(() => { load(); }, []); useEffect(() => {
load();
}, []);
const stats = useMemo(() => {
const active = rooms.filter((r) => (r.displayStatus || r.status) === 'active').length;
const upcoming = rooms.filter((r) => {
const st = r.displayStatus || r.status;
return st === 'ready' || st === 'draft';
}).length;
return { total: rooms.length, active, upcoming };
}, [rooms]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rooms;
return rooms.filter((r) => r.name.toLowerCase().includes(q));
}, [rooms, search]);
const create = async () => { const create = async () => {
if (!name.trim() || !start || !end) return; if (!name.trim() || !start || !end) return;
@@ -65,6 +83,9 @@ export const ExamsTab: React.FC = () => {
}); });
setShowCreate(false); setShowCreate(false);
setName(''); setName('');
setStart('');
setEnd('');
await load();
openExam(room.id, room.name); openExam(room.id, room.name);
} catch (e: any) { } catch (e: any) {
alert(e?.message || 'Lỗi'); alert(e?.message || 'Lỗi');
@@ -74,20 +95,47 @@ export const ExamsTab: React.FC = () => {
}; };
return ( return (
<div className="page-stack"> <div className="tab-page exams-page">
<header className="page-header page-header--row"> <header className="page-header page-header--row">
<div> <div>
<h1 className="page-title">Phòng thi</h1> <h1 className="page-title">Danh sách phòng thi</h1>
<p className="page-desc">Tạo phòng thi, chia đ ngẫu nhiên, gửi đ thu bài từ sinh viên.</p> <p className="page-desc">Tạo phòng, chia đ ngẫu nhiên, gửi đ thu bài từ sinh viên.</p>
</div> </div>
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>+ Tạo phòng thi</button> <button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
+ Tạo phòng thi
</button>
</header> </header>
<div className="exams-stats-row">
<div className="learning-stat">
<span className="learning-stat-value">{stats.total}</span>
<span className="learning-stat-label">Tổng phòng</span>
</div>
<div className="learning-stat learning-stat--accent">
<span className="learning-stat-value">{stats.active}</span>
<span className="learning-stat-label">Đang thi</span>
</div>
<div className="learning-stat">
<span className="learning-stat-value">{stats.upcoming}</span>
<span className="learning-stat-label">Sắp / tạm</span>
</div>
</div>
<div className="exams-toolbar">
<input
type="search"
className="app-pool-search exams-search"
placeholder="Tìm theo tên phòng thi..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{showCreate && ( {showCreate && (
<div className="card" style={{ padding: '1.25rem' }}> <div className="system-form-card exams-create-card">
<h2 className="section-title">Phòng thi mới</h2> <h2 className="system-form-title">Phòng thi mới</h2>
<div className="form-grid" style={{ maxWidth: 520 }}> <div className="system-form-grid">
<label className="login-field"> <label className="login-field system-form-full">
<span>Tên phòng thi</span> <span>Tên phòng thi</span>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Thi cuối kỳ Java" /> <input value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Thi cuối kỳ Java" />
</label> </label>
@@ -100,48 +148,56 @@ export const ExamsTab: React.FC = () => {
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} /> <input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
</label> </label>
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}> <div className="system-form-actions">
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>Tạo</button> <div />
<button type="button" className="btn btn-ghost" onClick={() => setShowCreate(false)}>Hủy</button> <div className="system-form-actions-right">
<button type="button" className="btn btn-ghost" onClick={() => setShowCreate(false)}>
Hủy
</button>
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>
{busy ? 'Đang tạo...' : 'Tạo & mở phòng'}
</button>
</div>
</div> </div>
</div> </div>
)} )}
<div className="card table-card"> {loading ? (
{loading ? ( <div className="system-empty">Đang tải...</div>
<div className="table-empty">Đang tải...</div> ) : filtered.length === 0 ? (
) : rooms.length === 0 ? ( <div className="system-empty">
<div className="table-empty">Chưa phòng thi nào.</div> {search.trim() ? 'Không tìm thấy phòng thi phù hợp.' : 'Chưa có phòng thi — bấm Tạo phòng thi để bắt đầu.'}
) : ( </div>
<table className="data-table"> ) : (
<thead> <div className="exams-card-grid">
<tr> {filtered.map((r) => {
<th>Tên</th> const st = statusLabel(r.displayStatus || r.status);
<th>Thời gian</th> return (
<th>SV / Đ</th> <article key={r.id} className="exam-list-card">
<th>Trạng thái</th> <div className="exam-list-card-head">
<th></th> <h3>{r.name}</h3>
</tr> <span className={`badge ${st.cls}`}>{st.text}</span>
</thead> </div>
<tbody> <div className="exam-list-card-meta">
{rooms.map((r) => { <div>
const st = statusLabel(r.displayStatus || r.status); <span className="exam-list-card-label">Thời gian</span>
return ( <span>{fmtTime(r.startTime)}</span>
<tr key={r.id}> <span className="exam-list-card-sep"></span>
<td><strong>{r.name}</strong></td> <span>{fmtTime(r.endTime)}</span>
<td style={{ fontSize: '0.82rem' }}>{fmtTime(r.startTime)} {fmtTime(r.endTime)}</td> </div>
<td>{r.studentCount} SV · {r.paperCount} đ</td> <div>
<td><span className={`badge ${st.cls}`}>{st.text}</span></td> <span className="exam-list-card-label">Quy </span>
<td> <span>{r.studentCount} sinh viên · {r.paperCount} đ</span>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => openExam(r.id, r.name)}>Mở</button> </div>
</td> </div>
</tr> <button type="button" className="btn btn-primary btn-sm exam-list-card-open" onClick={() => openExam(r.id, r.name)}>
); Mở phòng thi
})} </button>
</tbody> </article>
</table> );
)} })}
</div> </div>
)}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,186 @@
import { useCallback, useEffect, useState } from 'react';
import { apiGitHubRepos, type GitHubRepoItem } from '../api';
export function GitHubReposPanel({ connected, canDeleteRepos = true }: { connected: boolean; canDeleteRepos?: boolean }) {
const [repos, setRepos] = useState<GitHubRepoItem[]>([]);
const [githubLogin, setGithubLogin] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [deleting, setDeleting] = useState(false);
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
const load = useCallback(async () => {
if (!connected) {
setRepos([]);
return;
}
setLoading(true);
setErr('');
try {
const res = await apiGitHubRepos.list();
setRepos(res.data || []);
setGithubLogin(res.githubLogin || '');
setSelected(new Set());
} catch (e: any) {
setErr(e?.message || 'Không tải repo');
} finally {
setLoading(false);
}
}, [connected]);
useEffect(() => { load().catch(console.error); }, [load]);
const toggle = (key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleAll = () => {
if (selected.size === repos.length) setSelected(new Set());
else setSelected(new Set(repos.map((r) => r.key)));
};
const deleteSelected = async () => {
if (selected.size === 0) return;
if (!canDeleteRepos) {
setErr('Chưa có quyền xóa repo — bấm Cấp quyền xóa repo ở trên.');
return;
}
if (!confirm(`Xóa ${selected.size} repo trên GitHub? Hành động không hoàn tác.`)) return;
setErr(''); setMsg('');
setDeleting(true);
try {
const res = await apiGitHubRepos.bulkDelete([...selected]);
if (res.deleted > 0) {
setMsg(res.message || `Đã xóa ${res.deleted} repo`);
if (res.failures?.length) setErr(res.failures.join('; '));
} else {
setMsg('');
setErr(res.failures?.join('; ') || 'Không xóa được repo nào — thử ngắt kết nối rồi kết nối lại GitHub.');
}
await load();
} catch (e: any) {
setErr(e?.message || 'Xóa thất bại');
} finally {
setDeleting(false);
}
};
const deleteOne = async (repo: GitHubRepoItem) => {
if (!canDeleteRepos) {
setErr('Chưa có quyền xóa repo — bấm Cấp quyền xóa repo ở trên.');
return;
}
if (!confirm(`Xóa repo "${repo.repoName}" trên GitHub?`)) return;
setErr(''); setMsg('');
setDeleting(true);
try {
const res = await apiGitHubRepos.bulkDelete([repo.key]);
if (res.deleted > 0) {
setMsg(res.message || 'Đã xóa repo');
} else {
setMsg('');
setErr(res.failures?.join('; ') || 'Không xóa được repo — thử ngắt kết nối rồi kết nối lại GitHub.');
}
await load();
} catch (e: any) {
setErr(e?.message || 'Xóa thất bại');
} finally {
setDeleting(false);
}
};
if (!connected) return null;
return (
<div className="github-repos-panel">
<p className="exam-git-hint" style={{ marginTop: 0 }}>
Toàn bộ repo trên tài khoản GitHub{githubLogin ? ` @${githubLogin}` : ''} thể chọn xóa nhiều repo cùng lúc.
</p>
<div className="github-repos-toolbar">
<button type="button" className="btn btn-secondary btn-sm" disabled={loading} onClick={() => load()}>
{loading ? 'Đang tải từ GitHub...' : 'Làm mới'}
</button>
{repos.length > 0 && (
<>
<button type="button" className="btn btn-secondary btn-sm" onClick={toggleAll}>
{selected.size === repos.length ? 'Bỏ chọn' : 'Chọn tất cả'}
</button>
<button
type="button"
className="btn btn-secondary btn-sm learning-btn-danger"
disabled={deleting || selected.size === 0 || !canDeleteRepos}
onClick={deleteSelected}
>
{deleting ? 'Đang xóa...' : `Xóa đã chọn (${selected.size})`}
</button>
</>
)}
</div>
{err && <div className="login-error">{err}</div>}
{msg && <div className="login-success">{msg}</div>}
{repos.length === 0 && !loading ? (
<p className="exam-git-hint">Không repo nào trên tài khoản GitHub (hoặc token thiếu quyền đc repo).</p>
) : (
<div className="github-repos-table-wrap table-wrapper">
<table className="data-table github-repos-table">
<thead>
<tr>
<th style={{ width: 36 }} />
<th>Repo</th>
<th>Loại</th>
<th>Phòng / SV</th>
<th>Cập nhật</th>
<th />
</tr>
</thead>
<tbody>
{repos.map((r) => (
<tr key={r.key}>
<td>
<input type="checkbox" checked={selected.has(r.key)} onChange={() => toggle(r.key)} />
</td>
<td>
<a href={r.repoHtmlUrl} target="_blank" rel="noreferrer" className="github-repo-link">
{r.repoName}
</a>
{r.private && <span className="badge badge-muted" style={{ marginLeft: 6 }}>private</span>}
</td>
<td>
{r.publishMode === 'student' ? (
<span className="badge badge-info">Từng SV</span>
) : r.publishMode === 'room' ? (
<span className="badge badge-muted">Gộp phòng</span>
) : r.fromSimpleCare ? (
<span className="badge badge-muted">Simple Care</span>
) : (
<span className="badge badge-muted">Khác</span>
)}
</td>
<td style={{ fontSize: '0.85rem' }}>
{r.examRoomName && <div>{r.examRoomName}</div>}
{r.studentLabel && <div className="text-muted">{r.studentLabel}</div>}
{!r.examRoomName && !r.studentLabel && '—'}
</td>
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{r.updatedAt ? new Date(r.updatedAt).toLocaleString('vi-VN') : new Date(r.createdAt).toLocaleString('vi-VN')}
</td>
<td style={{ textAlign: 'right' }}>
<button type="button" className="btn btn-ghost btn-sm learning-btn-danger" disabled={deleting || !canDeleteRepos} onClick={() => deleteOne(r)}>
Xóa
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
TAB_LABELS, TAB_LABELS,
SYSTEM_SECTION_LABELS,
type NavEntry, type NavEntry,
type TabId, type TabId,
parseRoute, parseRoute,
@@ -24,7 +25,10 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
return () => window.removeEventListener('popstate', refresh); return () => window.removeEventListener('popstate', refresh);
}, []); }, []);
const currentTabLabel = TAB_LABELS[route.tab]; const currentTabLabel =
route.tab === 'system'
? `Hệ thống · ${SYSTEM_SECTION_LABELS[route.systemSection]}`
: TAB_LABELS[route.tab];
const recent = history.slice(0, 6); const recent = history.slice(0, 6);
const handlePillClick = (entry: NavEntry) => { const handlePillClick = (entry: NavEntry) => {
@@ -32,6 +36,8 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
navigate(entry.tab, entry.classId, entry.label, 'class'); navigate(entry.tab, entry.classId, entry.label, 'class');
} else if (entry.kind === 'exam' && entry.examId) { } else if (entry.kind === 'exam' && entry.examId) {
navigate(entry.tab, entry.examId, entry.label, 'exam'); navigate(entry.tab, entry.examId, entry.label, 'exam');
} else if (entry.tab === 'system' && entry.systemSection) {
navigate('system', null, undefined, 'class', entry.systemSection);
} else { } else {
navigate(entry.tab); navigate(entry.tab);
} }
@@ -44,6 +50,9 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
if (entry.kind === 'exam' && route.examId) { if (entry.kind === 'exam' && route.examId) {
return entry.examId === route.examId; return entry.examId === route.examId;
} }
if (entry.kind === 'tab' && route.tab === 'system') {
return !route.classId && !route.examId && entry.tab === 'system' && entry.systemSection === route.systemSection;
}
return entry.kind === 'tab' && !route.classId && !route.examId && entry.tab === route.tab; return entry.kind === 'tab' && !route.classId && !route.examId && entry.tab === route.tab;
}; };
@@ -61,7 +70,13 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
Simple Care Simple Care
</button> </button>
<span className="breadcrumb-sep">/</span> <span className="breadcrumb-sep">/</span>
<button type="button" className="breadcrumb-link" onClick={() => navigate(route.tab)}> <button type="button" className="breadcrumb-link" onClick={() => {
if (route.tab === 'system') {
navigate('system', null, undefined, 'class', route.systemSection);
} else {
navigate(route.tab);
}
}}>
{currentTabLabel} {currentTabLabel}
</button> </button>
{route.classId && classLabel && ( {route.classId && classLabel && (

View File

@@ -16,7 +16,7 @@ function formatBssid(raw: string): string | null {
return key.match(/.{2}/g)!.join(':'); return key.match(/.{2}/g)!.join(':');
} }
export const NetworkTab: React.FC = () => { export const NetworkSection: React.FC = () => {
const [acceptedItems, setAcceptedItems] = useState<WifiAcceptItem[]>([]); const [acceptedItems, setAcceptedItems] = useState<WifiAcceptItem[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -31,8 +31,8 @@ export const NetworkTab: React.FC = () => {
const res = await apiFetchAcceptedWifis(); const res = await apiFetchAcceptedWifis();
setAcceptedItems( setAcceptedItems(
(res.data || []) (res.data || [])
.filter(r => r.ssid && r.bssid) .filter((r) => r.ssid && r.bssid)
.map(r => ({ ssid: r.ssid, bssid: r.bssid })) .map((r) => ({ ssid: r.ssid, bssid: r.bssid })),
); );
} catch { } catch {
setAcceptedItems([]); setAcceptedItems([]);
@@ -50,15 +50,15 @@ export const NetworkTab: React.FC = () => {
const trimmedBssid = bssid.trim(); const trimmedBssid = bssid.trim();
const key = bssidKey(trimmedBssid); const key = bssidKey(trimmedBssid);
if (!trimmedSsid || key.length !== 12) return; if (!trimmedSsid || key.length !== 12) return;
setAcceptedItems(prev => { setAcceptedItems((prev) => {
if (prev.some(item => bssidKey(item.bssid) === key)) return prev; if (prev.some((item) => bssidKey(item.bssid) === key)) return prev;
return [...prev, { ssid: trimmedSsid, bssid: trimmedBssid }]; return [...prev, { ssid: trimmedSsid, bssid: trimmedBssid }];
}); });
}; };
const removeWifi = (bssid: string) => { const removeWifi = (bssid: string) => {
const key = bssidKey(bssid); const key = bssidKey(bssid);
setAcceptedItems(prev => prev.filter(item => bssidKey(item.bssid) !== key)); setAcceptedItems((prev) => prev.filter((item) => bssidKey(item.bssid) !== key));
}; };
const handleManualAdd = () => { const handleManualAdd = () => {
@@ -73,11 +73,11 @@ export const NetworkTab: React.FC = () => {
return; return;
} }
const key = bssidKey(bssid); const key = bssidKey(bssid);
if (acceptedItems.some(item => bssidKey(item.bssid) === key)) { if (acceptedItems.some((item) => bssidKey(item.bssid) === key)) {
setManualError('BSSID này đã có trong danh sách.'); setManualError('BSSID này đã có trong danh sách.');
return; return;
} }
setAcceptedItems(prev => [...prev, { ssid, bssid }]); setAcceptedItems((prev) => [...prev, { ssid, bssid }]);
setManualSsid(''); setManualSsid('');
setManualBssid(''); setManualBssid('');
setManualError(null); setManualError(null);
@@ -96,41 +96,36 @@ export const NetworkTab: React.FC = () => {
} }
}; };
const acceptedBssidKeys = acceptedItems.map(item => bssidKey(item.bssid)).join(','); const acceptedBssidKeys = acceptedItems.map((item) => bssidKey(item.bssid)).join(',');
return ( return (
<div className="page-container"> <div className="system-section system-section--grid">
<div className="page-header"> <div className="system-block">
<div> <div className="system-block-head">
<h1 className="page-title">Quản mạng</h1> <div>
<p className="page-subtitle"> <h2 className="system-block-title">Gói mạng đưc chấp nhận</h2>
Cấu hình điểm phát WiFi đưc phép (SSID + BSSID/MAC) áp dụng <strong>toàn hệ thống</strong>. <p className="system-block-desc">
Sinh viên không thể giả mạo bằng hotspot trùng tên. SSID + BSSID (MAC điểm phát) áp dụng toàn hệ thống. Hotspot giả trùng tên nhưng MAC khác sẽ bị chặn.
</p> </p>
</div>
<span className="system-stat-pill">{acceptedItems.length} điểm phát</span>
</div> </div>
</div>
<div className="network-grid"> <form
<div className="card config-card-flat"> className="network-manual-form"
<div className="card-header-title">WiFi đưc chấp nhận</div> onSubmit={(e) => {
<p className="config-card-desc"> e.preventDefault();
Chọn từ kho WiFi hoặc thêm thủ công SSID + BSSID. Đ trống = chưa bật kiểm tra WiFi. handleManualAdd();
</p> }}
>
<form <p className="network-manual-hint">Thêm thủ công (router, lệnh netsh...)</p>
className="network-manual-form" <div className="system-inline-form system-inline-form--stack">
onSubmit={e => {
e.preventDefault();
handleManualAdd();
}}
>
<p className="network-manual-hint">Thêm thủ công khi cần (vd: lấy BSSID từ router hoặc lệnh netsh)</p>
<input <input
type="text" type="text"
className="app-pool-search" className="app-pool-search"
placeholder="SSID (tên WiFi)" placeholder="SSID (tên WiFi)"
value={manualSsid} value={manualSsid}
onChange={e => { onChange={(e) => {
setManualSsid(e.target.value); setManualSsid(e.target.value);
setManualError(null); setManualError(null);
}} }}
@@ -141,7 +136,7 @@ export const NetworkTab: React.FC = () => {
className="app-pool-search" className="app-pool-search"
placeholder="BSSID / MAC (f0:61:c0:b0:fd:d2)" placeholder="BSSID / MAC (f0:61:c0:b0:fd:d2)"
value={manualBssid} value={manualBssid}
onChange={e => { onChange={(e) => {
setManualBssid(e.target.value); setManualBssid(e.target.value);
setManualError(null); setManualError(null);
}} }}
@@ -151,17 +146,19 @@ export const NetworkTab: React.FC = () => {
<button type="submit" className="btn btn-secondary" disabled={loading}> <button type="submit" className="btn btn-secondary" disabled={loading}>
+ Thêm thủ công + Thêm thủ công
</button> </button>
{manualError && <p className="network-manual-error">{manualError}</p>} </div>
</form> {manualError && <p className="network-manual-error">{manualError}</p>}
</form>
{loading ? ( {loading ? (
<div className="app-pool-status">Đang tải...</div> <div className="system-empty">Đang tải...</div>
) : acceptedItems.length === 0 ? ( ) : acceptedItems.length === 0 ? (
<div className="app-pool-status" style={{ marginBottom: '0.75rem' }}> <div className="system-empty system-empty--muted">
Chưa điểm phát nào. Mở kho WiFi đ thêm từ máy sinh viên đang kết nối đúng mạng trường. Chưa điểm phát. Mở kho WiFi đ thêm từ máy sinh viên đang kết nối đúng mạng trường.
</div> </div>
) : ( ) : (
<table className="data-table app-pool-table" style={{ marginBottom: '0.75rem' }}> <div className="system-table-wrap">
<table className="data-table app-pool-table">
<thead> <thead>
<tr> <tr>
<th>SSID</th> <th>SSID</th>
@@ -170,7 +167,7 @@ export const NetworkTab: React.FC = () => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{acceptedItems.map(item => ( {acceptedItems.map((item) => (
<tr key={bssidKey(item.bssid)}> <tr key={bssidKey(item.bssid)}>
<td><code className="app-pool-kw">{item.ssid}</code></td> <td><code className="app-pool-kw">{item.ssid}</code></td>
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}> <td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
@@ -179,7 +176,7 @@ export const NetworkTab: React.FC = () => {
<td> <td>
<button <button
type="button" type="button"
className="btn btn-secondary app-pool-add-btn" className="btn btn-ghost btn-sm"
onClick={() => removeWifi(item.bssid)} onClick={() => removeWifi(item.bssid)}
> >
Xóa Xóa
@@ -189,29 +186,28 @@ export const NetworkTab: React.FC = () => {
))} ))}
</tbody> </tbody>
</table> </table>
)}
<div className="network-actions">
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
Mở kho WiFi
</button>
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
{saving ? 'Đang lưu...' : 'Lưu cấu hình'}
</button>
</div> </div>
</div> )}
<div className="card config-card-flat network-info-card"> <div className="network-actions">
<div className="card-header-title">Cách hoạt đng</div> <button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
<ul className="network-info-list"> Mở kho WiFi
<li>Sinh viên kết nối WiFi app gửi <strong>SSID + BSSID</strong> (MAC điểm phát) lên kho</li> </button>
<li>Thầy chọn từ kho hoặc <strong>thêm thủ công</strong> SSID + BSSID khi cần</li> <button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
<li>Hotspot giả trùng tên nhưng MAC khác app báo lỗi thoát</li> {saving ? 'Đang lưu...' : 'Lưu cấu hình'}
<li>Chưa cấu hình không chặn (đ thu thập kho trước)</li> </button>
</ul>
</div> </div>
</div> </div>
<div className="system-block system-block--info">
<h3 className="system-info-title">Cách hoạt đng</h3>
<ul className="network-info-list">
<li>Sinh viên kết nối WiFi app gửi <strong>SSID + BSSID</strong> lên kho</li>
<li>Chọn từ kho hoặc thêm thủ công SSID + BSSID</li>
<li>Chưa cấu hình không chặn (đ thu thập kho trước)</li>
</ul>
</div>
<WifiPoolModal <WifiPoolModal
open={poolOpen} open={poolOpen}
onClose={() => setPoolOpen(false)} onClose={() => setPoolOpen(false)}
@@ -221,3 +217,13 @@ export const NetworkTab: React.FC = () => {
</div> </div>
); );
}; };
/** @deprecated Dùng SystemTab → Gói mạng */
export const NetworkTab: React.FC = () => (
<div className="tab-page">
<header className="page-header">
<h1 className="page-title">Quản mạng</h1>
</header>
<NetworkSection />
</div>
);

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react';
import { apiAdmin, type EmailDomainItem } from '../api';
export function OrganizationSection() {
const [domains, setDomains] = useState<EmailDomainItem[]>([]);
const [newDomain, setNewDomain] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const load = async () => {
setLoading(true);
try {
const res = await apiAdmin.listEmailDomains();
setDomains(res.data);
} finally {
setLoading(false);
}
};
useEffect(() => {
load().catch(console.error);
}, []);
const addDomain = async () => {
if (!newDomain.trim()) return;
setBusy(true);
try {
await apiAdmin.addEmailDomain(newDomain.trim());
setNewDomain('');
await load();
} catch (e: any) {
alert(e?.message || 'Không thêm được');
} finally {
setBusy(false);
}
};
const removeDomain = async (id: number) => {
if (!confirm('Xóa đuôi email này?')) return;
try {
await apiAdmin.deleteEmailDomain(id);
await load();
} catch (e: any) {
alert(e?.message || 'Không xóa được');
}
};
return (
<div className="system-section">
<div className="system-block">
<div className="system-block-head">
<div>
<h2 className="system-block-title">Đuôi email đưc phép</h2>
<p className="system-block-desc">
Giới hạn tài khoản giáo viên / nhân sự theo tổ chức. Chưa cấu hình = chấp nhận mọi đuôi email.
</p>
</div>
<span className="system-stat-pill">{domains.length} đuôi</span>
</div>
<div className="system-inline-form">
<input
placeholder="vd: rikkeiacademy.com"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
/>
<button type="button" className="btn btn-primary" onClick={addDomain} disabled={busy}>
Thêm đuôi
</button>
</div>
{loading ? (
<div className="system-empty">Đang tải...</div>
) : domains.length === 0 ? (
<div className="system-empty system-empty--muted">
Chưa giới hạn đuôi email mọi đa chỉ đu thể đăng .
</div>
) : (
<ul className="system-domain-list">
{domains.map((d) => (
<li key={d.id} className="system-domain-item">
<span className="system-domain-label">@{d.domain}</span>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => removeDomain(d.id)}>
Xóa
</button>
</li>
))}
</ul>
)}
</div>
<div className="system-block system-block--info">
<h3 className="system-info-title">Gợi ý</h3>
<ul className="network-info-list">
<li>Thêm từng đuôi email thuộc tổ chức (vd: <code>rikkei.edu.vn</code>)</li>
<li>Sinh viên đăng nhập client không bị nh hưởng bởi cấu hình này</li>
<li>Chỉ áp dụng cho tài khoản quản trên portal này</li>
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useState } from 'react';
import {
navigateSystem,
parseRoute,
SYSTEM_SECTION_LABELS,
type SystemSection,
} from '../navigation';
import { OrganizationSection } from './OrganizationSection';
import { NetworkSection } from './NetworkSection';
import { AppTemplatesSection } from './AppTemplatesSection';
const SECTIONS: { id: SystemSection; icon: React.ReactNode; hint: string }[] = [
{
id: 'organization',
hint: 'Đuôi email & tổ chức',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
</svg>
),
},
{
id: 'network',
hint: 'WiFi được phép',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
<circle cx="12" cy="20" r="1" />
</svg>
),
},
{
id: 'templates',
hint: 'Bộ keyword app',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
),
},
];
export const SystemTab: React.FC = () => {
const [section, setSection] = useState<SystemSection>(() => parseRoute().systemSection);
useEffect(() => {
const sync = () => setSection(parseRoute().systemSection);
window.addEventListener('popstate', sync);
return () => window.removeEventListener('popstate', sync);
}, []);
const selectSection = (id: SystemSection) => {
setSection(id);
navigateSystem(id);
};
return (
<div className="tab-page system-page">
<header className="page-header">
<h1 className="page-title">Quản hệ thống</h1>
<p className="page-desc">
Cấu hình tổ chức, mạng WiFi khung ng dụng dùng chung cho toàn hệ thống.
</p>
</header>
<nav className="system-subnav" aria-label="Mục hệ thống">
{SECTIONS.map((s) => (
<button
key={s.id}
type="button"
className={`system-subnav-btn ${section === s.id ? 'active' : ''}`}
onClick={() => selectSection(s.id)}
>
<span className="system-subnav-icon">{s.icon}</span>
<span className="system-subnav-text">
<strong>{SYSTEM_SECTION_LABELS[s.id]}</strong>
<small>{s.hint}</small>
</span>
</button>
))}
</nav>
<div className="system-panel">
{section === 'organization' && <OrganizationSection />}
{section === 'network' && <NetworkSection />}
{section === 'templates' && <AppTemplatesSection />}
</div>
</div>
);
};

View File

@@ -0,0 +1,91 @@
import { useCallback, useEffect, useState } from 'react';
import { apiGitHub } from '../api';
export function useGitHubConnection() {
const [connected, setConnected] = useState(false);
const [githubLogin, setGithubLogin] = useState('');
const [canDeleteRepos, setCanDeleteRepos] = useState(false);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const refresh = useCallback(async () => {
try {
const st = await apiGitHub.status();
setConnected(!!st.connected);
setGithubLogin(st.githubLogin || '');
setCanDeleteRepos(!!st.canDeleteRepos);
} catch (e) {
console.error(e);
setConnected(false);
setGithubLogin('');
setCanDeleteRepos(false);
}
}, []);
useEffect(() => {
refresh().catch(console.error);
}, [refresh]);
useEffect(() => {
const onMessage = (e: MessageEvent) => {
const data = e.data as { type?: string; ok?: boolean; detail?: string };
if (data?.type !== 'simple-care-github-connected') return;
setConnecting(false);
if (data.ok) {
refresh().catch(console.error);
setMessage(typeof data.detail === 'string' ? `Đã kết nối GitHub @${data.detail}` : 'Đã kết nối GitHub');
setError('');
} else {
setError(typeof data.detail === 'string' ? data.detail : 'Kết nối GitHub thất bại');
}
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [refresh]);
const connect = async () => {
setError('');
setMessage('');
setConnecting(true);
try {
const { authorizeUrl } = await apiGitHub.authorizeUrl();
const w = window.open(authorizeUrl, 'simple_care_github_oauth', 'width=720,height=760');
if (!w) {
setConnecting(false);
setError('Trình duyệt chặn popup — cho phép popup rồi thử lại.');
}
} catch (e: any) {
setConnecting(false);
setError(e?.message || 'Không mở được OAuth GitHub');
}
};
const disconnect = async () => {
setError('');
setMessage('');
try {
await apiGitHub.disconnect();
setConnected(false);
setGithubLogin('');
setCanDeleteRepos(false);
setMessage('Đã ngắt kết nối GitHub');
} catch (e: any) {
setError(e?.message || 'Ngắt kết nối thất bại');
}
};
return {
connected,
githubLogin,
canDeleteRepos,
connecting,
error,
message,
setError,
setMessage,
refresh,
connect,
disconnect,
};
}

View File

@@ -2102,6 +2102,47 @@ input:checked + .slider:before {
font-weight: 600; font-weight: 600;
} }
/* Thanh thao tác hàng loạt */
.attendance-bulk-actions {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.25rem;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-color, #dee2e6);
border-radius: 8px;
margin-bottom: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
animation: attendanceSlideDown 0.2s ease-out;
}
.attendance-bulk-title {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-color, #212529);
}
.attendance-bulk-btns {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.attendance-bulk-btn-close {
margin-left: auto;
}
@keyframes attendanceSlideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Trạng thái điểm danh — màu theo status 0..4 */ /* Trạng thái điểm danh — màu theo status 0..4 */
.attendance-status--0, .attendance-status--0,
.attendance-summary-chip.attendance-status--0 { .attendance-summary-chip.attendance-status--0 {
@@ -2156,6 +2197,10 @@ input:checked + .slider:before {
background: var(--att-bg); background: var(--att-bg);
} }
.attendance-table tbody tr:hover {
filter: brightness(0.97);
}
.attendance-student-name { .attendance-student-name {
font-weight: 700; font-weight: 700;
font-size: 0.88rem; font-size: 0.88rem;
@@ -4122,6 +4167,131 @@ input:checked + .slider:before {
cursor: pointer; cursor: pointer;
} }
.exam-papers-view-modal {
width: min(560px, 94vw);
max-height: 85vh;
display: flex;
flex-direction: column;
}
.exam-papers-view-body {
padding: 0 1.25rem 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.exam-paper-view-card {
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
padding: 0.85rem 1rem;
background: var(--bg-subtle);
}
.exam-paper-view-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.exam-paper-view-resources {
margin: 0.65rem 0 0;
padding-left: 1.1rem;
font-size: 0.88rem;
color: var(--text-secondary);
}
.exam-paper-view-resources li {
margin-bottom: 0.35rem;
}
.exam-paper-view-actions {
margin-left: 0.5rem;
display: inline-flex;
gap: 0.5rem;
}
.exam-pdf-viewer-overlay {
z-index: 2000;
}
.exam-pdf-viewer-modal {
width: min(960px, 96vw);
height: min(90vh, 900px);
display: flex;
flex-direction: column;
background: var(--bg-card);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-lg);
}
.exam-pdf-viewer-header {
flex-shrink: 0;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border-light);
}
.exam-pdf-viewer-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
background: #525659;
padding: 1rem 0;
min-height: 0;
}
.exam-pdf-viewer-status {
text-align: center;
color: #fff;
padding: 2rem 1rem;
margin: 0;
}
.exam-pdf-page-wrap {
display: flex;
justify-content: center;
margin-bottom: 12px;
padding: 0 12px;
}
.exam-pdf-page {
display: block;
max-width: 100%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
background: #fff;
}
.exam-pdf-viewer-frame {
flex: 1;
width: 100%;
border: none;
background: #525659;
}
.exam-inline-resource-modal {
height: min(85vh, 800px);
}
.exam-inline-resource-body {
flex: 1;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-subtle);
min-height: 0;
}
.exam-inline-resource-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.exam-send-row { .exam-send-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -4197,7 +4367,7 @@ input:checked + .slider:before {
.exam-student-hit { .exam-student-hit {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto auto 1fr auto;
align-items: center; align-items: center;
gap: 0.85rem; gap: 0.85rem;
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
@@ -4207,11 +4377,35 @@ input:checked + .slider:before {
transition: var(--transition); transition: var(--transition);
} }
.exam-student-hit--added {
opacity: 0.85;
}
.exam-student-hit--selected {
border-color: var(--accent);
background-color: var(--accent-light, #fff5f5);
}
.exam-student-hit:hover { .exam-student-hit:hover {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 2px 10px rgba(187, 33, 38, 0.08); box-shadow: 0 2px 10px rgba(187, 33, 38, 0.08);
} }
.exam-student-hit-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #fff5f5 0%, #fee2e2 100%);
color: var(--accent);
font-weight: 700;
font-size: 0.82rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid rgba(187, 33, 38, 0.12);
}
.exam-student-hit-info { .exam-student-hit-info {
min-width: 0; min-width: 0;
} }
@@ -4246,3 +4440,668 @@ input:checked + .slider:before {
justify-content: center; justify-content: center;
} }
} }
.exam-submissions-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-light);
background: #fafafa;
}
.exam-submissions-toolbar-hint {
font-size: 0.78rem;
color: var(--text-muted);
flex: 1;
min-width: 200px;
}
.exam-git-settings {
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px dashed var(--border-light);
}
.exam-git-oauth-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.65rem;
margin-bottom: 0.65rem;
}
.exam-git-connected {
font-size: 0.85rem;
color: var(--text-secondary);
}
.exam-git-connected strong {
color: var(--text-primary);
}
.exam-git-status-line {
margin: 0 0 0.65rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.exam-git-not-connected {
color: var(--danger);
}
.github-repos-panel {
margin-top: 0.75rem;
}
.github-repos-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.github-repos-table-wrap {
max-height: 360px;
overflow: auto;
}
.github-repo-link {
color: var(--accent);
font-weight: 500;
word-break: break-all;
}
.section-subtitle {
font-size: 0.95rem;
font-weight: 600;
margin: 0;
}
.exam-git-branch-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.45rem;
}
.exam-git-branch-label {
font-size: 0.8rem;
color: var(--text-secondary);
margin: 0;
}
.exam-git-branch-input {
width: 120px;
padding: 0.4rem 0.55rem;
font-size: 0.85rem;
}
.exam-git-hint {
margin: 0.5rem 0 0;
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.45;
}
.exam-git-hint code {
font-size: 0.72rem;
}
.exam-git-last {
margin: 0.45rem 0 0;
font-size: 0.78rem;
word-break: break-all;
}
.exam-git-last a {
color: var(--accent);
}
/* ── Mobile sidebar drawer ── */
.mobile-menu-btn {
display: none;
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 30;
width: 42px;
height: 42px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
background: var(--bg-card);
color: var(--text-primary);
box-shadow: var(--shadow-sm);
align-items: center;
justify-content: center;
cursor: pointer;
}
.mobile-menu-btn svg {
width: 22px;
height: 22px;
}
.sidebar-backdrop {
display: none;
position: fixed;
inset: 0;
z-index: 19;
background: rgba(15, 23, 42, 0.45);
border: none;
cursor: pointer;
}
.nav-sub {
list-style: none;
margin: 0 0 0.35rem 0;
padding: 0 0 0 2.15rem;
}
.nav-sub .nav-btn {
font-size: 0.82rem;
padding: 0.45rem 0.65rem;
min-height: auto;
}
.nav-sub .nav-btn::before {
display: none;
}
.nav-sub .nav-btn.active {
background: var(--accent-light);
color: var(--accent);
font-weight: 600;
}
@media (max-width: 900px) {
.mobile-menu-btn {
display: inline-flex;
}
.sidebar-backdrop {
display: block;
}
.sidebar {
transform: translateX(-110%);
transition: transform 0.28s ease;
z-index: 25;
box-shadow: var(--shadow-md);
}
.sidebar.sidebar--open {
transform: translateX(0);
}
.main-content {
margin-left: 0;
padding: 3.75rem 0.85rem 1rem;
}
}
/* ── System management ── */
.system-page {
overflow-y: auto;
padding-bottom: 1.5rem;
}
.system-subnav {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.65rem;
margin-bottom: 1.25rem;
}
.system-subnav-btn {
display: flex;
align-items: flex-start;
gap: 0.65rem;
text-align: left;
padding: 0.85rem 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-card);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.system-subnav-btn:hover {
border-color: rgba(198, 40, 40, 0.35);
}
.system-subnav-btn.active {
border-color: var(--accent);
background: var(--accent-light);
box-shadow: 0 0 0 1px rgba(198, 40, 40, 0.12);
}
.system-subnav-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
background: var(--bg-subtle);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--accent);
}
.system-subnav-btn.active .system-subnav-icon {
background: rgba(198, 40, 40, 0.12);
}
.system-subnav-icon svg {
width: 18px;
height: 18px;
}
.system-subnav-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.system-subnav-text strong {
font-size: 0.88rem;
color: var(--text-primary);
}
.system-subnav-text small {
font-size: 0.72rem;
color: var(--text-muted);
}
.system-panel {
min-height: 200px;
}
.system-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.system-section--grid {
display: grid;
grid-template-columns: 1fr 280px;
gap: 1rem;
align-items: start;
}
.system-block {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 1.15rem 1.25rem;
box-shadow: var(--shadow-sm);
}
.system-block--info {
background: var(--bg-subtle);
}
.system-block-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.system-block-title {
margin: 0 0 0.35rem;
font-size: 1.05rem;
font-weight: 700;
}
.system-block-desc {
margin: 0;
font-size: 0.84rem;
color: var(--text-muted);
line-height: 1.5;
max-width: 52rem;
}
.system-stat-pill {
flex-shrink: 0;
padding: 0.25rem 0.65rem;
border-radius: 99px;
font-size: 0.75rem;
font-weight: 700;
background: var(--bg-subtle);
border: 1px solid var(--border-color);
color: var(--text-muted);
}
.system-inline-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.system-inline-form input {
flex: 1;
min-width: 0;
}
.system-inline-form--stack {
flex-direction: column;
}
.system-empty {
padding: 1.25rem;
text-align: center;
color: var(--text-muted);
font-size: 0.88rem;
border: 1px dashed var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-subtle);
}
.system-empty--muted {
background: transparent;
}
.system-domain-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.system-domain-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem 0.85rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-subtle);
}
.system-domain-label {
font-weight: 600;
font-family: ui-monospace, monospace;
font-size: 0.88rem;
}
.system-info-title {
margin: 0 0 0.65rem;
font-size: 0.92rem;
font-weight: 700;
}
.system-form-card {
background: var(--bg-subtle);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 1rem 1.15rem;
margin-bottom: 1rem;
}
.system-form-title {
margin: 0 0 0.85rem;
font-size: 0.95rem;
font-weight: 700;
}
.system-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.system-form-full {
grid-column: 1 / -1;
}
.system-form-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.85rem;
flex-wrap: wrap;
}
.system-form-actions-right {
display: flex;
gap: 0.5rem;
}
.system-table-wrap {
overflow-x: auto;
margin-bottom: 0.75rem;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.75rem;
}
.template-card {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 0.9rem 1rem;
background: var(--bg-subtle);
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.template-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.template-card-head h3 {
margin: 0;
font-size: 0.95rem;
}
.template-card-badge {
font-size: 0.7rem;
font-weight: 700;
padding: 0.15rem 0.45rem;
border-radius: 99px;
background: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-muted);
}
.template-card-desc {
margin: 0;
font-size: 0.8rem;
color: var(--text-muted);
}
.template-card-kw {
display: block;
font-size: 0.72rem;
line-height: 1.45;
padding: 0.45rem 0.55rem;
border-radius: var(--radius-sm);
background: var(--bg-card);
border: 1px solid var(--border-light);
word-break: break-word;
max-height: 4.5rem;
overflow: hidden;
}
.template-card-actions {
display: flex;
gap: 0.35rem;
margin-top: 0.25rem;
}
.template-picker-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.template-picker-card {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 0.85rem 1rem;
background: var(--bg-subtle);
}
.template-picker-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.template-picker-count {
font-size: 0.72rem;
color: var(--text-muted);
font-weight: 600;
}
.template-picker-desc {
margin: 0 0 0.35rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.template-picker-kw {
display: block;
font-size: 0.72rem;
margin-bottom: 0.65rem;
word-break: break-word;
}
.template-picker-apply {
width: 100%;
justify-content: center;
}
.app-pool-open-actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
justify-content: flex-end;
}
/* ── Exams list cards ── */
.exams-page {
overflow-y: auto;
padding-bottom: 1.5rem;
}
.exams-stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
.exams-toolbar {
margin-bottom: 1rem;
}
.exams-search {
max-width: 420px;
}
.exams-create-card {
margin-bottom: 1rem;
}
.exams-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.85rem;
}
.exam-list-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 1rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
box-shadow: var(--shadow-sm);
transition: border-color 0.15s, box-shadow 0.15s;
}
.exam-list-card:hover {
border-color: rgba(198, 40, 40, 0.3);
box-shadow: var(--shadow-md);
}
.exam-list-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.65rem;
}
.exam-list-card-head h3 {
margin: 0;
font-size: 1rem;
line-height: 1.35;
}
.exam-list-card-meta {
display: flex;
flex-direction: column;
gap: 0.45rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.exam-list-card-meta > div {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
.exam-list-card-label {
font-weight: 700;
color: var(--text-muted);
margin-right: 0.25rem;
}
.exam-list-card-sep {
color: var(--text-muted);
}
.exam-list-card-open {
width: 100%;
justify-content: center;
}
@media (max-width: 900px) {
.system-subnav {
grid-template-columns: 1fr;
}
.system-section--grid {
grid-template-columns: 1fr;
}
.system-form-grid {
grid-template-columns: 1fr;
}
.exams-stats-row {
grid-template-columns: 1fr;
}
}

View File

@@ -1,4 +1,6 @@
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'network' | 'email-domains' | 'profile'; export type SystemSection = 'organization' | 'network' | 'templates';
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile';
export interface NavEntry { export interface NavEntry {
id: string; id: string;
@@ -6,18 +8,24 @@ export interface NavEntry {
tab: TabId; tab: TabId;
classId?: number; classId?: number;
examId?: number; examId?: number;
systemSection?: SystemSection;
label: string; label: string;
timestamp: number; timestamp: number;
} }
export const SYSTEM_SECTION_LABELS: Record<SystemSection, string> = {
organization: 'Tổ chức',
network: 'Gói mạng',
templates: 'Khung ứng dụng',
};
export const TAB_LABELS: Record<TabId, string> = { export const TAB_LABELS: Record<TabId, string> = {
dashboard: 'Tổng quan', dashboard: 'Tổng quan',
classes: 'Lớp học', classes: 'Lớp học',
students: 'Sinh viên', students: 'Sinh viên',
learning: 'Giám sát & Lịch học', learning: 'Giám sát & Lịch học',
exams: 'Phòng thi', exams: 'Phòng thi',
network: 'Quản lý mạng', system: 'Hệ thống',
'email-domains': 'Đuôi email',
profile: 'Tài khoản của tôi', profile: 'Tài khoản của tôi',
}; };
@@ -25,14 +33,47 @@ const HISTORY_KEY = 'sc_nav_history';
const MAX_HISTORY = 10; const MAX_HISTORY = 10;
function isTabId(value: string | null): value is TabId { function isTabId(value: string | null): value is TabId {
return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'exams' || value === 'network' || value === 'email-domains' || value === 'profile'; return (
value === 'dashboard' ||
value === 'classes' ||
value === 'students' ||
value === 'learning' ||
value === 'exams' ||
value === 'system' ||
value === 'profile'
);
} }
export function parseRoute(): { tab: TabId; classId: number | null; examId: number | null } { function isSystemSection(value: string | null): value is SystemSection {
return value === 'organization' || value === 'network' || value === 'templates';
}
function resolveTabAndSection(tabParam: string | null): { tab: TabId; systemSection: SystemSection } {
if (tabParam === 'email-domains' || tabParam === 'accounts') {
return { tab: 'system', systemSection: 'organization' };
}
if (tabParam === 'network') {
return { tab: 'system', systemSection: 'network' };
}
const tab: TabId = isTabId(tabParam) ? tabParam : 'dashboard';
const params = new URLSearchParams(window.location.search);
const secParam = params.get('section');
let systemSection: SystemSection = 'organization';
if (isSystemSection(secParam)) {
systemSection = secParam;
}
return { tab, systemSection };
}
export function parseRoute(): {
tab: TabId;
classId: number | null;
examId: number | null;
systemSection: SystemSection;
} {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const tabParam = params.get('tab'); const tabParam = params.get('tab');
let tab: TabId = isTabId(tabParam) ? tabParam : 'dashboard'; const { tab, systemSection } = resolveTabAndSection(tabParam);
if (tabParam === 'accounts') tab = 'email-domains';
const classIdRaw = params.get('classId'); const classIdRaw = params.get('classId');
const classId = classIdRaw ? Number(classIdRaw) : null; const classId = classIdRaw ? Number(classIdRaw) : null;
const examIdRaw = params.get('examId'); const examIdRaw = params.get('examId');
@@ -41,12 +82,21 @@ export function parseRoute(): { tab: TabId; classId: number | null; examId: numb
tab, tab,
classId: classId && !Number.isNaN(classId) ? classId : null, classId: classId && !Number.isNaN(classId) ? classId : null,
examId: examId && !Number.isNaN(examId) ? examId : null, examId: examId && !Number.isNaN(examId) ? examId : null,
systemSection,
}; };
} }
export function buildUrl(tab: TabId, classId?: number | null, examId?: number | null): string { export function buildUrl(
tab: TabId,
classId?: number | null,
examId?: number | null,
systemSection?: SystemSection,
): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('tab', tab); params.set('tab', tab);
if (tab === 'system' && systemSection) {
params.set('section', systemSection);
}
if (classId) { if (classId) {
params.set('classId', String(classId)); params.set('classId', String(classId));
} }
@@ -71,15 +121,16 @@ function writeHistory(entries: NavEntry[]) {
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, MAX_HISTORY))); sessionStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, MAX_HISTORY)));
} }
function entryKey(kind: NavEntry['kind'], tab: TabId, id?: number) { function entryKey(kind: NavEntry['kind'], tab: TabId, id?: number, section?: SystemSection) {
if (kind === 'class') return `class:${id}`; if (kind === 'class') return `class:${id}`;
if (kind === 'exam') return `exam:${id}`; if (kind === 'exam') return `exam:${id}`;
if (tab === 'system' && section) return `tab:system:${section}`;
return `tab:${tab}`; return `tab:${tab}`;
} }
export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) { export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
const history = readHistory(); const history = readHistory();
const id = entryKey(entry.kind, entry.tab, entry.classId ?? entry.examId); const id = entryKey(entry.kind, entry.tab, entry.classId ?? entry.examId, entry.systemSection);
const next: NavEntry = { const next: NavEntry = {
...entry, ...entry,
id, id,
@@ -89,21 +140,47 @@ export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
writeHistory([next, ...filtered]); writeHistory([next, ...filtered]);
} }
export function navigate(tab: TabId, id?: number | null, label?: string, kind: 'class' | 'exam' = 'class') { export function navigate(
const url = kind === 'exam' tab: TabId,
? buildUrl(tab, null, id) id?: number | null,
: buildUrl(tab, id); label?: string,
kind: 'class' | 'exam' = 'class',
systemSection?: SystemSection,
) {
const url =
kind === 'exam'
? buildUrl(tab, null, id, systemSection)
: buildUrl(tab, id, undefined, systemSection);
window.history.pushState({}, '', url); window.history.pushState({}, '', url);
if (id && label) { if (id && label) {
pushNav({ kind, tab, classId: kind === 'class' ? id : undefined, examId: kind === 'exam' ? id : undefined, label }); pushNav({
kind,
tab,
classId: kind === 'class' ? id : undefined,
examId: kind === 'exam' ? id : undefined,
label,
});
} else { } else {
pushNav({ kind: 'tab', tab, label: TAB_LABELS[tab] }); const navLabel =
tab === 'system' && systemSection
? `Hệ thống · ${SYSTEM_SECTION_LABELS[systemSection]}`
: TAB_LABELS[tab];
pushNav({
kind: 'tab',
tab,
systemSection: tab === 'system' ? systemSection : undefined,
label: navLabel,
});
} }
window.dispatchEvent(new PopStateEvent('popstate')); window.dispatchEvent(new PopStateEvent('popstate'));
} }
export function navigateSystem(section: SystemSection) {
navigate('system', null, undefined, 'class', section);
}
export function goBack(fallbackTab: TabId = 'classes') { export function goBack(fallbackTab: TabId = 'classes') {
const history = readHistory(); const history = readHistory();
const current = parseRoute(); const current = parseRoute();
@@ -111,18 +188,19 @@ export function goBack(fallbackTab: TabId = 'classes') {
? entryKey('class', current.tab, current.classId) ? entryKey('class', current.tab, current.classId)
: current.examId : current.examId
? entryKey('exam', current.tab, current.examId) ? entryKey('exam', current.tab, current.examId)
: entryKey('tab', current.tab); : entryKey('tab', current.tab, undefined, current.systemSection);
const remaining = history.filter((h) => h.id !== currentId); const remaining = history.filter((h) => h.id !== currentId);
writeHistory(remaining); writeHistory(remaining);
const previous = remaining[0]; const previous = remaining[0];
if (previous) { if (previous) {
const url = previous.kind === 'class' && previous.classId const url =
? buildUrl(previous.tab, previous.classId) previous.kind === 'class' && previous.classId
: previous.kind === 'exam' && previous.examId ? buildUrl(previous.tab, previous.classId)
? buildUrl(previous.tab, null, previous.examId) : previous.kind === 'exam' && previous.examId
: buildUrl(previous.tab); ? buildUrl(previous.tab, null, previous.examId)
: buildUrl(previous.tab, null, null, previous.systemSection);
window.history.pushState({}, '', url); window.history.pushState({}, '', url);
} else { } else {
window.history.pushState({}, '', buildUrl(fallbackTab)); window.history.pushState({}, '', buildUrl(fallbackTab));

15
management/src/pdfjs.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
declare module 'pdfjs-dist/build/pdf' {
export const GlobalWorkerOptions: { workerSrc: string };
export function getDocument(src: { data: Uint8Array }): { promise: Promise<{
numPages: number;
getPage: (n: number) => Promise<{
getViewport: (opts: { scale: number }) => { width: number; height: number };
render: (opts: { canvasContext: CanvasRenderingContext2D; viewport: unknown }) => { promise: Promise<void> };
}>;
}> };
}
declare module 'pdfjs-dist/build/pdf.worker.min.js?url' {
const src: string;
export default src;
}

View File

@@ -0,0 +1,21 @@
export function parseKeywordSet(csv: string): Set<string> {
return new Set(
csv
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
}
export function mergeKeywordCSV(current: string, addition: string): string {
const set = parseKeywordSet(current);
addition.split(',').forEach((k) => {
const kw = k.trim().toLowerCase();
if (kw) set.add(kw);
});
return Array.from(set).join(', ');
}
export function countKeywords(csv: string): number {
return parseKeywordSet(csv).size;
}

View File

@@ -0,0 +1,33 @@
import * as pdfjsLib from 'pdfjs-dist/build/pdf';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.js?url';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
export async function renderExamPdfPages(container: HTMLElement, bytes: Uint8Array) {
container.innerHTML = '';
const pdf = await pdfjsLib.getDocument({ data: bytes }).promise;
const pad = 16;
const width = container.clientWidth || container.parentElement?.clientWidth || 900;
const maxWidth = Math.max(360, width - pad * 2);
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) {
const page = await pdf.getPage(pageNum);
const base = page.getViewport({ scale: 1 });
const scale = Math.min(1.6, maxWidth / base.width);
const viewport = page.getViewport({ scale });
const wrap = document.createElement('div');
wrap.className = 'exam-pdf-page-wrap';
const canvas = document.createElement('canvas');
canvas.className = 'exam-pdf-page';
canvas.width = viewport.width;
canvas.height = viewport.height;
wrap.appendChild(canvas);
container.appendChild(wrap);
await page.render({
canvasContext: canvas.getContext('2d')!,
viewport,
}).promise;
}
}

View File

@@ -7,3 +7,8 @@ MAIL_PORT=465
MAIL_SECURE=true MAIL_SECURE=true
MAIL_AUTH_USER=phuocnguyenbp0@gmail.com MAIL_AUTH_USER=phuocnguyenbp0@gmail.com
MAIL_AUTH_PASS="cygi rtnv kkbw uuoz" MAIL_AUTH_PASS="cygi rtnv kkbw uuoz"
# GitHub OAuth — mỗi giáo viên kết nối tài khoản riêng (tạo OAuth App tại github.com/settings/developers)
GITHUB_OAUTH_CLIENT_ID=Ov23liRiArENu3uBwyDz
GITHUB_OAUTH_CLIENT_SECRET=f1d762d4061c83905bd13acaa8611ee2fb0ccb01
GITHUB_OAUTH_REDIRECT_URI=http://127.0.0.1:8080/api/auth/github/callback

View File

@@ -0,0 +1,90 @@
package auth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"time"
"github.com/golang-jwt/jwt/v5"
)
type GitHubOAuthStateClaims struct {
StaffID uint `json:"staffId"`
jwt.RegisteredClaims
}
func IssueGitHubOAuthState(staffID uint) (string, error) {
claims := GitHubOAuthStateClaims{
StaffID: staffID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString([]byte(JWTSecret()))
}
func ParseGitHubOAuthState(state string) (uint, error) {
t, err := jwt.ParseWithClaims(state, &GitHubOAuthStateClaims{}, func(t *jwt.Token) (any, error) {
return []byte(JWTSecret()), nil
})
if err != nil {
return 0, err
}
claims, ok := t.Claims.(*GitHubOAuthStateClaims)
if !ok || !t.Valid || claims.StaffID == 0 {
return 0, errors.New("invalid oauth state")
}
return claims.StaffID, nil
}
func tokenCipher() (cipher.AEAD, error) {
sum := sha256.Sum256([]byte(JWTSecret()))
block, err := aes.NewCipher(sum[:])
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func EncryptSecret(plain string) (string, error) {
if plain == "" {
return "", errors.New("empty secret")
}
gcm, err := tokenCipher()
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := gcm.Seal(nonce, nonce, []byte(plain), nil)
return base64.StdEncoding.EncodeToString(sealed), nil
}
func DecryptSecret(encoded string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
gcm, err := tokenCipher()
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plain), nil
}

View File

@@ -49,6 +49,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.ClassCourse{}, &models.ClassCourse{},
&models.ClassAllowedApp{}, &models.ClassAllowedApp{},
&models.AppPoolEntry{}, &models.AppPoolEntry{},
&models.AppTemplate{},
&models.WifiPoolEntry{}, &models.WifiPoolEntry{},
&models.AcceptedWifi{}, &models.AcceptedWifi{},
&models.StudentSession{}, &models.StudentSession{},
@@ -62,6 +63,8 @@ func AutoMigrate(db *gorm.DB) error {
&models.ExamPaperResource{}, &models.ExamPaperResource{},
&models.ExamRoomStudent{}, &models.ExamRoomStudent{},
&models.ExamSubmission{}, &models.ExamSubmission{},
&models.StaffGitHubAuth{},
&models.StaffGitHubRepo{},
); err != nil { ); err != nil {
return err return err
} }

View File

@@ -0,0 +1,119 @@
package handlers
import (
"strings"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// GET /api/app-templates
func ListAppTemplatesHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var rows []models.AppTemplate
if err := db.Order("name asc").Find(&rows).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": rows})
}
}
// POST /api/app-templates
func CreateAppTemplateHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Keywords string `json:"keywords"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
name := strings.TrimSpace(req.Name)
if name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name required"})
}
keywords := normalizeKeywordCSV(req.Keywords)
if keywords == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "keywords required"})
}
row := models.AppTemplate{
Name: name,
Description: strings.TrimSpace(req.Description),
Keywords: keywords,
}
if err := db.Create(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "data": row})
}
}
// PATCH /api/app-templates/:id
func UpdateAppTemplateHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var row models.AppTemplate
if err := db.First(&row, c.Params("id")).Error; err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "not found"})
}
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
Keywords *string `json:"keywords"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name required"})
}
row.Name = name
}
if req.Description != nil {
row.Description = strings.TrimSpace(*req.Description)
}
if req.Keywords != nil {
keywords := normalizeKeywordCSV(*req.Keywords)
if keywords == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "keywords required"})
}
row.Keywords = keywords
}
if err := db.Save(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "data": row})
}
}
// DELETE /api/app-templates/:id
func DeleteAppTemplateHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if err := db.Delete(&models.AppTemplate{}, c.Params("id")).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
}
func normalizeKeywordCSV(raw string) string {
parts := strings.Split(raw, ",")
seen := make(map[string]struct{})
var out []string
for _, p := range parts {
kw := strings.ToLower(strings.TrimSpace(p))
if kw == "" {
continue
}
if _, ok := seen[kw]; ok {
continue
}
seen[kw] = struct{}{}
out = append(out, kw)
}
return strings.Join(out, ", ")
}

View File

@@ -366,6 +366,69 @@ func UpdateAttendanceStatusHandler(db *gorm.DB) fiber.Handler {
} }
} }
// PUT /api/classes/:rkId/attendance/bulk-status
func UpdateAttendanceBulkStatusHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
var req struct {
Date string `json:"date"`
Period int `json:"period"`
StudentRkIDs []int64 `json:"studentRkIds"`
Status int `json:"status"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
if req.Date == "" {
req.Date = qldt.NowDateVN()
}
if req.Period < 1 {
req.Period = 1
}
if len(req.StudentRkIDs) == 0 {
return c.JSON(fiber.Map{"ok": true, "message": "No students selected"})
}
label := qldt.AttendanceStatusLabel(req.Status)
tx := db.Begin()
for _, studentRkID := range req.StudentRkIDs {
var row models.AttendanceResult
err := tx.Where("class_rk_id = ? AND session_date = ? AND period = ? AND student_rk_id = ?",
classRkID, req.Date, req.Period, studentRkID).First(&row).Error
if err == gorm.ErrRecordNotFound {
row = models.AttendanceResult{
ClassRkID: classRkID,
SessionDate: req.Date,
Period: req.Period,
StudentRkID: studentRkID,
Status: req.Status,
StatusLabel: label,
StatusEditedByTeacher: true,
}
if err := tx.Create(&row).Error; err != nil {
tx.Rollback()
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
} else if err != nil {
tx.Rollback()
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} else {
row.Status = req.Status
row.StatusLabel = label
row.StatusEditedByTeacher = true
if err := tx.Save(&row).Error; err != nil {
tx.Rollback()
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
}
}
if err := tx.Commit().Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
}
// POST /api/classes/:rkId/attendance/push-qldt // POST /api/classes/:rkId/attendance/push-qldt
func PushAttendanceToQLDTHandler(db *gorm.DB, qldtClient *qldt.Client, token string) fiber.Handler { func PushAttendanceToQLDTHandler(db *gorm.DB, qldtClient *qldt.Client, token string) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"archive/zip" "archive/zip"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"math/rand" "math/rand"
@@ -919,12 +920,13 @@ func GetStudentExamPaperFilesHandler(db *gorm.DB) fiber.Handler {
resList = append(resList, fiber.Map{ resList = append(resList, fiber.Map{
"id": r.ID, "id": r.ID,
"fileName": r.FileName, "fileName": r.FileName,
"url": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d", studentID, r.ID), "url": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d&view=1", studentID, r.ID),
"downloadUrl": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d", studentID, r.ID),
}) })
} }
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"paperTitle": info.Paper.Title, "paperTitle": info.Paper.Title,
"pdfUrl": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=pdf", studentID), "pdfUrl": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=pdf&view=1", studentID),
"resources": resList, "resources": resList,
}) })
} }
@@ -941,15 +943,176 @@ func DownloadStudentExamFileHandler(db *gorm.DB) fiber.Handler {
if info == nil || info.Enrollment.PaperSentAt == nil || info.Paper == nil { if info == nil || info.Enrollment.PaperSentAt == nil || info.Paper == nil {
return c.Status(403).JSON(fiber.Map{"error": "Không có quyền"}) return c.Status(403).JSON(fiber.Map{"error": "Không có quyền"})
} }
inline := c.Query("view") == "1" || c.Query("inline") == "1"
kind := c.Query("kind", "pdf") kind := c.Query("kind", "pdf")
if kind == "pdf" { if kind == "pdf" {
return c.Download(info.Paper.PdfPath, filepath.Base(info.Paper.PdfPath)) name := filepath.Base(info.Paper.PdfPath)
if inline {
return sendExamFileInline(c, info.Paper.PdfPath, name)
}
return c.Download(info.Paper.PdfPath, name)
} }
fileID, _ := strconv.ParseUint(c.Query("fileId", "0"), 10, 64) fileID, _ := strconv.ParseUint(c.Query("fileId", "0"), 10, 64)
var res models.ExamPaperResource var res models.ExamPaperResource
if err := db.Where("id = ? AND exam_paper_id = ?", fileID, info.Paper.ID).First(&res).Error; err != nil { if err := db.Where("id = ? AND exam_paper_id = ?", fileID, info.Paper.ID).First(&res).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "File không tồn tại"}) return c.Status(404).JSON(fiber.Map{"error": "File không tồn tại"})
} }
if inline && examFileCanViewInline(res.FileName) {
return sendExamFileInline(c, res.FilePath, res.FileName)
}
return c.Download(res.FilePath, res.FileName)
}
}
func examFileCanViewInline(fileName string) bool {
switch strings.ToLower(filepath.Ext(fileName)) {
case ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".txt":
return true
default:
return false
}
}
func examViewMimeType(fileName string) string {
switch strings.ToLower(filepath.Ext(fileName)) {
case ".pdf":
return "application/pdf"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".txt":
return "text/plain; charset=utf-8"
default:
return "application/octet-stream"
}
}
func sendExamFileInline(c *fiber.Ctx, filePath, fileName string) error {
data, err := os.ReadFile(filePath)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không đọc được file"})
}
c.Set("Content-Type", examViewMimeType(fileName))
c.Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", fileName))
return c.Send(data)
}
func encodeExamFileViewPayload(filePath, fileName, title string) (fiber.Map, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
if fileName == "" {
fileName = filepath.Base(filePath)
}
return fiber.Map{
"title": title,
"fileName": fileName,
"mime": examViewMimeType(fileName),
"data": base64.StdEncoding.EncodeToString(data),
}, nil
}
// GET /api/exam-rooms/:id/papers/:paperId/view — JSON base64, dùng cho xem trên web (không tải file)
func ViewStaffExamPaperHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
paperID, err := parseUintParam(c, "paperId")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid paperId"})
}
var paper models.ExamPaper
if err := db.Where("id = ? AND exam_room_id = ?", paperID, roomID).First(&paper).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy gói đề"})
}
payload, err := encodeExamFileViewPayload(paper.PdfPath, "de-thi.pdf", paper.Title)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "File PDF đề không tồn tại trên server"})
}
return c.JSON(payload)
}
}
// GET /api/exam-rooms/:id/papers/:paperId/resources/:resId/view
func ViewStaffExamPaperResourceHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
paperID, err := parseUintParam(c, "paperId")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid paperId"})
}
resID, err := parseUintParam(c, "resId")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid resId"})
}
var paper models.ExamPaper
if err := db.Where("id = ? AND exam_room_id = ?", paperID, roomID).First(&paper).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy gói đề"})
}
var res models.ExamPaperResource
if err := db.Where("id = ? AND exam_paper_id = ?", resID, paper.ID).First(&res).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "File tài nguyên không tồn tại"})
}
payload, err := encodeExamFileViewPayload(res.FilePath, res.FileName, res.FileName)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "File tài nguyên không tồn tại trên server"})
}
return c.JSON(payload)
}
}
// GET /api/exam-rooms/:id/papers/:paperId/download?kind=pdf|resource&fileId=&view=1
func DownloadStaffExamPaperFileHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
paperID, err := parseUintParam(c, "paperId")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid paperId"})
}
var paper models.ExamPaper
if err := db.Where("id = ? AND exam_room_id = ?", paperID, roomID).First(&paper).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy gói đề"})
}
inline := c.Query("view") == "1" || c.Query("inline") == "1"
kind := c.Query("kind", "pdf")
if kind == "pdf" {
if _, statErr := os.Stat(paper.PdfPath); statErr != nil {
return c.Status(404).JSON(fiber.Map{"error": "File PDF đề không tồn tại trên server"})
}
name := filepath.Base(paper.PdfPath)
if name == "" || name == "." {
name = "de-thi.pdf"
}
if inline {
return sendExamFileInline(c, paper.PdfPath, name)
}
return c.Download(paper.PdfPath, name)
}
fileID, _ := strconv.ParseUint(c.Query("fileId", "0"), 10, 64)
var res models.ExamPaperResource
if err := db.Where("id = ? AND exam_paper_id = ?", fileID, paper.ID).First(&res).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "File tài nguyên không tồn tại"})
}
if _, statErr := os.Stat(res.FilePath); statErr != nil {
return c.Status(404).JSON(fiber.Map{"error": "File tài nguyên không tồn tại trên server"})
}
if inline && examFileCanViewInline(res.FileName) {
return sendExamFileInline(c, res.FilePath, res.FileName)
}
return c.Download(res.FilePath, res.FileName) return c.Download(res.FilePath, res.FileName)
} }
} }
@@ -1040,8 +1203,8 @@ func ZipFolder(srcDir, destZip string) error {
if err != nil { if err != nil {
return err return err
} }
defer rf.Close() _, copyErr := io.Copy(writer, rf)
_, err = io.Copy(writer, rf) rf.Close()
return err return copyErr
}) })
} }

View File

@@ -0,0 +1,597 @@
package handlers
import (
"archive/zip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"server/internal/middleware"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
var invalidPathChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]+`)
type submissionRow struct {
models.ExamSubmission
FullName string
StudentCode string
}
func sanitizePathPart(s string) string {
s = strings.TrimSpace(s)
s = invalidPathChars.ReplaceAllString(s, "_")
s = strings.Trim(s, " .")
if s == "" {
return "unknown"
}
return s
}
func studentSubmissionFolder(code, fullName string) string {
code = sanitizePathPart(code)
name := sanitizePathPart(fullName)
if code != "unknown" && name != "unknown" {
return code + "_" + name
}
if code != "unknown" {
return code
}
if name != "unknown" {
return name
}
return "sinh_vien"
}
func shouldSkipBundledPath(rel string) bool {
rel = filepath.ToSlash(strings.TrimSpace(rel))
if rel == "" {
return true
}
for _, part := range strings.Split(rel, "/") {
switch part {
case ".git", "__MACOSX", ".svn", ".hg":
return true
}
}
base := filepath.Base(rel)
switch base {
case ".DS_Store", "Thumbs.db", "desktop.ini":
return true
}
return false
}
func unzipToDir(zipPath, destDir string) (skipped []string, err error) {
r, err := zip.OpenReader(zipPath)
if err != nil {
return nil, err
}
defer r.Close()
cleanDest := filepath.Clean(destDir)
for _, f := range r.File {
name := filepath.FromSlash(f.Name)
if shouldSkipBundledPath(name) {
continue
}
target := filepath.Join(destDir, name)
cleanTarget := filepath.Clean(target)
if cleanTarget != cleanDest && !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) {
continue
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(cleanTarget, 0755); err != nil {
skipped = append(skipped, name+": "+err.Error())
}
continue
}
if err := os.MkdirAll(filepath.Dir(cleanTarget), 0755); err != nil {
skipped = append(skipped, name+": "+err.Error())
continue
}
rc, err := f.Open()
if err != nil {
skipped = append(skipped, name+": "+err.Error())
continue
}
out, err := os.Create(cleanTarget)
if err != nil {
rc.Close()
skipped = append(skipped, name+": "+err.Error())
continue
}
_, copyErr := io.Copy(out, rc)
out.Close()
rc.Close()
if copyErr != nil {
skipped = append(skipped, name+": "+copyErr.Error())
}
}
return skipped, nil
}
func loadExamSubmissionRows(db *gorm.DB, roomID uint) ([]submissionRow, error) {
var subs []models.ExamSubmission
if err := db.Where("exam_room_id = ?", roomID).Order("created_at asc").Find(&subs).Error; err != nil {
return nil, err
}
rows := make([]submissionRow, 0, len(subs))
for _, s := range subs {
row := submissionRow{ExamSubmission: s}
var st models.Student
if err := db.Where("rk_id = ?", s.StudentRkID).First(&st).Error; err == nil {
row.FullName = st.FullName
row.StudentCode = st.StudentCode
}
rows = append(rows, row)
}
return rows, nil
}
func copySubmissionZipEntries(srcZipPath, prefix string, addFile func(destPath string, open func() (io.ReadCloser, error), size int64) error) (skipped []string, err error) {
r, err := zip.OpenReader(srcZipPath)
if err != nil {
return nil, err
}
defer r.Close()
prefix = strings.Trim(prefix, "/\\")
for _, f := range r.File {
name := filepath.ToSlash(filepath.FromSlash(f.Name))
if shouldSkipBundledPath(name) {
continue
}
if f.FileInfo().IsDir() {
continue
}
dest := name
if prefix != "" {
dest = prefix + "/" + name
}
dest = strings.TrimPrefix(dest, "/")
entry := f
addErr := addFile(dest, entry.Open, int64(entry.UncompressedSize64))
if addErr != nil {
skipped = append(skipped, dest+": "+addErr.Error())
}
}
return skipped, nil
}
func bundleSubmissionZips(rows []submissionRow, destZipPath string) (warnings []string, fileCount int, err error) {
f, err := os.Create(destZipPath)
if err != nil {
return nil, 0, err
}
zw := zip.NewWriter(f)
usedFolders := map[string]int{}
for _, row := range rows {
if _, statErr := os.Stat(row.FilePath); statErr != nil {
warnings = append(warnings, fmt.Sprintf("bài SV %d: không tìm thấy file", row.StudentRkID))
continue
}
folder := studentSubmissionFolder(row.StudentCode, row.FullName)
if n := usedFolders[folder]; n > 0 {
folder = fmt.Sprintf("%s_%d", folder, n+1)
}
usedFolders[folder]++
skipped, copyErr := copySubmissionZipEntries(row.FilePath, folder, func(destPath string, open func() (io.ReadCloser, error), size int64) error {
hdr := &zip.FileHeader{
Name: destPath,
Method: zip.Deflate,
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return err
}
rc, err := open()
if err != nil {
return err
}
_, copyErr := io.Copy(w, rc)
rc.Close()
if copyErr == nil {
fileCount++
}
return copyErr
})
if copyErr != nil {
warnings = append(warnings, folder+": "+copyErr.Error())
continue
}
for _, s := range skipped {
warnings = append(warnings, folder+"/"+s)
}
}
if closeErr := zw.Close(); closeErr != nil {
f.Close()
_ = os.Remove(destZipPath)
return warnings, fileCount, closeErr
}
if closeErr := f.Close(); closeErr != nil {
_ = os.Remove(destZipPath)
return warnings, fileCount, closeErr
}
if fileCount == 0 {
_ = os.Remove(destZipPath)
return warnings, 0, errors.New("không có file bài nộp hợp lệ để gộp")
}
return warnings, fileCount, nil
}
func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warnings []string) {
return collectSubmissionZipFilesWithPrefix(rows, true)
}
func collectSubmissionZipFilesWithPrefix(rows []submissionRow, useStudentFolder bool) (files []gitPathFile, warnings []string) {
byPath := map[string]gitPathFile{}
usedFolders := map[string]int{}
for _, row := range rows {
if _, statErr := os.Stat(row.FilePath); statErr != nil {
warnings = append(warnings, fmt.Sprintf("bài SV %d: không tìm thấy file", row.StudentRkID))
continue
}
prefix := ""
if useStudentFolder {
folder := studentSubmissionFolder(row.StudentCode, row.FullName)
if n := usedFolders[folder]; n > 0 {
folder = fmt.Sprintf("%s_%d", folder, n+1)
}
usedFolders[folder]++
prefix = folder
}
skipped, err := copySubmissionZipEntries(row.FilePath, prefix, func(destPath string, open func() (io.ReadCloser, error), size int64) error {
if size > githubBlobMaxBytes {
warnings = append(warnings, destPath+": file quá lớn (>100MB), bỏ qua")
return nil
}
rc, err := open()
if err != nil {
return err
}
data, readErr := io.ReadAll(rc)
rc.Close()
if readErr != nil {
return readErr
}
byPath[destPath] = gitPathFile{path: destPath, content: data}
return nil
})
if err != nil {
warnings = append(warnings, prefix+": "+err.Error())
continue
}
for _, s := range skipped {
warnings = append(warnings, prefix+"/"+s)
}
}
files = make([]gitPathFile, 0, len(byPath))
for _, f := range byPath {
files = append(files, f)
}
return files, warnings
}
func buildExamSubmissionsBundle(room models.ExamRoom, rows []submissionRow) (zipPath string, cleanup func(), err error) {
tmpBase, err := os.MkdirTemp("", "exam-bundle-*")
if err != nil {
return "", nil, err
}
cleanup = func() { _ = os.RemoveAll(tmpBase) }
rootName := sanitizePathPart(room.Name) + "_bai_nop"
outZip := filepath.Join(tmpBase, rootName+".zip")
_, _, err = bundleSubmissionZips(rows, outZip)
if err != nil {
cleanup()
return "", nil, err
}
return outZip, cleanup, nil
}
func prepareExamSubmissionsForGit(rows []submissionRow) (files []gitPathFile, warnings []string, err error) {
files, warnings = collectSubmissionZipFiles(rows)
if len(files) == 0 {
return nil, warnings, errors.New("không có file bài nộp hợp lệ để đẩy")
}
return files, warnings, nil
}
// GET /api/exam-rooms/:id/submissions/download-all
func DownloadAllExamSubmissionsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
var room models.ExamRoom
if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"})
}
rows, err := loadExamSubmissionRows(db, roomID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
if len(rows) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "Chưa có bài nộp"})
}
zipPath, cleanup, err := buildExamSubmissionsBundle(room, rows)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
zipBytes, readErr := os.ReadFile(zipPath)
cleanup()
if readErr != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không đọc được file zip gộp"})
}
downloadName := sanitizePathPart(room.Name) + "_bai_nop.zip"
c.Set("Content-Type", "application/zip")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, downloadName))
return c.Send(zipBytes)
}
}
// PATCH /api/exam-rooms/:id/git-settings
func UpdateExamRoomGitSettingsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
var room models.ExamRoom
if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"})
}
var req struct {
GitRepoURL *string `json:"gitRepoUrl"`
GitBranch *string `json:"gitBranch"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
}
if req.GitRepoURL != nil {
room.GitRepoURL = strings.TrimSpace(*req.GitRepoURL)
}
if req.GitBranch != nil {
b := strings.TrimSpace(*req.GitBranch)
if b == "" {
b = "main"
}
room.GitBranch = b
}
if room.GitBranch == "" {
room.GitBranch = "main"
}
if err := db.Save(&room).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"gitRepoUrl": room.GitRepoURL,
"gitBranch": room.GitBranch,
"gitPublishUrl": room.GitPublishURL,
})
}
}
func parseGitHubRepo(repoURL string) (owner, repo string, err error) {
u := strings.TrimSpace(repoURL)
u = strings.TrimSuffix(u, ".git")
u = strings.TrimPrefix(u, "https://github.com/")
u = strings.TrimPrefix(u, "http://github.com/")
u = strings.TrimPrefix(u, "github.com/")
parts := strings.Split(strings.Trim(u, "/"), "/")
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", "", errors.New("URL GitHub không hợp lệ (vd: https://github.com/org/repo)")
}
return parts[0], parts[1], nil
}
// POST /api/exam-rooms/:id/submissions/publish-git
func PublishExamSubmissionsGitHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
if staffID == 0 {
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
}
token, ghLogin, err := loadStaffGitHubToken(db, staffID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(401).JSON(fiber.Map{
"error": "Chưa kết nối GitHub. Vào Tài khoản của tôi → Kết nối GitHub.",
"needsGitHubOAuth": true,
})
}
return c.Status(500).JSON(fiber.Map{"error": "Không đọc được token GitHub"})
}
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
var room models.ExamRoom
if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"})
}
rows, err := loadExamSubmissionRows(db, roomID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
if len(rows) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "Chưa có bài nộp"})
}
files, unpackWarn, err := prepareExamSubmissionsForGit(rows)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error(), "warnings": unpackWarn})
}
_, repo, branch, repoHTML, err := resolvePublishRepo(db, staffID, token, ghLogin, &room)
if err != nil {
return c.Status(502).JSON(fiber.Map{"error": err.Error()})
}
if repoHTML != "" {
room.GitRepoURL = repoHTML
room.GitBranch = branch
}
pushOwner, pushRepo := resolveGitPushTarget(token, ghLogin, repo, repoHTML)
commitMsg := fmt.Sprintf("Simple Care: bài nộp phòng thi %s (#%d)", room.Name, roomID)
viewURL, pushWarn, err := githubPushFiles(token, pushOwner, pushRepo, branch, files, commitMsg)
allWarn := append(unpackWarn, pushWarn...)
if err != nil {
return c.Status(502).JSON(fiber.Map{
"error": "Đẩy Git thất bại: " + err.Error(),
"warnings": allWarn,
})
}
room.GitPublishURL = viewURL
_ = db.Save(&room).Error
roomIDCopy := roomID
recordStaffGitHubRepo(db, staffID, pushOwner, pushRepo, repoHTML, models.GitPublishModeRoom, room.Name, &roomIDCopy, nil)
msg := fmt.Sprintf("Đã đẩy %d bài lên GitHub — mỗi sinh viên một folder.", len(rows))
if len(allWarn) > 0 {
msg += fmt.Sprintf(" (%d file bỏ qua do lỗi)", len(allWarn))
}
return c.JSON(fiber.Map{
"ok": true,
"url": viewURL,
"gitPublishUrl": viewURL,
"gitRepoUrl": room.GitRepoURL,
"githubLogin": ghLogin,
"openUrl": viewURL,
"warnings": allWarn,
"message": msg,
})
}
}
// POST /api/exam-rooms/:id/submissions/publish-git-students — mỗi sinh viên một repo GitHub riêng
func PublishExamSubmissionsGitPerStudentHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
if staffID == 0 {
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
}
token, ghLogin, err := loadStaffGitHubToken(db, staffID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(401).JSON(fiber.Map{
"error": "Chưa kết nối GitHub. Vào Tài khoản của tôi → Kết nối GitHub.",
"needsGitHubOAuth": true,
})
}
return c.Status(500).JSON(fiber.Map{"error": "Không đọc được token GitHub"})
}
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
var room models.ExamRoom
if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"})
}
rows, err := loadExamSubmissionRows(db, roomID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
if len(rows) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "Chưa có bài nộp"})
}
published := 0
var allWarn []string
var repoURLs []string
type studentRepo struct {
StudentRkID int64 `json:"studentRkId"`
StudentCode string `json:"studentCode"`
FullName string `json:"fullName"`
URL string `json:"url"`
}
studentRepos := make([]studentRepo, 0, len(rows))
for _, row := range rows {
files, unpackWarn := collectSubmissionZipFilesWithPrefix([]submissionRow{row}, false)
allWarn = append(allWarn, unpackWarn...)
if len(files) == 0 {
allWarn = append(allWarn, fmt.Sprintf("%s: không có file để đẩy", studentSubmissionFolder(row.StudentCode, row.FullName)))
continue
}
repoName, repoHTML, err := githubEnsureRepoForStudent(db, staffID, token, ghLogin, &room, &row)
if err != nil {
allWarn = append(allWarn, row.StudentCode+": "+err.Error())
continue
}
pushOwner, pushRepo := resolveGitPushTarget(token, ghLogin, repoName, repoHTML)
branch := "main"
if meta, ok := githubFetchRepo(token, pushOwner, pushRepo); ok && meta.DefaultBranch != "" {
branch = meta.DefaultBranch
}
label := studentSubmissionFolder(row.StudentCode, row.FullName)
commitMsg := fmt.Sprintf("Simple Care: bài nộp %s — %s", label, room.Name)
viewURL, pushWarn, err := githubPushFiles(token, pushOwner, pushRepo, branch, files, commitMsg)
allWarn = append(allWarn, pushWarn...)
if err != nil {
allWarn = append(allWarn, label+": "+err.Error())
continue
}
_ = db.Model(&models.ExamSubmission{}).Where("id = ?", row.ID).
Updates(map[string]any{"git_repo_url": repoHTML, "git_publish_url": viewURL}).Error
subID := row.ID
roomIDCopy := roomID
recordStaffGitHubRepo(db, staffID, pushOwner, pushRepo, repoHTML, models.GitPublishModeStudent, label, &roomIDCopy, &subID)
published++
repoURLs = append(repoURLs, viewURL)
studentRepos = append(studentRepos, studentRepo{
StudentRkID: row.StudentRkID,
StudentCode: row.StudentCode,
FullName: row.FullName,
URL: viewURL,
})
}
if published == 0 {
return c.Status(502).JSON(fiber.Map{
"error": "Không đẩy được repo nào",
"warnings": allWarn,
})
}
msg := fmt.Sprintf("Đã tạo %d repo GitHub — mỗi sinh viên một repo.", published)
if len(allWarn) > 0 {
msg += fmt.Sprintf(" (%d cảnh báo)", len(allWarn))
}
openURL := ""
if len(repoURLs) > 0 {
openURL = repoURLs[0]
}
return c.JSON(fiber.Map{
"ok": true,
"published": published,
"studentRepos": studentRepos,
"openUrl": openURL,
"warnings": allWarn,
"message": msg,
})
}
}

View File

@@ -0,0 +1,289 @@
package handlers
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)
const githubBlobMaxBytes = 100 * 1024 * 1024
func collectDirFiles(rootDir string) ([]gitPathFile, []string) {
cleanRoot := filepath.Clean(rootDir)
byPath := map[string]gitPathFile{}
var warnings []string
_ = filepath.Walk(cleanRoot, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
warnings = append(warnings, path+": "+walkErr.Error())
return nil
}
if info.IsDir() {
if shouldSkipBundledPath(filepath.Base(path)) && path != cleanRoot {
return filepath.SkipDir
}
return nil
}
rel, err := filepath.Rel(cleanRoot, path)
if err != nil {
warnings = append(warnings, path+": "+err.Error())
return nil
}
rel = filepath.ToSlash(rel)
if rel == "" || strings.HasPrefix(rel, "..") || shouldSkipBundledPath(rel) {
return nil
}
if info.Size() > githubBlobMaxBytes {
warnings = append(warnings, rel+": file quá lớn (>100MB), bỏ qua")
return nil
}
data, err := os.ReadFile(path)
if err != nil {
warnings = append(warnings, rel+": "+err.Error())
return nil
}
byPath[rel] = gitPathFile{path: rel, content: data}
return nil
})
out := make([]gitPathFile, 0, len(byPath))
for _, f := range byPath {
out = append(out, f)
}
return out, warnings
}
type gitPathFile struct {
path string
content []byte
}
func githubRepoAPI(owner, repo, suffix string) string {
return fmt.Sprintf("https://api.github.com/repos/%s/%s%s",
url.PathEscape(strings.TrimSpace(owner)),
url.PathEscape(strings.TrimSpace(repo)),
suffix,
)
}
func resolveGitPushTarget(token, fallbackOwner, repoName, repoHTML string) (owner, repo string) {
if o, r, err := parseGitHubRepo(repoHTML); err == nil && o != "" && r != "" {
return o, r
}
owner = strings.TrimSpace(fallbackOwner)
if live, err := githubGetUser(token); err == nil && live != "" {
owner = live
}
return owner, trimGitHubRepoName(repoName)
}
func githubCreateBlob(token, owner, repo string, content []byte) (string, error) {
apiURL := githubRepoAPI(owner, repo, "/git/blobs")
body := map[string]string{
"content": base64.StdEncoding.EncodeToString(content),
"encoding": "base64",
}
var resp struct {
SHA string `json:"sha"`
}
if err := githubJSON(token, http.MethodPost, apiURL, body, &resp); err != nil {
return "", err
}
if resp.SHA == "" {
return "", errors.New("GitHub không trả blob sha")
}
return resp.SHA, nil
}
func githubCreateTree(token, owner, repo, baseTreeSHA string, entries []map[string]string) (string, error) {
apiURL := githubRepoAPI(owner, repo, "/git/trees")
body := map[string]any{"tree": entries}
if baseTreeSHA != "" {
body["base_tree"] = baseTreeSHA
}
var resp struct {
SHA string `json:"sha"`
}
if err := githubJSON(token, http.MethodPost, apiURL, body, &resp); err != nil {
return "", err
}
if resp.SHA == "" {
return "", errors.New("GitHub không trả tree sha")
}
return resp.SHA, nil
}
func githubCreateCommit(token, owner, repo, treeSHA, parentSHA, message string) (string, error) {
apiURL := githubRepoAPI(owner, repo, "/git/commits")
body := map[string]any{
"message": message,
"tree": treeSHA,
}
if parentSHA != "" {
body["parents"] = []string{parentSHA}
}
var resp struct {
SHA string `json:"sha"`
}
if err := githubJSON(token, http.MethodPost, apiURL, body, &resp); err != nil {
return "", err
}
if resp.SHA == "" {
return "", errors.New("GitHub không trả commit sha")
}
return resp.SHA, nil
}
func githubUpdateBranchRef(token, owner, repo, branch, commitSHA string) error {
apiURL := githubRepoAPI(owner, repo, "/git/refs/heads/"+url.PathEscape(branch))
body := map[string]any{"sha": commitSHA, "force": false}
err := githubJSON(token, http.MethodPatch, apiURL, body, nil)
if err == nil {
return nil
}
// Branch chưa tồn tại — tạo mới
createURL := githubRepoAPI(owner, repo, "/git/refs")
createBody := map[string]string{
"ref": "refs/heads/" + branch,
"sha": commitSHA,
}
if createErr := githubJSON(token, http.MethodPost, createURL, createBody, nil); createErr == nil {
return nil
} else if !strings.Contains(strings.ToLower(createErr.Error()), "already exists") {
return createErr
}
// Ref đã tồn tại nhưng PATCH thất bại (non-fast-forward) — cập nhật force
forceBody := map[string]any{"sha": commitSHA, "force": true}
return githubJSON(token, http.MethodPatch, apiURL, forceBody, nil)
}
func githubBranchTip(token, owner, repo, branch string) (commitSHA, treeSHA string, err error) {
apiURL := githubRepoAPI(owner, repo, "/git/ref/heads/"+url.PathEscape(branch))
var refResp struct {
Object struct {
SHA string `json:"sha"`
} `json:"object"`
}
if err := githubJSON(token, http.MethodGet, apiURL, nil, &refResp); err != nil {
return "", "", err
}
commitSHA = refResp.Object.SHA
if commitSHA == "" {
return "", "", errors.New("không lấy được commit hiện tại")
}
commitURL := githubRepoAPI(owner, repo, "/git/commits/"+url.PathEscape(commitSHA))
var commitResp struct {
Tree struct {
SHA string `json:"sha"`
} `json:"tree"`
}
if err := githubJSON(token, http.MethodGet, commitURL, nil, &commitResp); err != nil {
return "", "", err
}
return commitSHA, commitResp.Tree.SHA, nil
}
func githubJSON(token, method, apiURL string, body any, out any) error {
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(payload)
}
req, err := http.NewRequest(method, apiURL, reader)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
var errBody struct {
Message string `json:"message"`
}
_ = json.Unmarshal(raw, &errBody)
if errBody.Message != "" {
return errors.New(errBody.Message)
}
return fmt.Errorf("GitHub API lỗi (%d)", resp.StatusCode)
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return err
}
}
return nil
}
// githubPushFiles — đẩy danh sách file lên repo; file lỗi được bỏ qua.
func githubPushFiles(token, owner, repo, branch string, files []gitPathFile, message string) (string, []string, error) {
warnings := []string{}
if len(files) == 0 {
return "", warnings, errors.New("không có file để đẩy")
}
parentSHA, baseTreeSHA, tipErr := githubBranchTip(token, owner, repo, branch)
if tipErr != nil {
parentSHA = ""
baseTreeSHA = ""
}
entries := make([]map[string]string, 0, len(files))
for _, f := range files {
blobSHA, err := githubCreateBlob(token, owner, repo, f.content)
if err != nil {
warnings = append(warnings, f.path+": "+err.Error()+" (bỏ qua)")
continue
}
entries = append(entries, map[string]string{
"path": f.path,
"mode": "100644",
"type": "blob",
"sha": blobSHA,
})
}
if len(entries) == 0 {
return "", warnings, errors.New("không đẩy được file nào lên GitHub")
}
treeSHA, err := githubCreateTree(token, owner, repo, baseTreeSHA, entries)
if err != nil {
return "", warnings, err
}
commitSHA, err := githubCreateCommit(token, owner, repo, treeSHA, parentSHA, message)
if err != nil {
return "", warnings, err
}
if err := githubUpdateBranchRef(token, owner, repo, branch, commitSHA); err != nil {
return "", warnings, err
}
return fmt.Sprintf("https://github.com/%s/%s/tree/%s", owner, repo, branch), warnings, nil
}
// githubPushDirectory — đẩy file lên repo; file lỗi được bỏ qua, không chặn cả lần push.
func githubPushDirectory(token, owner, repo, branch, localRoot, message string) (string, []string, error) {
files, collectWarn := collectDirFiles(localRoot)
warnings := append([]string{}, collectWarn...)
url, pushWarn, err := githubPushFiles(token, owner, repo, branch, files, message)
return url, append(warnings, pushWarn...), err
}

View File

@@ -0,0 +1,611 @@
package handlers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"server/internal/auth"
"server/internal/middleware"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
func githubOAuthConfig() (clientID, clientSecret, redirectURI string, err error) {
clientID = strings.TrimSpace(os.Getenv("GITHUB_OAUTH_CLIENT_ID"))
clientSecret = strings.TrimSpace(os.Getenv("GITHUB_OAUTH_CLIENT_SECRET"))
redirectURI = strings.TrimSpace(os.Getenv("GITHUB_OAUTH_REDIRECT_URI"))
if redirectURI == "" {
redirectURI = "http://127.0.0.1:8080/api/auth/github/callback"
}
if clientID == "" || clientSecret == "" {
return "", "", "", errors.New("Server chưa cấu hình GITHUB_OAUTH_CLIENT_ID / GITHUB_OAUTH_CLIENT_SECRET")
}
return clientID, clientSecret, redirectURI, nil
}
func loadStaffGitHubToken(db *gorm.DB, staffID uint) (token string, login string, err error) {
var row models.StaffGitHubAuth
if err := db.First(&row, "staff_id = ?", staffID).Error; err != nil {
return "", "", err
}
token, err = auth.DecryptSecret(row.AccessTokenEnc)
if err != nil {
return "", "", err
}
return token, row.GitHubLogin, nil
}
// GET /api/auth/github/authorize — trả URL để mở popup OAuth (cần JWT staff)
func GitHubAuthorizeURLHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if _, _, _, err := githubOAuthConfig(); err != nil {
return c.Status(503).JSON(fiber.Map{"error": err.Error()})
}
staffID := middleware.StaffIDFromCtx(c)
if staffID == 0 {
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
}
state, err := auth.IssueGitHubOAuthState(staffID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không tạo được state OAuth"})
}
clientID, _, redirectURI, _ := githubOAuthConfig()
authorizeURL := fmt.Sprintf(
"https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&scope=%s&state=%s",
url.QueryEscape(clientID),
url.QueryEscape(redirectURI),
url.QueryEscape("repo delete_repo"),
url.QueryEscape(state),
)
return c.JSON(fiber.Map{"authorizeUrl": authorizeURL})
}
}
// GET /api/auth/github/callback — GitHub redirect (public)
func GitHubOAuthCallbackHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if errMsg := strings.TrimSpace(c.Query("error")); errMsg != "" {
return oauthCallbackHTML(c, false, "GitHub từ chối: "+errMsg)
}
code := strings.TrimSpace(c.Query("code"))
state := strings.TrimSpace(c.Query("state"))
if code == "" || state == "" {
return oauthCallbackHTML(c, false, "Thiếu mã xác thực từ GitHub")
}
staffID, err := auth.ParseGitHubOAuthState(state)
if err != nil {
return oauthCallbackHTML(c, false, "Phiên OAuth hết hạn — thử kết nối lại")
}
clientID, clientSecret, redirectURI, err := githubOAuthConfig()
if err != nil {
return oauthCallbackHTML(c, false, err.Error())
}
form := url.Values{}
form.Set("client_id", clientID)
form.Set("client_secret", clientSecret)
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
req, err := http.NewRequest(http.MethodPost, "https://github.com/login/oauth/access_token", strings.NewReader(form.Encode()))
if err != nil {
return oauthCallbackHTML(c, false, err.Error())
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return oauthCallbackHTML(c, false, "Không đổi được token GitHub")
}
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
Scope string `json:"scope"`
Error string `json:"error"`
Description string `json:"error_description"`
}
_ = json.NewDecoder(resp.Body).Decode(&tokenResp)
if tokenResp.AccessToken == "" {
msg := tokenResp.Description
if msg == "" {
msg = tokenResp.Error
}
if msg == "" {
msg = "Không nhận được access token"
}
return oauthCallbackHTML(c, false, msg)
}
userReq, _ := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil)
userReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
userReq.Header.Set("Accept", "application/vnd.github+json")
userResp, err := http.DefaultClient.Do(userReq)
if err != nil {
return oauthCallbackHTML(c, false, "Không lấy được thông tin GitHub")
}
defer userResp.Body.Close()
var ghUser struct {
Login string `json:"login"`
}
_ = json.NewDecoder(userResp.Body).Decode(&ghUser)
if ghUser.Login == "" {
return oauthCallbackHTML(c, false, "Tài khoản GitHub không hợp lệ")
}
enc, err := auth.EncryptSecret(tokenResp.AccessToken)
if err != nil {
return oauthCallbackHTML(c, false, "Không lưu được token")
}
row := models.StaffGitHubAuth{
StaffID: staffID,
GitHubLogin: ghUser.Login,
AccessTokenEnc: enc,
Scope: tokenResp.Scope,
ConnectedAt: time.Now(),
}
if err := db.Save(&row).Error; err != nil {
return oauthCallbackHTML(c, false, "Lưu DB thất bại")
}
return oauthCallbackHTML(c, true, ghUser.Login)
}
}
func oauthCallbackHTML(c *fiber.Ctx, ok bool, detail string) error {
c.Set("Content-Type", "text/html; charset=utf-8")
payload, _ := json.Marshal(map[string]any{"type": "simple-care-github-connected", "ok": ok, "detail": detail})
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="vi"><head><meta charset="utf-8"><title>GitHub</title></head>
<body style="font-family:Segoe UI,sans-serif;padding:2rem;text-align:center">
<p>%s</p>
<script>
try {
if (window.opener) window.opener.postMessage(%s, "*");
} catch (e) {}
setTimeout(function(){ window.close(); }, 800);
</script>
</body></html>`, detail, string(payload))
if !ok {
html = strings.Replace(html, detail, "Lỗi: "+detail, 1)
} else {
html = strings.Replace(html, detail, "Đã kết nối GitHub: @"+detail, 1)
}
return c.SendString(html)
}
// GET /api/auth/github/status
func GitHubStatusHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
var row models.StaffGitHubAuth
if err := db.First(&row, "staff_id = ?", staffID).Error; err != nil {
return c.JSON(fiber.Map{"connected": false})
}
return c.JSON(fiber.Map{
"connected": true,
"githubLogin": row.GitHubLogin,
"connectedAt": row.ConnectedAt,
"scope": row.Scope,
"canDeleteRepos": githubScopeAllowsDelete(row.Scope),
})
}
}
// DELETE /api/auth/github
func GitHubDisconnectHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
_ = db.Delete(&models.StaffGitHubAuth{}, "staff_id = ?", staffID).Error
return c.JSON(fiber.Map{"ok": true})
}
}
func githubScopeAllowsDelete(scope string) bool {
scope = strings.ToLower(strings.TrimSpace(scope))
if scope == "" {
return false
}
for _, part := range strings.FieldsFunc(scope, func(r rune) bool {
return r == ' ' || r == ','
}) {
if part == "delete_repo" {
return true
}
}
return false
}
func githubGetUser(token string) (login string, err error) {
req, _ := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var u struct {
Login string `json:"login"`
}
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return "", err
}
if u.Login == "" {
return "", errors.New("không lấy được tài khoản GitHub")
}
return u.Login, nil
}
func githubGetRepo(token, owner, repoName string) (htmlURL string, found bool) {
meta, ok := githubFetchRepo(token, owner, repoName)
if !ok {
return "", false
}
return meta.HTMLURL, true
}
func githubFetchRepo(token, owner, repoName string) (meta struct {
HTMLURL string
DefaultBranch string
}, ok bool) {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(owner), url.PathEscape(repoName))
req, _ := http.NewRequest(http.MethodGet, apiURL, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return meta, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return meta, false
}
var repo struct {
HTMLURL string `json:"html_url"`
DefaultBranch string `json:"default_branch"`
}
_ = json.NewDecoder(resp.Body).Decode(&repo)
if repo.HTMLURL == "" {
return meta, false
}
meta.HTMLURL = repo.HTMLURL
meta.DefaultBranch = strings.TrimSpace(repo.DefaultBranch)
if meta.DefaultBranch == "" {
meta.DefaultBranch = "main"
}
return meta, true
}
func clearStaleExamRoomGitURLs(db *gorm.DB, room *models.ExamRoom) {
room.GitRepoURL = ""
room.GitPublishURL = ""
_ = db.Model(room).Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
}
func clearStaleSubmissionGitURLs(db *gorm.DB, row *submissionRow) {
row.GitRepoURL = ""
row.GitPublishURL = ""
_ = db.Model(&models.ExamSubmission{}).Where("id = ?", row.ID).
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
}
func githubRepoTrackedForRoom(db *gorm.DB, staffID uint, owner, repoName string, roomID uint) bool {
var count int64
_ = db.Model(&models.StaffGitHubRepo{}).
Where("staff_id = ? AND owner_login = ? AND repo_name = ? AND exam_room_id = ? AND publish_mode = ?",
staffID, owner, repoName, roomID, models.GitPublishModeRoom).
Count(&count).Error
return count > 0
}
func githubRepoTrackedForSubmission(db *gorm.DB, staffID uint, owner, repoName string, submissionID uint) bool {
var count int64
_ = db.Model(&models.StaffGitHubRepo{}).
Where("staff_id = ? AND owner_login = ? AND repo_name = ? AND submission_id = ? AND publish_mode = ?",
staffID, owner, repoName, submissionID, models.GitPublishModeStudent).
Count(&count).Error
return count > 0
}
func repoNameMatchesSavedURL(savedURL, owner, repoName string) bool {
if strings.TrimSpace(savedURL) == "" {
return false
}
o, r, err := parseGitHubRepo(savedURL)
return err == nil && strings.EqualFold(o, owner) && strings.EqualFold(r, repoName)
}
func githubCreateRepo(token, owner, repoName string, private bool) (actualName, htmlURL string, err error) {
repoName = trimGitHubRepoName(repoName)
body := map[string]any{
"name": repoName,
"private": private,
"auto_init": true,
"description": "Bài nộp phòng thi — Simple Care",
}
payload, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, "https://api.github.com/user/repos", bytes.NewReader(payload))
if err != nil {
return "", "", err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return "", "", fmt.Errorf("tạo repo thất bại: %s", strings.TrimSpace(string(raw)))
}
var created struct {
HTMLURL string `json:"html_url"`
Name string `json:"name"`
FullName string `json:"full_name"`
}
if err := json.Unmarshal(raw, &created); err != nil {
return "", "", err
}
if created.HTMLURL == "" {
return "", "", errors.New("GitHub không trả URL repo")
}
actualName = strings.TrimSpace(created.Name)
if actualName == "" {
if _, r, perr := parseGitHubRepo(created.HTMLURL); perr == nil {
actualName = r
} else {
actualName = repoName
}
}
return actualName, created.HTMLURL, nil
}
func githubOwnerRepoFromURL(htmlURL string) (owner, repo string, ok bool) {
o, r, err := parseGitHubRepo(htmlURL)
return o, r, err == nil
}
func isGitHubRepoNameTaken(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "name already exists") || strings.Contains(msg, `"code":"custom"`) && strings.Contains(msg, "name")
}
func trimGitHubRepoName(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
name = strings.NewReplacer(" ", "-", "_", "-").Replace(name)
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '.':
b.WriteRune(r)
case r == '_':
b.WriteRune('-')
}
}
name = b.String()
for strings.Contains(name, "--") {
name = strings.ReplaceAll(name, "--", "-")
}
name = strings.Trim(name, "-.")
if len(name) > 90 {
name = strings.Trim(name[:90], "-.")
}
if name == "" {
name = "exam-repo"
}
return name
}
func examGitRepoBaseName(room *models.ExamRoom) string {
base := strings.ToLower(sanitizePathPart(room.Name))
base = strings.NewReplacer(" ", "-", "_", "-").Replace(base)
base = invalidPathChars.ReplaceAllString(base, "-")
base = strings.Trim(base, "-.")
for strings.Contains(base, "--") {
base = strings.ReplaceAll(base, "--", "-")
}
if base == "" {
base = fmt.Sprintf("exam-%d", room.ID)
}
return trimGitHubRepoName(base)
}
func examGitRepoNameCandidates(room *models.ExamRoom) []string {
base := examGitRepoBaseName(room)
candidates := []string{
base,
fmt.Sprintf("%s-exam-%d", base, room.ID),
fmt.Sprintf("%s-%d", base, room.ID),
fmt.Sprintf("exam-%d", room.ID),
}
for i := 2; i <= 20; i++ {
candidates = append(candidates, fmt.Sprintf("%s-exam-%d-%d", base, room.ID, i))
}
seen := map[string]bool{}
out := make([]string, 0, len(candidates))
for _, c := range candidates {
c = trimGitHubRepoName(c)
if seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
return out
}
// githubEnsureRepoForRoom — dùng repo đã gắn phòng thi, hoặc tạo mới; trùng tên thì tự đổi tên.
func githubEnsureRepoForRoom(db *gorm.DB, staffID uint, token, owner string, room *models.ExamRoom) (repoName, htmlURL string, err error) {
if u := strings.TrimSpace(room.GitRepoURL); u != "" {
o, r, parseErr := parseGitHubRepo(u)
if parseErr == nil && strings.EqualFold(o, owner) {
if url, ok := githubGetRepo(token, owner, trimGitHubRepoName(r)); ok {
if _, name, perr := parseGitHubRepo(url); perr == nil && name != "" {
return name, url, nil
}
return trimGitHubRepoName(r), url, nil
}
clearStaleExamRoomGitURLs(db, room)
}
}
var tracked models.StaffGitHubRepo
if err := db.Where("staff_id = ? AND exam_room_id = ? AND publish_mode = ?", staffID, room.ID, models.GitPublishModeRoom).
Order("created_at desc").First(&tracked).Error; err == nil {
if strings.EqualFold(tracked.OwnerLogin, owner) {
trackedName := trimGitHubRepoName(tracked.RepoName)
if url, ok := githubGetRepo(token, owner, trackedName); ok {
if _, name, perr := parseGitHubRepo(url); perr == nil && name != "" {
return name, url, nil
}
return trackedName, url, nil
}
}
}
for _, candidate := range examGitRepoNameCandidates(room) {
if url, ok := githubGetRepo(token, owner, candidate); ok {
if repoNameMatchesSavedURL(room.GitRepoURL, owner, candidate) ||
githubRepoTrackedForRoom(db, staffID, owner, candidate, room.ID) {
return candidate, url, nil
}
continue
}
actualName, url, createErr := githubCreateRepo(token, owner, candidate, true)
if createErr == nil {
return actualName, url, nil
}
if isGitHubRepoNameTaken(createErr) {
continue
}
return "", "", createErr
}
fallback := trimGitHubRepoName(fmt.Sprintf("%s-sc-%d-%d", examGitRepoBaseName(room), room.ID, time.Now().Unix()))
actualName, url, createErr := githubCreateRepo(token, owner, fallback, true)
if createErr != nil {
return "", "", fmt.Errorf("không tạo được repo GitHub — đã thử nhiều tên khác nhau: %w", createErr)
}
return actualName, url, nil
}
func examGitRepoName(room *models.ExamRoom) string {
if u := strings.TrimSpace(room.GitRepoURL); u != "" {
if _, name, err := parseGitHubRepo(u); err == nil && name != "" {
return name
}
}
return examGitRepoBaseName(room)
}
func resolvePublishRepo(db *gorm.DB, staffID uint, token, ghLogin string, room *models.ExamRoom) (owner, repo, branch, repoHTML string, err error) {
owner = ghLogin
repo, htmlURL, err := githubEnsureRepoForRoom(db, staffID, token, owner, room)
if err != nil {
return "", "", "", "", err
}
branch = "main"
if meta, ok := githubFetchRepo(token, owner, repo); ok && meta.DefaultBranch != "" {
branch = meta.DefaultBranch
}
return owner, repo, branch, htmlURL, nil
}
func studentGitRepoNameCandidates(row submissionRow, room *models.ExamRoom) []string {
base := trimGitHubRepoName(strings.ToLower(studentSubmissionFolder(row.StudentCode, row.FullName)))
candidates := []string{
base,
fmt.Sprintf("%s-exam-%d", base, room.ID),
fmt.Sprintf("%s-%d", base, row.StudentRkID),
fmt.Sprintf("exam-%d-%d", room.ID, row.StudentRkID),
}
seen := map[string]bool{}
out := make([]string, 0, len(candidates))
for _, c := range candidates {
c = trimGitHubRepoName(c)
if seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
for i := 2; i <= 10; i++ {
c := trimGitHubRepoName(fmt.Sprintf("%s-%d", base, i))
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out
}
func githubEnsureRepoForStudent(db *gorm.DB, staffID uint, token, owner string, room *models.ExamRoom, row *submissionRow) (repoName, htmlURL string, err error) {
if u := strings.TrimSpace(row.GitRepoURL); u != "" {
o, r, parseErr := parseGitHubRepo(u)
if parseErr == nil && strings.EqualFold(o, owner) {
if url, ok := githubGetRepo(token, owner, trimGitHubRepoName(r)); ok {
if _, name, perr := parseGitHubRepo(url); perr == nil && name != "" {
return name, url, nil
}
return trimGitHubRepoName(r), url, nil
}
clearStaleSubmissionGitURLs(db, row)
}
}
var tracked models.StaffGitHubRepo
if err := db.Where("staff_id = ? AND submission_id = ? AND publish_mode = ?", staffID, row.ID, models.GitPublishModeStudent).
Order("created_at desc").First(&tracked).Error; err == nil {
if strings.EqualFold(tracked.OwnerLogin, owner) {
trackedName := trimGitHubRepoName(tracked.RepoName)
if url, ok := githubGetRepo(token, owner, trackedName); ok {
if _, name, perr := parseGitHubRepo(url); perr == nil && name != "" {
return name, url, nil
}
return trackedName, url, nil
}
}
}
for _, candidate := range studentGitRepoNameCandidates(*row, room) {
if url, ok := githubGetRepo(token, owner, candidate); ok {
if repoNameMatchesSavedURL(row.GitRepoURL, owner, candidate) ||
githubRepoTrackedForSubmission(db, staffID, owner, candidate, row.ID) {
return candidate, url, nil
}
continue
}
actualName, url, createErr := githubCreateRepo(token, owner, candidate, true)
if createErr == nil {
return actualName, url, nil
}
if isGitHubRepoNameTaken(createErr) {
continue
}
return "", "", createErr
}
fallback := trimGitHubRepoName(fmt.Sprintf("%s-sc-%d-%d", studentSubmissionFolder(row.StudentCode, row.FullName), row.StudentRkID, time.Now().Unix()))
actualName, url, createErr := githubCreateRepo(token, owner, fallback, true)
if createErr != nil {
return "", "", fmt.Errorf("không tạo được repo GitHub cho sinh viên: %w", createErr)
}
return actualName, url, nil
}

View File

@@ -0,0 +1,322 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"server/internal/middleware"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type githubRepoListItem struct {
Key string `json:"key"`
OwnerLogin string `json:"ownerLogin"`
RepoName string `json:"repoName"`
RepoHTMLURL string `json:"repoHtmlUrl"`
Private bool `json:"private"`
PublishMode string `json:"publishMode,omitempty"`
ExamRoomID *uint `json:"examRoomId,omitempty"`
ExamRoomName string `json:"examRoomName,omitempty"`
StudentLabel string `json:"studentLabel,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
FromSimpleCare bool `json:"fromSimpleCare"`
}
func githubDeleteRepo(token, owner, repo string) error {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo))
req, err := http.NewRequest(http.MethodDelete, apiURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil
}
if resp.StatusCode >= 300 {
raw, _ := io.ReadAll(resp.Body)
msg := strings.TrimSpace(string(raw))
var errBody struct {
Message string `json:"message"`
}
_ = json.Unmarshal(raw, &errBody)
if m := strings.TrimSpace(errBody.Message); m != "" {
msg = m
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
if resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%s — token thiếu quyền delete_repo; ngắt kết nối rồi kết nối lại GitHub", msg)
}
return fmt.Errorf("%s", msg)
}
return nil
}
func githubListUserRepos(token string) ([]struct {
Name string `json:"name"`
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
Private bool `json:"private"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}, error) {
var all []struct {
Name string `json:"name"`
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
Private bool `json:"private"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
for page := 1; page <= 10; page++ {
apiURL := fmt.Sprintf("https://api.github.com/user/repos?per_page=100&page=%d&sort=updated&direction=desc", page)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
var batch []struct {
Name string `json:"name"`
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
Private bool `json:"private"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
resp.Body.Close()
return nil, err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("GitHub API lỗi (%d)", resp.StatusCode)
}
all = append(all, batch...)
if len(batch) < 100 {
break
}
}
return all, nil
}
type repoMeta struct {
PublishMode string
ExamRoomID *uint
ExamRoomName string
StudentLabel string
}
func repoMetaKey(owner, name string) string {
return strings.ToLower(owner) + "/" + strings.ToLower(name)
}
func buildRepoMetaMap(db *gorm.DB, staffID uint) map[string]repoMeta {
out := map[string]repoMeta{}
var tracked []models.StaffGitHubRepo
_ = db.Where("staff_id = ?", staffID).Find(&tracked).Error
roomNames := map[uint]string{}
for _, t := range tracked {
key := repoMetaKey(t.OwnerLogin, t.RepoName)
meta := repoMeta{
PublishMode: t.PublishMode,
ExamRoomID: t.ExamRoomID,
StudentLabel: t.StudentLabel,
}
if t.ExamRoomID != nil {
rid := *t.ExamRoomID
if name, ok := roomNames[rid]; ok {
meta.ExamRoomName = name
} else {
var room models.ExamRoom
if err := db.Select("name").First(&room, rid).Error; err == nil {
roomNames[rid] = room.Name
meta.ExamRoomName = room.Name
}
}
}
out[key] = meta
}
return out
}
func recordStaffGitHubRepo(db *gorm.DB, staffID uint, owner, repoName, htmlURL, publishMode, studentLabel string, examRoomID, submissionID *uint) {
owner = strings.TrimSpace(owner)
repoName = strings.TrimSpace(repoName)
if staffID == 0 || owner == "" || repoName == "" || htmlURL == "" {
return
}
_ = db.Where("staff_id = ? AND owner_login = ? AND repo_name = ?", staffID, owner, repoName).
Delete(&models.StaffGitHubRepo{}).Error
row := models.StaffGitHubRepo{
StaffID: staffID,
OwnerLogin: owner,
RepoName: repoName,
RepoHTMLURL: htmlURL,
ExamRoomID: examRoomID,
SubmissionID: submissionID,
StudentLabel: studentLabel,
PublishMode: publishMode,
}
_ = db.Create(&row).Error
}
// GET /api/auth/github/repos — toàn bộ repo trên tài khoản GitHub đã kết nối
func GitHubListReposHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
token, ghLogin, err := loadStaffGitHubToken(db, staffID)
if err != nil {
return c.Status(401).JSON(fiber.Map{"error": "Chưa kết nối GitHub"})
}
ghRepos, err := githubListUserRepos(token)
if err != nil {
return c.Status(502).JSON(fiber.Map{"error": "Không tải repo từ GitHub: " + err.Error()})
}
metaMap := buildRepoMetaMap(db, staffID)
out := make([]githubRepoListItem, 0, len(ghRepos))
for _, r := range ghRepos {
parts := strings.SplitN(r.FullName, "/", 2)
owner := ghLogin
if len(parts) == 2 {
owner = parts[0]
}
key := owner + "/" + r.Name
item := githubRepoListItem{
Key: key,
OwnerLogin: owner,
RepoName: r.Name,
RepoHTMLURL: r.HTMLURL,
Private: r.Private,
CreatedAt: r.CreatedAt.Format(time.RFC3339),
UpdatedAt: r.UpdatedAt.Format(time.RFC3339),
FromSimpleCare: strings.Contains(strings.ToLower(r.Description), "simple care"),
}
if meta, ok := metaMap[repoMetaKey(owner, r.Name)]; ok {
item.PublishMode = meta.PublishMode
item.ExamRoomID = meta.ExamRoomID
item.ExamRoomName = meta.ExamRoomName
item.StudentLabel = meta.StudentLabel
item.FromSimpleCare = true
}
out = append(out, item)
}
return c.JSON(fiber.Map{"data": out, "total": len(out), "githubLogin": ghLogin})
}
}
// POST /api/auth/github/repos/bulk-delete — repos: ["owner/name", ...]
func GitHubBulkDeleteReposHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
token, _, err := loadStaffGitHubToken(db, staffID)
if err != nil {
return c.Status(401).JSON(fiber.Map{"error": "Chưa kết nối GitHub"})
}
var authRow models.StaffGitHubAuth
_ = db.First(&authRow, "staff_id = ?", staffID).Error
if !githubScopeAllowsDelete(authRow.Scope) {
return c.Status(403).JSON(fiber.Map{
"error": "Token GitHub chưa có quyền xóa repo (delete_repo). Ngắt kết nối rồi bấm Kết nối lại GitHub.",
})
}
var req struct {
Repos []string `json:"repos"`
IDs []uint `json:"ids"` // legacy
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
}
targets := make([]struct{ owner, name string }, 0)
for _, raw := range req.Repos {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
parts := strings.SplitN(raw, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
continue
}
targets = append(targets, struct{ owner, name string }{owner: parts[0], name: parts[1]})
}
if len(targets) == 0 && len(req.IDs) > 0 {
var rows []models.StaffGitHubRepo
_ = db.Where("staff_id = ? AND id IN ?", staffID, req.IDs).Find(&rows).Error
for _, row := range rows {
targets = append(targets, struct{ owner, name string }{owner: row.OwnerLogin, name: row.RepoName})
}
}
if len(targets) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "Chọn ít nhất một repo"})
}
deleted := 0
var failures []string
for _, t := range targets {
if err := githubDeleteRepo(token, t.owner, t.name); err != nil {
failures = append(failures, t.name+": "+err.Error())
continue
}
var row models.StaffGitHubRepo
if err := db.Where("staff_id = ? AND owner_login = ? AND repo_name = ?", staffID, t.owner, t.name).First(&row).Error; err == nil {
_ = db.Delete(&row).Error
clearLinkedGitURLs(db, row)
} else {
clearLinkedGitURLsByName(db, t.owner, t.name)
}
deleted++
}
return c.JSON(fiber.Map{
"ok": deleted > 0,
"deleted": deleted,
"failures": failures,
"message": fmt.Sprintf("Đã xóa %d repo trên GitHub", deleted),
})
}
}
func clearLinkedGitURLs(db *gorm.DB, row models.StaffGitHubRepo) {
url := strings.TrimSpace(row.RepoHTMLURL)
if url == "" {
return
}
if row.ExamRoomID != nil && row.PublishMode == models.GitPublishModeRoom {
_ = db.Model(&models.ExamRoom{}).Where("id = ? AND git_repo_url = ?", *row.ExamRoomID, url).
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
}
if row.SubmissionID != nil {
_ = db.Model(&models.ExamSubmission{}).Where("id = ? AND git_repo_url = ?", *row.SubmissionID, url).
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
}
}
func clearLinkedGitURLsByName(db *gorm.DB, owner, repoName string) {
prefix := fmt.Sprintf("https://github.com/%s/%s", owner, repoName)
_ = db.Model(&models.ExamRoom{}).Where("git_repo_url LIKE ?", prefix+"%").
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
_ = db.Model(&models.ExamSubmission{}).Where("git_repo_url LIKE ?", prefix+"%").
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
}

View File

@@ -18,8 +18,11 @@ type ExamRoom struct {
StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"` StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"`
EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"` EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"`
AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"` AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"`
QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"` QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"`
Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"` GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl"`
GitBranch string `gorm:"column:git_branch;size:64;default:main" json:"gitBranch"`
GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"`
Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"`
} }
func (ExamRoom) TableName() string { return "exam_rooms" } func (ExamRoom) TableName() string { return "exam_rooms" }
@@ -63,8 +66,10 @@ type ExamSubmission struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
ExamRoomID uint `gorm:"column:exam_room_id;not null;index" json:"examRoomId"` ExamRoomID uint `gorm:"column:exam_room_id;not null;index" json:"examRoomId"`
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"` StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"` FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"`
FileName string `gorm:"column:file_name;size:255;not null" json:"fileName"` FileName string `gorm:"column:file_name;size:255;not null" json:"fileName"`
GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl,omitempty"`
GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"`
} }
func (ExamSubmission) TableName() string { return "exam_submissions" } func (ExamSubmission) TableName() string { return "exam_submissions" }

View File

@@ -122,6 +122,18 @@ type AppPoolEntry struct {
func (AppPoolEntry) TableName() string { return "app_pool" } func (AppPoolEntry) TableName() string { return "app_pool" }
// AppTemplate — khung ứng dụng (bộ keyword whitelist) dùng chung cho lớp / phòng thi
type AppTemplate struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Name string `gorm:"column:name;size:120;not null" json:"name"`
Description string `gorm:"column:description;type:text" json:"description"`
Keywords string `gorm:"column:keywords;type:text;not null" json:"keywords"`
}
func (AppTemplate) TableName() string { return "app_templates" }
// WifiPoolEntry — kho WiFi phát hiện từ máy sinh viên (SSID + BSSID điểm phát) // WifiPoolEntry — kho WiFi phát hiện từ máy sinh viên (SSID + BSSID điểm phát)
type WifiPoolEntry struct { type WifiPoolEntry struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`

View File

@@ -0,0 +1,15 @@
package models
import "time"
// StaffGitHubAuth — token OAuth GitHub của từng giáo viên
type StaffGitHubAuth struct {
StaffID uint `gorm:"primaryKey;column:staff_id" json:"staffId"`
GitHubLogin string `gorm:"column:github_login;size:128;not null" json:"githubLogin"`
AccessTokenEnc string `gorm:"column:access_token_enc;type:text;not null" json:"-"`
Scope string `gorm:"column:scope;size:255" json:"scope"`
ConnectedAt time.Time `gorm:"column:connected_at;not null" json:"connectedAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (StaffGitHubAuth) TableName() string { return "staff_github_auth" }

View File

@@ -0,0 +1,26 @@
package models
import "time"
const (
GitPublishModeRoom = "room"
GitPublishModeStudent = "student"
)
// StaffGitHubRepo — repo GitHub do giáo viên tạo qua Simple Care
type StaffGitHubRepo struct {
ID uint `gorm:"primaryKey" json:"id"`
StaffID uint `gorm:"column:staff_id;not null;index" json:"staffId"`
OwnerLogin string `gorm:"column:owner_login;size:128;not null" json:"ownerLogin"`
RepoName string `gorm:"column:repo_name;size:128;not null" json:"repoName"`
RepoHTMLURL string `gorm:"column:repo_html_url;size:512;not null" json:"repoHtmlUrl"`
ExamRoomID *uint `gorm:"column:exam_room_id;index" json:"examRoomId,omitempty"`
ExamRoomName string `gorm:"-" json:"examRoomName,omitempty"`
SubmissionID *uint `gorm:"column:submission_id;index" json:"submissionId,omitempty"`
StudentLabel string `gorm:"column:student_label;size:255" json:"studentLabel,omitempty"`
PublishMode string `gorm:"column:publish_mode;size:16;not null;default:room" json:"publishMode"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (StaffGitHubRepo) TableName() string { return "staff_github_repos" }

View File

@@ -15,6 +15,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/recover"
gofiberWs "github.com/gofiber/websocket/v2" gofiberWs "github.com/gofiber/websocket/v2"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
@@ -57,10 +58,12 @@ func main() {
BodyLimit: 100 * 1024 * 1024, // 100MB — upload PDF gói đề BodyLimit: 100 * 1024 * 1024, // 100MB — upload PDF gói đề
}) })
app.Use(recover.New())
app.Use(cors.New(cors.Config{ app.Use(cors.New(cors.Config{
AllowOrigins: "*", AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept, Authorization", AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, PATCH, PUT, DELETE, OPTIONS", AllowMethods: "GET, POST, PATCH, PUT, DELETE, OPTIONS",
ExposeHeaders: "Content-Disposition, Content-Length",
})) }))
// WebSocket Upgrade Check // WebSocket Upgrade Check
@@ -82,6 +85,7 @@ func main() {
api.Post("/auth/login", handlers.LoginStaffHandler(gormDB)) api.Post("/auth/login", handlers.LoginStaffHandler(gormDB))
api.Post("/auth/forgot-password", handlers.ForgotPasswordHandler(gormDB, mailer)) api.Post("/auth/forgot-password", handlers.ForgotPasswordHandler(gormDB, mailer))
api.Post("/auth/reset-password", handlers.ResetPasswordHandler(gormDB)) api.Post("/auth/reset-password", handlers.ResetPasswordHandler(gormDB))
api.Get("/auth/github/callback", handlers.GitHubOAuthCallbackHandler(gormDB))
// Student client (không cần JWT staff) // Student client (không cần JWT staff)
api.Post("/student/sync-log", handlers.SyncStudentSessionLogHandler(gormDB)) api.Post("/student/sync-log", handlers.SyncStudentSessionLogHandler(gormDB))
@@ -105,6 +109,11 @@ func main() {
staff := api.Group("", middleware.RequireStaff()) staff := api.Group("", middleware.RequireStaff())
staff.Get("/auth/me", handlers.MeStaffHandler(gormDB)) staff.Get("/auth/me", handlers.MeStaffHandler(gormDB))
staff.Post("/auth/change-password", handlers.ChangePasswordHandler(gormDB)) staff.Post("/auth/change-password", handlers.ChangePasswordHandler(gormDB))
staff.Get("/auth/github/authorize", handlers.GitHubAuthorizeURLHandler(gormDB))
staff.Get("/auth/github/status", handlers.GitHubStatusHandler(gormDB))
staff.Get("/auth/github/repos", handlers.GitHubListReposHandler(gormDB))
staff.Post("/auth/github/repos/bulk-delete", handlers.GitHubBulkDeleteReposHandler(gormDB))
staff.Delete("/auth/github", handlers.GitHubDisconnectHandler(gormDB))
staff.Get("/stats", handlers.GetStatsHandler(gormDB)) staff.Get("/stats", handlers.GetStatsHandler(gormDB))
@@ -131,6 +140,10 @@ func main() {
staff.Get("/classes/:rkId/allowed-apps", handlers.GetAllowedAppsHandler(gormDB)) staff.Get("/classes/:rkId/allowed-apps", handlers.GetAllowedAppsHandler(gormDB))
staff.Post("/classes/:rkId/allowed-apps", handlers.SaveAllowedAppsHandler(gormDB)) staff.Post("/classes/:rkId/allowed-apps", handlers.SaveAllowedAppsHandler(gormDB))
staff.Get("/app-pool", handlers.ListAppPoolHandler(gormDB)) staff.Get("/app-pool", handlers.ListAppPoolHandler(gormDB))
staff.Get("/app-templates", handlers.ListAppTemplatesHandler(gormDB))
staff.Post("/app-templates", handlers.CreateAppTemplateHandler(gormDB))
staff.Patch("/app-templates/:id", handlers.UpdateAppTemplateHandler(gormDB))
staff.Delete("/app-templates/:id", handlers.DeleteAppTemplateHandler(gormDB))
staff.Get("/wifi-pool", handlers.ListWifiPoolHandler(gormDB)) staff.Get("/wifi-pool", handlers.ListWifiPoolHandler(gormDB))
staff.Get("/network/accepted-wifis", handlers.ListAcceptedWifisHandler(gormDB)) staff.Get("/network/accepted-wifis", handlers.ListAcceptedWifisHandler(gormDB))
@@ -145,6 +158,7 @@ func main() {
staff.Get("/classes/:rkId/attendance", handlers.GetClassAttendanceHandler(gormDB)) staff.Get("/classes/:rkId/attendance", handlers.GetClassAttendanceHandler(gormDB))
staff.Get("/classes/:rkId/attendance/shifts", handlers.ListAttendanceShiftsHandler(gormDB)) staff.Get("/classes/:rkId/attendance/shifts", handlers.ListAttendanceShiftsHandler(gormDB))
staff.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(gormDB)) staff.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(gormDB))
staff.Put("/classes/:rkId/attendance/bulk-status", handlers.UpdateAttendanceBulkStatusHandler(gormDB))
staff.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient, qldtToken)) staff.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient, qldtToken))
// Chat & quản lý tài khoản // Chat & quản lý tài khoản
@@ -170,12 +184,19 @@ func main() {
staff.Post("/exam-rooms/:id/papers", handlers.UploadExamPaperHandler(gormDB)) staff.Post("/exam-rooms/:id/papers", handlers.UploadExamPaperHandler(gormDB))
staff.Post("/exam-rooms/:id/papers/:paperId/resources", handlers.UploadExamPaperResourceHandler(gormDB)) staff.Post("/exam-rooms/:id/papers/:paperId/resources", handlers.UploadExamPaperResourceHandler(gormDB))
staff.Delete("/exam-rooms/:id/papers/:paperId", handlers.DeleteExamPaperHandler(gormDB)) staff.Delete("/exam-rooms/:id/papers/:paperId", handlers.DeleteExamPaperHandler(gormDB))
staff.Get("/exam-rooms/:id/papers/:paperId/view", handlers.ViewStaffExamPaperHandler(gormDB))
staff.Get("/exam-rooms/:id/papers/:paperId/resources/:resId/view", handlers.ViewStaffExamPaperResourceHandler(gormDB))
staff.Get("/exam-rooms/:id/papers/:paperId/download", handlers.DownloadStaffExamPaperFileHandler(gormDB))
staff.Post("/exam-rooms/:id/publish", handlers.PublishExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/publish", handlers.PublishExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/assign-random", handlers.RandomAssignExamPapersHandler(gormDB)) staff.Post("/exam-rooms/:id/assign-random", handlers.RandomAssignExamPapersHandler(gormDB))
staff.Post("/exam-rooms/:id/send-papers", handlers.SendExamPapersHandler(gormDB)) staff.Post("/exam-rooms/:id/send-papers", handlers.SendExamPapersHandler(gormDB))
staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB)) staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB))
staff.Get("/exam-rooms/:id/submissions/download-all", handlers.DownloadAllExamSubmissionsHandler(gormDB))
staff.Post("/exam-rooms/:id/submissions/publish-git", handlers.PublishExamSubmissionsGitHandler(gormDB))
staff.Post("/exam-rooms/:id/submissions/publish-git-students", handlers.PublishExamSubmissionsGitPerStudentHandler(gormDB))
staff.Patch("/exam-rooms/:id/git-settings", handlers.UpdateExamRoomGitSettingsHandler(gormDB))
staff.Get("/exam-rooms/:id/submissions/:subId/download", handlers.DownloadExamSubmissionHandler(gormDB)) staff.Get("/exam-rooms/:id/submissions/:subId/download", handlers.DownloadExamSubmissionHandler(gormDB))
go func() { go func() {

1
server/test-dl.zip Normal file
View File

@@ -0,0 +1 @@
{"error":"Unauthorized"}

Binary file not shown.

View File

@@ -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"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

View File

@@ -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"
}
]
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -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"
}
]
}