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/rand"
"encoding/json"
"encoding/base64"
"errors"
"fmt"
"html"
"io"
"log"
"mime/multipart"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -86,6 +89,7 @@ type App struct {
mu sync.Mutex
sessionPath string
statsPath string
webviewDataPath string
wsConn *websocket.Conn
wsConnected bool
wifiSSID string
@@ -109,6 +113,7 @@ type App struct {
monitoringTornDown bool
chatUnread int
replyStaffID uint
fetchAppsMu sync.Mutex
}
type LocalStats struct {
@@ -164,11 +169,14 @@ func NewApp() *App {
configDir = filepath.Dir(exePath)
}
appDir := filepath.Join(configDir, "SimpleCare")
webviewDir := filepath.Join(appDir, "WebView2")
_ = os.MkdirAll(appDir, 0755)
_ = os.MkdirAll(webviewDir, 0755)
return &App{
sessionPath: filepath.Join(appDir, "student_session.json"),
statsPath: filepath.Join(appDir, "student_stats.json"),
webviewDataPath: webviewDir,
lastSyncTime: time.Now(),
expectingLogin: false,
}
@@ -292,6 +300,7 @@ func (a *App) startLocalServer() {
go a.refreshStudentData()
// 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)
w.Write([]byte("ok"))
} else {
@@ -304,12 +313,71 @@ func (a *App) startLocalServer() {
Handler: mux,
}
mux.HandleFunc("/exam-view", func(w http.ResponseWriter, r *http.Request) {
target := strings.TrimSpace(r.URL.Query().Get("url"))
title := strings.TrimSpace(r.URL.Query().Get("title"))
if target == "" {
http.Error(w, "missing url", http.StatusBadRequest)
return
}
if title == "" {
title = "Xem trong Simple Care"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprintf(w, examViewHTML, html.EscapeString(title), html.EscapeString(title), html.EscapeString(target))
})
mux.HandleFunc("/exam-close", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
return
}
go func() {
guard.SuppressFor(5 * time.Second)
runtime.WindowReloadApp(a.ctx)
}()
w.Write([]byte("ok"))
})
go func() {
_ = server.ListenAndServe()
}()
log.Println("[LOCALSERVER] Callback server started on http://127.0.0.1:34115")
}
const examViewHTML = `<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Segoe UI,system-ui,sans-serif;background:#1e293b;color:#f8fafc;height:100vh;display:flex;flex-direction:column}
.bar{display:flex;align-items:center;gap:12px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0}
.bar button{background:#7c3aed;color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:14px;cursor:pointer;font-weight:600}
.bar button:hover{background:#6d28d9}
.bar span{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.frame-wrap{flex:1;min-height:0;background:#fff}
iframe{width:100%%;height:100%%;border:0;display:block}
</style>
</head>
<body>
<div class="bar">
<button type="button" onclick="goBack()">← Quay lại</button>
<span>%s</span>
</div>
<div class="frame-wrap">
<iframe src="%s" title="exam-content"></iframe>
</div>
<script>
function goBack(){
fetch('http://127.0.0.1:34115/exam-close').catch(function(){});
}
</script>
</body>
</html>`
// loadSession nạp session từ file cục bộ
func (a *App) loadSession() {
a.mu.Lock()
@@ -432,6 +500,7 @@ func (a *App) NavigateToLogin() {
a.mu.Lock()
a.expectingLogin = true
a.mu.Unlock()
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'")
}
@@ -456,6 +525,7 @@ func (a *App) Logout() {
a.disconnectWS()
a.stopScreenshotStream()
guard.SuppressFor(5 * time.Second)
runtime.WindowReloadApp(a.ctx)
}
@@ -849,6 +919,9 @@ func (a *App) rejectUnauthorizedWifi(ssid, bssid string) {
}
func (a *App) fetchAllowedApps(classId int64) {
a.fetchAppsMu.Lock()
defer a.fetchAppsMu.Unlock()
a.mu.Lock()
studentID := int64(0)
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 res.Exit {
a.mu.Lock()
stillExam := a.dashboard.MonitorMode == "exam"
a.mu.Unlock()
if stillExam {
return
}
reason := res.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)."
@@ -921,6 +1000,7 @@ func (a *App) fetchStudentStatus(studentID int64) {
}
a.mu.Lock()
prevMode := a.dashboard.MonitorMode
a.dashboard = res
if res.MonitorLabel != "" {
a.statusMsg = res.MonitorLabel
@@ -935,6 +1015,10 @@ func (a *App) fetchStudentStatus(studentID int64) {
a.statusMsg = res.MonitorLabel
}
a.mu.Unlock()
if prevMode != res.MonitorMode {
guard.SuppressFor(4 * time.Second)
}
}
func (a *App) connectWS() {
@@ -1215,72 +1299,307 @@ func (a *App) SendChatMessage(body string) error {
return nil
}
func (a *App) OpenExamQuiz() error {
func (a *App) examStudentContext() (int64, *StudentExamSnapshot, error) {
a.mu.Lock()
var url string
if a.dashboard.Exam != nil {
url = strings.TrimSpace(a.dashboard.Exam.QuizURL)
defer a.mu.Unlock()
if a.student == nil {
return 0, nil, errors.New("chưa đăng nhập")
}
a.mu.Unlock()
if url == "" {
return errors.New("Phòng thi chưa cấu hình link trắc nghiệm")
if a.dashboard.Exam == nil {
return 0, nil, errors.New("không trong giờ thi")
}
return openPrivateBrowser(url)
return a.student.StudentID, a.dashboard.Exam, nil
}
func openPrivateBrowser(url string) error {
tries := [][]string{
{"cmd", "/c", "start", "", "chrome", "--incognito", "--new-window", "--disk-cache-size=1", url},
{"cmd", "/c", "start", "", "msedge", "--inprivate", "--new-window", url},
{"cmd", "/c", "start", "", url},
// ReturnToDashboard quay lại giao diện chính của app (giữ cookie WebView2).
func (a *App) ReturnToDashboard() {
guard.SuppressFor(5 * time.Second)
runtime.WindowReloadApp(a.ctx)
}
func isLocalExamURL(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" {
return false
}
var lastErr error
for _, args := range tries {
if err := exec.Command(args[0], args[1:]...).Start(); err != nil {
lastErr = err
continue
host := strings.ToLower(u.Hostname())
return host == "127.0.0.1" || host == "localhost"
}
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
}
if lastErr != nil {
return lastErr
}
return errors.New("không mở được trình duyệt")
// Trang thi bên ngoài: mở trực tiếp trong WebView (first-party cookies → giữ phiên đăng nhập).
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL))
return nil
}
func (a *App) OpenExamPaper() error {
a.mu.Lock()
student := a.student
exam := a.dashboard.Exam
a.mu.Unlock()
if student == nil {
return errors.New("chưa đăng nhập")
}
if exam == nil || !exam.PaperSent {
return errors.New("Giảng viên chưa gửi đề")
}
dlURL := fmt.Sprintf("http://127.0.0.1:8080/api/student/exam/download?studentRkId=%d&kind=pdf", student.StudentID)
resp, err := http.Get(dlURL)
func (a *App) GetExamPaperViewURL() (string, error) {
studentID, exam, err := a.examStudentContext()
if err != nil {
return err
return "", err
}
if !exam.PaperSent {
return "", errors.New("Giảng viên chưa gửi đề")
}
return fmt.Sprintf("http://127.0.0.1:8080/api/student/exam/download?studentRkId=%d&kind=pdf&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()
if resp.StatusCode != http.StatusOK {
return errors.New("không tải được đề")
return nil, errors.New("không tải được danh sách tài nguyên")
}
dir := filepath.Join(os.TempDir(), "SimpleCareExam")
_ = os.MkdirAll(dir, 0755)
dest := filepath.Join(dir, fmt.Sprintf("de_thi_%d.pdf", exam.ExamRoomID))
f, err := os.Create(dest)
var payload map[string]any
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
base := "http://127.0.0.1:8080"
if pdf, ok := payload["pdfUrl"].(string); ok && pdf != "" && !strings.HasPrefix(pdf, "http") {
payload["pdfUrl"] = base + pdf
}
if raw, ok := payload["resources"].([]any); ok {
for i, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if u, ok := m["url"].(string); ok && u != "" && !strings.HasPrefix(u, "http") {
m["url"] = base + u
}
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 {
return err
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
title := "Đề thi"
a.mu.Lock()
if a.dashboard.Exam != nil && strings.TrimSpace(a.dashboard.Exam.PaperTitle) != "" {
title = strings.TrimSpace(a.dashboard.Exam.PaperTitle)
}
a.mu.Unlock()
return a.openExamWebView(viewURL, title)
}
func (a *App) OpenExamQuiz() error {
viewURL, err := a.GetExamQuizViewURL()
if err != nil {
return err
}
return a.openExamWebView(viewURL, "Làm trắc nghiệm")
}
func (a *App) OpenExamResource(fileID uint) error {
studentID, _, err := a.examStudentContext()
if err != nil {
return err
}
if fileID == 0 {
return errors.New("tài nguyên không hợp lệ")
}
viewURL := fmt.Sprintf(
"http://127.0.0.1:8080/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d&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 exec.Command("cmd", "/c", "start", "", dest).Start()
return "", err
}
f.Close()
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) {
@@ -1388,8 +1707,8 @@ func zipFolder(srcDir, destZip string) error {
if err != nil {
return err
}
defer rf.Close()
_, err = io.Copy(w, rf)
return err
_, copyErr := io.Copy(w, rf)
rf.Close()
return copyErr
})
}

View File

@@ -7,6 +7,9 @@
"": {
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"pdfjs-dist": "^3.11.174"
},
"devDependencies": {
"vite": "^3.0.7"
}
@@ -45,6 +48,202 @@
"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": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -433,6 +632,39 @@
"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": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -458,6 +690,57 @@
"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": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -471,6 +754,39 @@
"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": {
"version": "2.16.2",
"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"
}
},
"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": {
"version": "3.3.15",
"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_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": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -513,6 +1036,29 @@
"dev": true,
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -549,6 +1095,21 @@
"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": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -571,6 +1132,23 @@
"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": {
"version": "2.80.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
@@ -587,6 +1165,87 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -597,6 +1256,44 @@
"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": {
"version": "1.0.0",
"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"
}
},
"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": {
"version": "3.2.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz",
@@ -659,6 +1389,48 @@
"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": {
"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);
}
.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 {
display: grid;
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 './app.css';
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" />`;
@@ -68,9 +75,12 @@ function init() {
});
window.runtime.EventsOn('exam:paper-sent', () => {
if (loggedIn) {
examPaperFiles = null;
examPaperFilesRoomId = 0;
lastExamPanelKey = '';
window.go.main.App.GetStats().then((s) => {
stats = { ...stats, ...s };
updateExamPanel();
updateExamPanel(true);
}).catch(console.error);
}
});
@@ -87,6 +97,7 @@ async function checkLogin() {
startStatsTicker();
ensureChatWidget();
updateChatBadge();
if (stats.monitorMode === 'exam') updateExamPanel();
} else {
loggedIn = false;
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() {
const ex = stats.exam;
if (!ex || stats.monitorMode !== 'exam') return '';
@@ -195,20 +401,27 @@ function renderExamPanel() {
<div class="card-title-row">
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
</div>
<p class="exam-panel-desc">Bạn đang trong giờ thi. Làm bài theo hướng dẫn của giảng viên.</p>
<p class="exam-panel-desc">Đề 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">
${paperBtn}
${quizBtn}
${submitBtn}
</div>
${renderExamResources()}
</div>
`;
}
function wireExamPanel() {
const ex = stats.exam;
const paper = document.getElementById('btn-exam-paper');
if (paper) paper.addEventListener('click', () => {
window.go.main.App.OpenExamPaper().catch((e) => alert(e?.message || e));
if (paper) paper.addEventListener('click', async () => {
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');
if (quiz) quiz.addEventListener('click', () => {
@@ -221,20 +434,44 @@ function wireExamPanel() {
alert(`Đã nộp bài: ${name}`);
const fresh = await window.go.main.App.GetStats();
stats = { ...stats, ...fresh };
await loadExamPaperFiles();
updateExamPanel();
} catch (e) {
alert(e?.message || e || 'Nộp bài thất bại');
}
});
document.querySelectorAll('.btn-exam-res-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');
if (!host) return;
if (stats.monitorMode !== 'exam' || !stats.exam) {
host.innerHTML = '';
lastExamPanelKey = '';
return;
}
await loadExamPaperFiles(forceReloadFiles);
host.innerHTML = renderExamPanel();
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() {
if (!studentInfo) return;
@@ -485,7 +722,7 @@ function renderChatMessages(silent = false) {
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function sendStudentChat() {
@@ -575,7 +812,17 @@ function startStatsTicker() {
}
}
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');
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 DownloadExamResource(arg1:number):Promise<string>;
export function GetChatConversations():Promise<Array<Record<string, any>>>;
export function GetChatMessages(arg1:number):Promise<Array<Record<string, any>>>;
export function GetChatUnread():Promise<number>;
export function GetExamPaperFiles():Promise<Record<string, any>>;
export function GetExamPaperViewURL():Promise<string>;
export function GetExamQuizViewURL():Promise<string>;
export function GetStats():Promise<Record<string, any>>;
export function GetStudentInfo():Promise<Record<string, any>>;
export function LoadExamViewFile(arg1:string):Promise<Record<string, any>>;
export function Logout():Promise<void>;
export function NavigateToLogin():Promise<void>;
@@ -23,6 +33,10 @@ export function OpenExamPaper():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 SendWebcamFrame(arg1:string):Promise<void>;

View File

@@ -10,6 +10,10 @@ export function ClearChatUnread() {
return window['go']['main']['App']['ClearChatUnread']();
}
export function DownloadExamResource(arg1) {
return window['go']['main']['App']['DownloadExamResource'](arg1);
}
export function GetChatConversations() {
return window['go']['main']['App']['GetChatConversations']();
}
@@ -22,6 +26,18 @@ export function GetChatUnread() {
return window['go']['main']['App']['GetChatUnread']();
}
export function GetExamPaperFiles() {
return window['go']['main']['App']['GetExamPaperFiles']();
}
export function GetExamPaperViewURL() {
return window['go']['main']['App']['GetExamPaperViewURL']();
}
export function GetExamQuizViewURL() {
return window['go']['main']['App']['GetExamQuizViewURL']();
}
export function GetStats() {
return window['go']['main']['App']['GetStats']();
}
@@ -30,6 +46,10 @@ export function GetStudentInfo() {
return window['go']['main']['App']['GetStudentInfo']();
}
export function LoadExamViewFile(arg1) {
return window['go']['main']['App']['LoadExamViewFile'](arg1);
}
export function Logout() {
return window['go']['main']['App']['Logout']();
}
@@ -46,6 +66,14 @@ export function 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) {
return window['go']['main']['App']['SendChatMessage'](arg1);
}

View File

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

View File

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

View File

@@ -79,8 +79,29 @@ var (
guardBaselineSession uint32
desktopHook uintptr
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).
func Start(onViolation func(reason string)) {
guardOnce.Do(func() {
@@ -122,7 +143,7 @@ func pollLoop() {
}
func checkEnvironment() {
if guardViolation == nil {
if guardViolation == nil || violationsSuppressed() {
return
}
if n := monitorCount(); n > 1 {
@@ -141,6 +162,10 @@ func checkEnvironment() {
}
func triggerViolation(reason string) {
if violationsSuppressed() {
log.Printf("[GUARD] suppressed: %s", reason)
return
}
if guardViolation != nil {
guardViolation(reason)
}

View File

@@ -4,8 +4,11 @@ import (
"embed"
"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/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/windows"
)
//go:embed all:frontend/dist
@@ -15,16 +18,26 @@ func main() {
// Create an instance of the app structure
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
err := wails.Run(&options.App{
Title: "Simple Care — Rikkei Education",
Width: 1024,
Height: 768,
Menu: appMenu,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
Windows: &windows.Options{
WebviewUserDataPath: app.webviewDataPath,
},
Bind: []interface{}{
app,
},

View File

@@ -8,6 +8,7 @@
"name": "management",
"version": "0.0.0",
"dependencies": {
"pdfjs-dist": "^3.11.174",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
@@ -349,6 +350,40 @@
"@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": {
"version": "1.1.6",
"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": {
"version": "1.0.0",
"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"
}
},
"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": {
"version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
@@ -1173,6 +1267,17 @@
"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": {
"version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
@@ -1228,6 +1333,56 @@
],
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1246,7 +1401,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"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": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -1277,6 +1452,13 @@
"dev": true,
"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": {
"version": "3.2.0",
"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": {
"version": "2.3.3",
"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_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": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -1330,6 +1574,78 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1646,13 +1962,119 @@
"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": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"devOptional": true,
"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": {
"version": "3.3.15",
"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_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": {
"version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
@@ -1682,6 +2125,56 @@
"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": {
"version": "1.71.0",
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1801,6 +2327,38 @@
"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": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
@@ -1835,6 +2393,27 @@
"@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": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1845,12 +2424,59 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"devOptional": true,
"license": "ISC",
"bin": {
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1861,6 +2487,70 @@
"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": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1878,6 +2568,13 @@
"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": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1938,6 +2635,13 @@
"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": {
"version": "8.1.0",
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"pdfjs-dist": "^3.11.174",
"react": "^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 { ClassesTab } from './components/ClassesTab';
import { StudentsTab } from './components/StudentsTab';
import { LearningTab } from './components/LearningTab';
import { ExamsTab } from './components/ExamsTab';
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
import { NetworkTab } from './components/NetworkTab';
import { EmailDomainsTab, MyAccountTab } from './components/AccountsTab';
import { SystemTab } from './components/SystemTab';
import { MyAccountTab } from './components/AccountsTab';
import { ChatWidget } from './components/ChatWidget';
import { StaffChatSocket } from './hooks/useStaffChatSocket';
import { ClassWorkspace } from './components/ClassWorkspace';
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';
const IconDashboard = () => (
@@ -51,36 +61,54 @@ const IconExam = () => (
</svg>
);
const IconNetwork = () => (
const IconSystem = () => (
<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" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<circle cx="12" cy="20" r="1" />
<circle cx="12" cy="12" r="3" />
<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" />
</svg>
);
const IconEmail = () => (
const IconMenu = () => (
<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" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
<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" />
</svg>
);
const SYSTEM_NAV: { section: SystemSection }[] = [
{ section: 'organization' },
{ section: 'network' },
{ section: 'templates' },
];
function App() {
const route = useRoute();
const { staff, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
const initial = parseRoute();
if (initial.classId || initial.examId) {
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) => {
navigate(tab as TabId);
setSidebarOpen(false);
};
const handleBackFromWorkspace = () => {
@@ -89,9 +117,39 @@ function App() {
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 (
<>
<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">
<img src="/logo.jpeg" alt="Simple Care" className="brand-logo-img" />
<div className="brand-text">
@@ -105,8 +163,8 @@ function App() {
<div className="nav-header">Tổng quan</div>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'dashboard' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('dashboard')}
className={navBtn(route.tab === 'dashboard' && !inWorkspace)}
onClick={() => setActiveTab('dashboard')}
>
<span className="nav-icon"><IconDashboard /></span>
Tổng quan
@@ -115,18 +173,15 @@ function App() {
<div className="nav-header">CSDL liên kết</div>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'classes' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('classes')}
>
<button className={navBtn(classesActive)} onClick={() => setActiveTab('classes')}>
<span className="nav-icon"><IconClass /></span>
Lớp học
</button>
</li>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'students' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('students')}
className={navBtn(route.tab === 'students' && !inWorkspace)}
onClick={() => setActiveTab('students')}
>
<span className="nav-icon"><IconStudent /></span>
Sinh viên
@@ -135,43 +190,40 @@ function App() {
<div className="nav-header">Quản học tập</div>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'learning' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('learning')}
>
<button className={navBtn(learningActive)} onClick={() => setActiveTab('learning')}>
<span className="nav-icon"><IconLearning /></span>
Giám sát & Lịch học
Phòng Học
</button>
</li>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'exams' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('exams')}
>
<button className={navBtn(examsActive)} onClick={() => setActiveTab('exams')}>
<span className="nav-icon"><IconExam /></span>
Phòng thi
</button>
</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>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'email-domains' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('email-domains')}
className={navBtn(systemActive)}
onClick={() => goSystem('organization')}
>
<span className="nav-icon"><IconEmail /></span>
Đuôi email
<span className="nav-icon"><IconSystem /></span>
Quản hệ thống
</button>
</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>
</nav>
@@ -179,7 +231,7 @@ function App() {
<button
type="button"
className={`sidebar-user-btn ${route.tab === 'profile' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('profile')}
onClick={() => setActiveTab('profile')}
title="Tài khoản của tôi"
>
<span className="sidebar-user-name">
@@ -213,8 +265,7 @@ function App() {
{route.tab === 'students' && <StudentsTab />}
{route.tab === 'learning' && <LearningTab />}
{route.tab === 'exams' && <ExamsTab />}
{route.tab === 'network' && <NetworkTab />}
{route.tab === 'email-domains' && <EmailDomainsTab />}
{route.tab === 'system' && <SystemTab />}
{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 {
id: number;
domain: string;
@@ -445,6 +502,38 @@ export const apiFetchAppPool = async (q = '', limit = 50): Promise<{ data: AppPo
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 {
id: number;
ssid: string;
@@ -560,6 +649,21 @@ export const apiUpdateAttendanceStatus = async (
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) => {
const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, {
method: 'POST',
@@ -586,6 +690,9 @@ export interface ExamRoomItem {
endTime: string;
allowedApps: string;
quizUrl: string;
gitRepoUrl?: string;
gitBranch?: string;
gitPublishUrl?: string;
status: 'draft' | 'ready' | 'ended' | 'cancelled';
studentCount: number;
paperCount: number;
@@ -630,6 +737,15 @@ export interface ExamSubmission {
createdAt: string;
fullName: 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 = {
@@ -736,6 +852,38 @@ export const apiExam = {
if (!res.ok) await parseError(res, 'Xóa gói đề thất bại');
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) => {
const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' });
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[] }>;
},
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 { apiAdmin, type EmailDomainItem } from '../api';
import { useGitHubConnection } from '../hooks/useGitHubConnection';
import { GitHubReposPanel } from './GitHubReposPanel';
import { OrganizationSection } from './OrganizationSection';
export function ChangePasswordPage({ forced }: { forced?: boolean }) {
const { changePassword, logout } = useAuth();
@@ -65,84 +67,35 @@ export function ChangePasswordPage({ forced }: { forced?: boolean }) {
}
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 (
<div className="page-stack">
<div className="tab-page">
<header className="page-header">
<h1 className="page-title">Đuôi email</h1>
<p className="page-desc">Cấu hình đuôi email đưc phép đăng đăng nhập.</p>
<h1 className="page-title">Quản tổ chức</h1>
</header>
<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>
<OrganizationSection />
</div>
);
}
export function MyAccountTab() {
const { staff, changePassword } = useAuth();
const gh = useGitHubConnection();
const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState('');
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
const [pwMsg, setPwMsg] = useState('');
const [pwErr, setPwErr] = useState('');
const submitPw = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
setMsg('');
setPwErr('');
setPwMsg('');
try {
await changePassword(oldPw, newPw);
setMsg('Đã đổi mật khẩu');
setPwMsg('Đã đổi mật khẩu');
setOldPw('');
setNewPw('');
} catch (e: any) {
setErr(e?.message || 'Lỗi');
setPwErr(e?.message || 'Lỗi');
}
};
@@ -152,7 +105,7 @@ export function MyAccountTab() {
<div className="page-stack">
<header className="page-header">
<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>
<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>
</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' }}>
<h2 className="section-title">Đi mật khẩu</h2>
<form onSubmit={submitPw} className="login-form" style={{ maxWidth: 400 }}>
@@ -172,8 +176,8 @@ export function MyAccountTab() {
<span>Mật khẩu mới</span>
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} required minLength={8} />
</label>
{err && <div className="login-error">{err}</div>}
{msg && <div className="login-success">{msg}</div>}
{pwErr && <div className="login-error">{pwErr}</div>}
{pwMsg && <div className="login-success">{pwMsg}</div>}
<button type="submit" className="btn btn-primary">Lưu</button>
</form>
</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,
apiPushAttendanceQLDT,
apiUpdateAttendanceStatus,
apiUpdateAttendanceBulkStatus,
attendanceStatusClass,
type AttendanceRow,
type AttendanceShiftInfo,
@@ -34,6 +35,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
const [loading, setLoading] = useState(false);
const [pushing, setPushing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedStudentRkIds, setSelectedStudentRkIds] = useState<number[]>([]);
const loadShifts = useCallback(async () => {
try {
@@ -53,6 +55,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
const res = await apiFetchAttendance(classId, date, period);
setRows(res.data || []);
setShiftInfo(res.shift || null);
setSelectedStudentRkIds([]); // Clear selection when data changes
} catch (e: any) {
alert(e.message || 'Không tải được điểm danh');
} finally {
@@ -83,6 +86,12 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
return counts;
}, [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) => {
try {
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 () => {
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.');
@@ -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 đè.
</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">
{loading ? (
<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">
<thead>
<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>Online</th>
<th>Trạng thái</th>
@@ -229,14 +302,33 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
<tbody>
{filteredRows.length === 0 ? (
<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'}
</td>
</tr>
) : (
filteredRows.map(row => (
filteredRows.map(row => {
const isSelected = selectedStudentRkIds.includes(row.studentRkId);
return (
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)}>
<td>
<td
style={{ textAlign: 'center', cursor: 'pointer' }}
onClick={() => toggleStudentSelection(row.studentRkId)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
onClick={(e) => {
e.stopPropagation();
toggleStudentSelection(row.studentRkId);
}}
/>
</td>
<td
style={{ cursor: 'pointer' }}
onClick={() => toggleStudentSelection(row.studentRkId)}
>
<div className="attendance-student-name">{row.fullName}</div>
<div className="attendance-student-meta">
<code>{row.studentCode}</code>
@@ -275,7 +367,8 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
</div>
</td>
</tr>
))
);
})
)}
</tbody>
</table>

View File

@@ -22,6 +22,8 @@ import { AttendancePanel } from './AttendancePanel';
import { StudentAvatar } from './StudentAvatar';
import { StudentDetailModal } from './StudentDetailModal';
import { AppPoolModal } from './AppPoolModal';
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
import { mergeKeywordCSV } from '../utils/appKeywords';
interface ClassWorkspaceProps {
classId: number;
@@ -52,6 +54,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
const [appPoolOpen, setAppPoolOpen] = useState(false);
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const loadAllData = async () => {
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 () => {
try {
setSavingApps(true);
@@ -329,19 +336,28 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
</div>
<div className="app-pool-open-row">
<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' }}>
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>
</div>
<div className="app-pool-open-actions">
<button
type="button"
className="btn btn-secondary app-pool-open-btn"
onClick={() => setTemplatePickerOpen(true)}
>
Chọn khung
</button>
<button
type="button"
className="btn btn-secondary app-pool-open-btn"
onClick={() => setAppPoolOpen(true)}
>
Mở kho & tìm kiếm
Mở kho
</button>
</div>
</div>
<button
className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center' }}
@@ -539,6 +555,12 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
onSelect={addAppKeyword}
allowedApps={allowedApps}
/>
<AppTemplatePickerModal
open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}
onSelect={applyTemplate}
allowedApps={allowedApps}
/>
</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 {
apiExam,
staffFetch,
@@ -7,10 +7,14 @@ import {
type ExamSubmission,
type StudentItem,
} from '../api';
import { useGitHubConnection } from '../hooks/useGitHubConnection';
import { navigate, pushNav } from '../navigation';
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
import { pushNav } from '../navigation';
import { openStaffChat } from '../chatEvents';
import { AppPoolModal } from './AppPoolModal';
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
import { mergeKeywordCSV } from '../utils/appKeywords';
import { ExamPaperPdfModal } from './ExamPaperPdfModal';
import { NavHistoryBar } from './NavHistoryBar';
import { StudentAvatar } from './StudentAvatar';
import { StudentDetailModal } from './StudentDetailModal';
@@ -49,6 +53,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [end, setEnd] = useState('');
const [allowedApps, setAllowedApps] = useState(DEFAULT_EXAM_ALLOWED_APPS);
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 [prepEditable, setPrepEditable] = useState(false);
const [displayStatus, setDisplayStatus] = useState('draft');
@@ -60,6 +69,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
const [searchQ, setSearchQ] = useState('');
const [searchHits, setSearchHits] = useState<{ studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[]>([]);
const [selectedPickerRkIds, setSelectedPickerRkIds] = useState<number[]>([]);
const [paperTitle, setPaperTitle] = useState('');
const [packagePdf, setPackagePdf] = useState<File | null>(null);
const [packageResources, setPackageResources] = useState<File[]>([]);
@@ -68,6 +78,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
const [appPoolOpen, setAppPoolOpen] = useState(false);
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const [studentPickerOpen, setStudentPickerOpen] = useState(false);
const [saving, setSaving] = useState(false);
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 [configOpen, setConfigOpen] = useState(false);
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 () => {
setLoading(true);
@@ -87,6 +102,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
setEnd(toLocalInput(data.room.endTime));
setAllowedApps(data.room.allowedApps || DEFAULT_EXAM_ALLOWED_APPS);
setQuizUrl(data.room.quizUrl || '');
setGitPublishUrl(data.room.gitPublishUrl || '');
setEditable(data.editable);
setPrepEditable(data.prepEditable ?? data.editable);
setDisplayStatus(data.displayStatus);
@@ -110,6 +126,25 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
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 () => {
try {
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 () => {
setErr(''); setMsg('');
setSaving(true);
@@ -172,6 +211,39 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
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 () => {
if (!packagePdf) {
setErr('Chọn file PDF cho gói đề');
@@ -230,6 +302,117 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
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 () => {
setErr(''); setMsg('');
try {
@@ -357,6 +540,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
{canCancel && (
<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>
@@ -408,6 +596,31 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<span>Link trắc nghiệm</span>
<input value={quizUrl} onChange={(e) => setQuizUrl(e.target.value)} placeholder="https://..." disabled={!editable} />
</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">
<span>Bắt đu</span>
<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 className="app-pool-open-row">
<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' }}>
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>
</div>
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
Mở kho & tìm kiếm
<div className="app-pool-open-actions">
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setTemplatePickerOpen(true)}>
Chọn khung
</button>
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
Mở kho
</button>
</div>
</div>
<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'}
@@ -525,17 +743,25 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
{papers.map((p) => (
<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>
<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>
<ul className="config-card-desc" style={{ margin: '0.5rem 0 0', paddingLeft: '1.1rem' }}>
<li>📄 main.pdf (đ chính)</li>
{(p.resources || []).length === 0 ? (
<li style={{ color: 'var(--text-muted)' }}>Chưa tài nguyên kèm</li>
) : (
(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>
@@ -628,11 +854,16 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); }}
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); setSelectedPickerRkIds([]); }}
>
+ Thêm sinh viên
</button>
)}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ ({papers.length})
</button>
)}
</div>
</div>
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} />
@@ -649,7 +880,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<button
type="button"
className="btn btn-primary"
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); }}
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); setSelectedPickerRkIds([]); }}
>
+ Thêm sinh viên
</button>
@@ -735,7 +966,19 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<td style={{ color: isOnline ? 'var(--success)' : 'var(--text-muted)', fontWeight: 700 }}>
{isOnline ? 'Online' : 'Offline'}
</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.submitted ? <span className="badge badge-success">Đã nộp</span> : '—'}</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
@@ -766,6 +1009,56 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</div>
) : (
<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' }}>
{submissions.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
@@ -779,6 +1072,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<tr>
<th>Sinh viên</th>
<th>File</th>
<th>Git</th>
<th>Thời gian</th>
<th style={{ textAlign: 'right' }}>Tải</th>
</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>
</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={{ textAlign: 'right' }}>
<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>
{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 && (
<StudentDetailModal
student={selectedStudent}
@@ -816,55 +1190,137 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
)}
{studentPickerOpen && (
<div className="modal-overlay">
<div className="modal-container" style={{ maxWidth: '560px', width: '92%' }}>
<div className="modal-header">
<div className="modal-overlay exam-student-overlay" onClick={() => setStudentPickerOpen(false)}>
<div className="modal-container exam-student-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header exam-student-modal-header">
<div>
<h2 className="modal-title">Thêm sinh viên</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
Tìm theo lớp, họ tên, sinh viên
</p>
<p className="exam-student-modal-desc">Tìm theo lớp, họ tên, sinh viên</p>
</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 style={{ padding: '0 0 1rem' }}>
<div className="exam-student-modal-body">
<div className="exam-student-modal-search search-input-wrap" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input
type="search"
className="app-pool-search"
className="search-input"
placeholder="Tìm sinh viên..."
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
onChange={(e) => {
setSearchQ(e.target.value);
setSelectedPickerRkIds([]);
}}
autoFocus
style={{ flex: 1 }}
/>
<div style={{ maxHeight: 'min(55vh, 420px)', overflowY: 'auto', marginTop: '1rem' }}>
{searchQ.trim() === '' ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}> ít nhất vài tự đ tìm.</p>
) : searchHits.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Không tìm thấy sinh viên phù hợp.</p>
) : (
<ul className="learning-add-list">
{searchHits.map((h) => {
const added = enrolledIds.has(h.studentRkId);
return (
<li key={h.studentRkId} className="learning-add-item">
<div>
<div className="learning-class-code">{h.studentCode}</div>
<div className="learning-class-meta">{h.fullName}</div>
{(h.classCodes || h.email) && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.2rem' }}>
{[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'}
{searchQ.trim() !== '' && searchHits.some((h) => !enrolledIds.has(h.studentRkId)) && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={toggleSelectAllHits}
style={{ whiteSpace: 'nowrap' }}
>
{searchHits.filter((h) => !enrolledIds.has(h.studentRkId)).every((h) => selectedPickerRkIds.includes(h.studentRkId))
? 'Bỏ chọn tất cả'
: 'Chọn tất cả'}
</button>
</li>
);
})}
</ul>
)}
</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>
@@ -876,6 +1332,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
onSelect={addAppKeyword}
allowedApps={allowedApps}
/>
<AppTemplatePickerModal
open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}
onSelect={applyTemplate}
allowedApps={allowedApps}
/>
</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 { openExam } from './NavHistoryBar';
@@ -39,6 +39,7 @@ export const ExamsTab: React.FC = () => {
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
const [busy, setBusy] = useState(false);
const [search, setSearch] = useState('');
const load = async () => {
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 () => {
if (!name.trim() || !start || !end) return;
@@ -65,6 +83,9 @@ export const ExamsTab: React.FC = () => {
});
setShowCreate(false);
setName('');
setStart('');
setEnd('');
await load();
openExam(room.id, room.name);
} catch (e: any) {
alert(e?.message || 'Lỗi');
@@ -74,20 +95,47 @@ export const ExamsTab: React.FC = () => {
};
return (
<div className="page-stack">
<div className="tab-page exams-page">
<header className="page-header page-header--row">
<div>
<h1 className="page-title">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>
<h1 className="page-title">Danh sách phòng thi</h1>
<p className="page-desc">Tạo phòng, chia đ ngẫu nhiên, gửi đ thu bài từ sinh viên.</p>
</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>
<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 && (
<div className="card" style={{ padding: '1.25rem' }}>
<h2 className="section-title">Phòng thi mới</h2>
<div className="form-grid" style={{ maxWidth: 520 }}>
<label className="login-field">
<div className="system-form-card exams-create-card">
<h2 className="system-form-title">Phòng thi mới</h2>
<div className="system-form-grid">
<label className="login-field system-form-full">
<span>Tên phòng thi</span>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Thi cuối kỳ Java" />
</label>
@@ -100,48 +148,56 @@ export const ExamsTab: React.FC = () => {
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
</label>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>Tạo</button>
<button type="button" className="btn btn-ghost" onClick={() => setShowCreate(false)}>Hủy</button>
<div className="system-form-actions">
<div />
<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 className="card table-card">
{loading ? (
<div className="table-empty">Đang tải...</div>
) : rooms.length === 0 ? (
<div className="table-empty">Chưa phòng thi nào.</div>
<div className="system-empty">Đang tải...</div>
) : filtered.length === 0 ? (
<div className="system-empty">
{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>
<tr>
<th>Tên</th>
<th>Thời gian</th>
<th>SV / Đ</th>
<th>Trạng thái</th>
<th></th>
</tr>
</thead>
<tbody>
{rooms.map((r) => {
<div className="exams-card-grid">
{filtered.map((r) => {
const st = statusLabel(r.displayStatus || r.status);
return (
<tr key={r.id}>
<td><strong>{r.name}</strong></td>
<td style={{ fontSize: '0.82rem' }}>{fmtTime(r.startTime)} {fmtTime(r.endTime)}</td>
<td>{r.studentCount} SV · {r.paperCount} đ</td>
<td><span className={`badge ${st.cls}`}>{st.text}</span></td>
<td>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => openExam(r.id, r.name)}>Mở</button>
</td>
</tr>
<article key={r.id} className="exam-list-card">
<div className="exam-list-card-head">
<h3>{r.name}</h3>
<span className={`badge ${st.cls}`}>{st.text}</span>
</div>
<div className="exam-list-card-meta">
<div>
<span className="exam-list-card-label">Thời gian</span>
<span>{fmtTime(r.startTime)}</span>
<span className="exam-list-card-sep"></span>
<span>{fmtTime(r.endTime)}</span>
</div>
<div>
<span className="exam-list-card-label">Quy </span>
<span>{r.studentCount} sinh viên · {r.paperCount} đ</span>
</div>
</div>
<button type="button" className="btn btn-primary btn-sm exam-list-card-open" onClick={() => openExam(r.id, r.name)}>
Mở phòng thi
</button>
</article>
);
})}
</tbody>
</table>
)}
</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 {
TAB_LABELS,
SYSTEM_SECTION_LABELS,
type NavEntry,
type TabId,
parseRoute,
@@ -24,7 +25,10 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
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 handlePillClick = (entry: NavEntry) => {
@@ -32,6 +36,8 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
navigate(entry.tab, entry.classId, entry.label, 'class');
} else if (entry.kind === 'exam' && entry.examId) {
navigate(entry.tab, entry.examId, entry.label, 'exam');
} else if (entry.tab === 'system' && entry.systemSection) {
navigate('system', null, undefined, 'class', entry.systemSection);
} else {
navigate(entry.tab);
}
@@ -44,6 +50,9 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
if (entry.kind === 'exam' && 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;
};
@@ -61,7 +70,13 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
Simple Care
</button>
<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}
</button>
{route.classId && classLabel && (

View File

@@ -16,7 +16,7 @@ function formatBssid(raw: string): string | null {
return key.match(/.{2}/g)!.join(':');
}
export const NetworkTab: React.FC = () => {
export const NetworkSection: React.FC = () => {
const [acceptedItems, setAcceptedItems] = useState<WifiAcceptItem[]>([]);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
@@ -31,8 +31,8 @@ export const NetworkTab: React.FC = () => {
const res = await apiFetchAcceptedWifis();
setAcceptedItems(
(res.data || [])
.filter(r => r.ssid && r.bssid)
.map(r => ({ ssid: r.ssid, bssid: r.bssid }))
.filter((r) => r.ssid && r.bssid)
.map((r) => ({ ssid: r.ssid, bssid: r.bssid })),
);
} catch {
setAcceptedItems([]);
@@ -50,15 +50,15 @@ export const NetworkTab: React.FC = () => {
const trimmedBssid = bssid.trim();
const key = bssidKey(trimmedBssid);
if (!trimmedSsid || key.length !== 12) return;
setAcceptedItems(prev => {
if (prev.some(item => bssidKey(item.bssid) === key)) return prev;
setAcceptedItems((prev) => {
if (prev.some((item) => bssidKey(item.bssid) === key)) return prev;
return [...prev, { ssid: trimmedSsid, bssid: trimmedBssid }];
});
};
const removeWifi = (bssid: string) => {
const key = bssidKey(bssid);
setAcceptedItems(prev => prev.filter(item => bssidKey(item.bssid) !== key));
setAcceptedItems((prev) => prev.filter((item) => bssidKey(item.bssid) !== key));
};
const handleManualAdd = () => {
@@ -73,11 +73,11 @@ export const NetworkTab: React.FC = () => {
return;
}
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.');
return;
}
setAcceptedItems(prev => [...prev, { ssid, bssid }]);
setAcceptedItems((prev) => [...prev, { ssid, bssid }]);
setManualSsid('');
setManualBssid('');
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 (
<div className="page-container">
<div className="page-header">
<div className="system-section system-section--grid">
<div className="system-block">
<div className="system-block-head">
<div>
<h1 className="page-title">Quản mạng</h1>
<p className="page-subtitle">
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>.
Sinh viên không thể giả mạo bằng hotspot trùng tên.
<h2 className="system-block-title">Gói mạng đưc chấp nhận</h2>
<p className="system-block-desc">
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>
</div>
<span className="system-stat-pill">{acceptedItems.length} điểm phát</span>
</div>
<div className="network-grid">
<div className="card config-card-flat">
<div className="card-header-title">WiFi đưc chấp nhận</div>
<p className="config-card-desc">
Chọn từ kho WiFi hoặc thêm thủ công SSID + BSSID. Đ trống = chưa bật kiểm tra WiFi.
</p>
<form
className="network-manual-form"
onSubmit={e => {
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>
<p className="network-manual-hint">Thêm thủ công (router, lệnh netsh...)</p>
<div className="system-inline-form system-inline-form--stack">
<input
type="text"
className="app-pool-search"
placeholder="SSID (tên WiFi)"
value={manualSsid}
onChange={e => {
onChange={(e) => {
setManualSsid(e.target.value);
setManualError(null);
}}
@@ -141,7 +136,7 @@ export const NetworkTab: React.FC = () => {
className="app-pool-search"
placeholder="BSSID / MAC (f0:61:c0:b0:fd:d2)"
value={manualBssid}
onChange={e => {
onChange={(e) => {
setManualBssid(e.target.value);
setManualError(null);
}}
@@ -151,17 +146,19 @@ export const NetworkTab: React.FC = () => {
<button type="submit" className="btn btn-secondary" disabled={loading}>
+ Thêm thủ công
</button>
</div>
{manualError && <p className="network-manual-error">{manualError}</p>}
</form>
{loading ? (
<div className="app-pool-status">Đang tải...</div>
<div className="system-empty">Đang tải...</div>
) : acceptedItems.length === 0 ? (
<div className="app-pool-status" style={{ marginBottom: '0.75rem' }}>
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.
<div className="system-empty system-empty--muted">
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>
) : (
<table className="data-table app-pool-table" style={{ marginBottom: '0.75rem' }}>
<div className="system-table-wrap">
<table className="data-table app-pool-table">
<thead>
<tr>
<th>SSID</th>
@@ -170,7 +167,7 @@ export const NetworkTab: React.FC = () => {
</tr>
</thead>
<tbody>
{acceptedItems.map(item => (
{acceptedItems.map((item) => (
<tr key={bssidKey(item.bssid)}>
<td><code className="app-pool-kw">{item.ssid}</code></td>
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
@@ -179,7 +176,7 @@ export const NetworkTab: React.FC = () => {
<td>
<button
type="button"
className="btn btn-secondary app-pool-add-btn"
className="btn btn-ghost btn-sm"
onClick={() => removeWifi(item.bssid)}
>
Xóa
@@ -189,6 +186,7 @@ export const NetworkTab: React.FC = () => {
))}
</tbody>
</table>
</div>
)}
<div className="network-actions">
@@ -201,16 +199,14 @@ export const NetworkTab: React.FC = () => {
</div>
</div>
<div className="card config-card-flat network-info-card">
<div className="card-header-title">Cách hoạt đng</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> (MAC điểm phát) lên kho</li>
<li>Thầy chọn từ kho hoặc <strong>thêm thủ công</strong> SSID + BSSID khi cần</li>
<li>Hotspot giả trùng tên nhưng MAC khác app báo lỗi thoát</li>
<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>
</div>
<WifiPoolModal
open={poolOpen}
@@ -221,3 +217,13 @@ export const NetworkTab: React.FC = () => {
</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;
}
/* 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 */
.attendance-status--0,
.attendance-summary-chip.attendance-status--0 {
@@ -2156,6 +2197,10 @@ input:checked + .slider:before {
background: var(--att-bg);
}
.attendance-table tbody tr:hover {
filter: brightness(0.97);
}
.attendance-student-name {
font-weight: 700;
font-size: 0.88rem;
@@ -4122,6 +4167,131 @@ input:checked + .slider:before {
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 {
display: flex;
flex-wrap: wrap;
@@ -4197,7 +4367,7 @@ input:checked + .slider:before {
.exam-student-hit {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 0.85rem;
padding: 0.85rem 1rem;
@@ -4207,11 +4377,35 @@ input:checked + .slider:before {
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 {
border-color: var(--accent);
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 {
min-width: 0;
}
@@ -4246,3 +4440,668 @@ input:checked + .slider:before {
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 {
id: string;
@@ -6,18 +8,24 @@ export interface NavEntry {
tab: TabId;
classId?: number;
examId?: number;
systemSection?: SystemSection;
label: string;
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> = {
dashboard: 'Tổng quan',
classes: 'Lớp học',
students: 'Sinh viên',
learning: 'Giám sát & Lịch học',
exams: 'Phòng thi',
network: 'Quản lý mạng',
'email-domains': 'Đuôi email',
system: 'Hệ thống',
profile: 'Tài khoản của tôi',
};
@@ -25,14 +33,47 @@ const HISTORY_KEY = 'sc_nav_history';
const MAX_HISTORY = 10;
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 tabParam = params.get('tab');
let tab: TabId = isTabId(tabParam) ? tabParam : 'dashboard';
if (tabParam === 'accounts') tab = 'email-domains';
const { tab, systemSection } = resolveTabAndSection(tabParam);
const classIdRaw = params.get('classId');
const classId = classIdRaw ? Number(classIdRaw) : null;
const examIdRaw = params.get('examId');
@@ -41,12 +82,21 @@ export function parseRoute(): { tab: TabId; classId: number | null; examId: numb
tab,
classId: classId && !Number.isNaN(classId) ? classId : 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();
params.set('tab', tab);
if (tab === 'system' && systemSection) {
params.set('section', systemSection);
}
if (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)));
}
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 === 'exam') return `exam:${id}`;
if (tab === 'system' && section) return `tab:system:${section}`;
return `tab:${tab}`;
}
export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
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 = {
...entry,
id,
@@ -89,21 +140,47 @@ export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
writeHistory([next, ...filtered]);
}
export function navigate(tab: TabId, id?: number | null, label?: string, kind: 'class' | 'exam' = 'class') {
const url = kind === 'exam'
? buildUrl(tab, null, id)
: buildUrl(tab, id);
export function navigate(
tab: TabId,
id?: number | null,
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);
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 {
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'));
}
export function navigateSystem(section: SystemSection) {
navigate('system', null, undefined, 'class', section);
}
export function goBack(fallbackTab: TabId = 'classes') {
const history = readHistory();
const current = parseRoute();
@@ -111,18 +188,19 @@ export function goBack(fallbackTab: TabId = 'classes') {
? entryKey('class', current.tab, current.classId)
: 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);
writeHistory(remaining);
const previous = remaining[0];
if (previous) {
const url = previous.kind === 'class' && previous.classId
const url =
previous.kind === 'class' && previous.classId
? buildUrl(previous.tab, previous.classId)
: previous.kind === 'exam' && previous.examId
? buildUrl(previous.tab, null, previous.examId)
: buildUrl(previous.tab);
: buildUrl(previous.tab, null, null, previous.systemSection);
window.history.pushState({}, '', url);
} else {
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_AUTH_USER=phuocnguyenbp0@gmail.com
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.ClassAllowedApp{},
&models.AppPoolEntry{},
&models.AppTemplate{},
&models.WifiPoolEntry{},
&models.AcceptedWifi{},
&models.StudentSession{},
@@ -62,6 +63,8 @@ func AutoMigrate(db *gorm.DB) error {
&models.ExamPaperResource{},
&models.ExamRoomStudent{},
&models.ExamSubmission{},
&models.StaffGitHubAuth{},
&models.StaffGitHubRepo{},
); err != nil {
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
func PushAttendanceToQLDTHandler(db *gorm.DB, qldtClient *qldt.Client, token string) fiber.Handler {
return func(c *fiber.Ctx) error {

View File

@@ -2,6 +2,7 @@ package handlers
import (
"archive/zip"
"encoding/base64"
"fmt"
"io"
"math/rand"
@@ -919,12 +920,13 @@ func GetStudentExamPaperFilesHandler(db *gorm.DB) fiber.Handler {
resList = append(resList, fiber.Map{
"id": r.ID,
"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{
"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,
})
}
@@ -941,15 +943,176 @@ func DownloadStudentExamFileHandler(db *gorm.DB) fiber.Handler {
if info == nil || info.Enrollment.PaperSentAt == nil || info.Paper == nil {
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")
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)
var res models.ExamPaperResource
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"})
}
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)
}
}
@@ -1040,8 +1203,8 @@ func ZipFolder(srcDir, destZip string) error {
if err != nil {
return err
}
defer rf.Close()
_, err = io.Copy(writer, rf)
return err
_, copyErr := io.Copy(writer, rf)
rf.Close()
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

@@ -19,6 +19,9 @@ type ExamRoom struct {
EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"`
AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"`
QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"`
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"`
}
@@ -65,6 +68,8 @@ type ExamSubmission struct {
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"`
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" }

View File

@@ -122,6 +122,18 @@ type AppPoolEntry struct {
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)
type WifiPoolEntry struct {
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/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/recover"
gofiberWs "github.com/gofiber/websocket/v2"
"github.com/joho/godotenv"
)
@@ -57,10 +58,12 @@ func main() {
BodyLimit: 100 * 1024 * 1024, // 100MB — upload PDF gói đề
})
app.Use(recover.New())
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, PATCH, PUT, DELETE, OPTIONS",
ExposeHeaders: "Content-Disposition, Content-Length",
}))
// WebSocket Upgrade Check
@@ -82,6 +85,7 @@ func main() {
api.Post("/auth/login", handlers.LoginStaffHandler(gormDB))
api.Post("/auth/forgot-password", handlers.ForgotPasswordHandler(gormDB, mailer))
api.Post("/auth/reset-password", handlers.ResetPasswordHandler(gormDB))
api.Get("/auth/github/callback", handlers.GitHubOAuthCallbackHandler(gormDB))
// Student client (không cần JWT staff)
api.Post("/student/sync-log", handlers.SyncStudentSessionLogHandler(gormDB))
@@ -105,6 +109,11 @@ func main() {
staff := api.Group("", middleware.RequireStaff())
staff.Get("/auth/me", handlers.MeStaffHandler(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))
@@ -131,6 +140,10 @@ func main() {
staff.Get("/classes/:rkId/allowed-apps", handlers.GetAllowedAppsHandler(gormDB))
staff.Post("/classes/:rkId/allowed-apps", handlers.SaveAllowedAppsHandler(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("/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/shifts", handlers.ListAttendanceShiftsHandler(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))
// 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/:paperId/resources", handlers.UploadExamPaperResourceHandler(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/unpublish", handlers.UnpublishExamRoomHandler(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/send-papers", handlers.SendExamPapersHandler(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))
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"
}
]
}