diff --git a/landing_page/index.html b/landing_page/index.html new file mode 100644 index 0000000..64b35d3 --- /dev/null +++ b/landing_page/index.html @@ -0,0 +1,405 @@ + + + + + + Simple Care - Tải Ứng Dụng & Hướng Dẫn Hỗ Trợ Sinh Viên + + + +
+
+

Simple Care

+

Cổng tải ứng dụng và tài liệu hướng dẫn học tập, thi cử dành cho sinh viên

+
+ +
+ +
+

+ + + + + Ứng dụng hỗ trợ +

+
+
+

Đang tải danh sách ứng dụng...

+
+ + +
+ + +
+

+ + + + + + + Hướng dẫn & Tài liệu +

+
+
+

Đang tải danh sách tài liệu...

+
+ + +
+
+ + +
+ + + + diff --git a/management/src/App.tsx b/management/src/App.tsx index 97b46e8..738cdbb 100644 --- a/management/src/App.tsx +++ b/management/src/App.tsx @@ -8,6 +8,7 @@ import { ExamRoomWorkspace } from './components/ExamRoomWorkspace'; import { SystemTab } from './components/SystemTab'; import { MyAccountTab } from './components/AccountsTab'; import { StudentAffairsTab } from './components/StudentAffairsTab'; +import { ApplicationsTab } from './components/ApplicationsTab'; import { ChatWidget } from './components/ChatWidget'; import { StaffChatSocket } from './hooks/useStaffChatSocket'; import { ClassWorkspace } from './components/ClassWorkspace'; @@ -75,6 +76,15 @@ const IconStudentAffairs = () => ( ); +const IconApplications = () => ( + + + + + + +); + const IconMenu = () => ( @@ -219,6 +229,15 @@ function App() { Công Tác Sinh Viên +
  • + +
  • Hệ thống
  • @@ -286,6 +305,7 @@ function App() { {route.tab === 'system' && } {route.tab === 'profile' && } {route.tab === 'student-affairs' && } + {route.tab === 'applications' && } )} diff --git a/management/src/api.ts b/management/src/api.ts index f2f967b..6a8c714 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -277,6 +277,16 @@ export interface GuideLinkItem { url: string; } +export interface AppDownloadItem { + id: number; + createdAt: string; + updatedAt: string; + name: string; + description: string; + downloadUrl: string; + platform: string; +} + export interface PaginatedResponse { data: T[]; total: number; @@ -397,6 +407,33 @@ export const api = { return res.json(); }, + listAppDownloads: async (): Promise<{ data: AppDownloadItem[] }> => { + const res = await staffFetch('/app-downloads'); + if (!res.ok) throw new Error('Không thể tải danh sách ứng dụng'); + return res.json(); + }, + createAppDownload: async (name: string, description: string, downloadUrl: string, platform: string): Promise<{ data: AppDownloadItem }> => { + const res = await staffFetch('/app-downloads', { + method: 'POST', + body: JSON.stringify({ name, description, downloadUrl, platform }), + }); + if (!res.ok) await parseError(res, 'Thêm ứng dụng thất bại'); + return res.json(); + }, + updateAppDownload: async (id: number, name: string, description: string, downloadUrl: string, platform: string): Promise<{ data: AppDownloadItem }> => { + const res = await staffFetch(`/app-downloads/${id}`, { + method: 'PUT', + body: JSON.stringify({ name, description, downloadUrl, platform }), + }); + if (!res.ok) await parseError(res, 'Cập nhật ứng dụng thất bại'); + return res.json(); + }, + deleteAppDownload: async (id: number): Promise<{ ok: boolean }> => { + const res = await staffFetch(`/app-downloads/${id}`, { method: 'DELETE' }); + if (!res.ok) await parseError(res, 'Xóa ứng dụng thất bại'); + return res.json(); + }, + getClasses: async (params: { page: number; pageSize: number; diff --git a/management/src/components/ApplicationsTab.tsx b/management/src/components/ApplicationsTab.tsx new file mode 100644 index 0000000..e50268a --- /dev/null +++ b/management/src/components/ApplicationsTab.tsx @@ -0,0 +1,354 @@ +import React, { useEffect, useState } from 'react'; +import { api, type AppDownloadItem } from '../api'; +import { useAuth } from '../auth/AuthContext'; + +const IconDownload = () => ( + + + + + +); + +const PlatformIcon = ({ platform }: { platform: string }) => { + const p = platform.toLowerCase(); + if (p.includes('win')) { + return ( + + + + ); + } + if (p.includes('mac') || p.includes('apple') || p.includes('ios')) { + return ( + + + + ); + } + if (p.includes('linux')) { + return ( + + + + ); + } + return ( + + + + + + ); +}; + +export const ApplicationsTab: React.FC = () => { + const { staff } = useAuth(); + const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com'; + + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + + // Modal states + const [showModal, setShowModal] = useState(false); + const [editingApp, setEditingApp] = useState(null); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [downloadUrl, setDownloadUrl] = useState(''); + const [platform, setPlatform] = useState('Windows'); + const [saving, setSaving] = useState(false); + + const fetchApps = async () => { + try { + setLoading(true); + const res = await api.listAppDownloads(); + setApps(res.data || []); + } catch (err) { + console.error('Không thể tải danh sách ứng dụng:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchApps(); + }, []); + + const handleOpenAdd = () => { + setEditingApp(null); + setName(''); + setDescription(''); + setDownloadUrl(''); + setPlatform('Windows'); + setShowModal(true); + }; + + const handleOpenEdit = (app: AppDownloadItem) => { + setEditingApp(app); + setName(app.name); + setDescription(app.description); + setDownloadUrl(app.downloadUrl); + setPlatform(app.platform); + setShowModal(true); + }; + + const handleSave = async () => { + if (!name.trim() || !downloadUrl.trim()) { + alert('Vui lòng nhập tên ứng dụng và đường dẫn tải.'); + return; + } + try { + setSaving(true); + if (editingApp) { + await api.updateAppDownload(editingApp.id, name.trim(), description.trim(), downloadUrl.trim(), platform); + } else { + await api.createAppDownload(name.trim(), description.trim(), downloadUrl.trim(), platform); + } + setShowModal(false); + await fetchApps(); + } catch (err: any) { + alert(err.message || 'Lưu ứng dụng thất bại'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: number, appName: string) => { + if (window.confirm(`Bạn có chắc chắn muốn xóa liên kết tải cho ứng dụng "${appName}" không?`)) { + try { + await api.deleteAppDownload(id); + await fetchApps(); + } catch (err: any) { + alert(err.message || 'Xóa ứng dụng thất bại'); + } + } + }; + + return ( +
    +
    +
    +

    Quản Lý Ứng Dụng

    +

    Quản lý link tải các công cụ học tập hỗ trợ sinh viên.

    +
    + {isSuperAdmin && ( + + )} +
    + +
    +
    + {loading && ( +
    +
    +

    Đang tải danh sách ứng dụng...

    +
    + )} + + {!loading && apps.length === 0 && ( +
    + + + + +

    Chưa có ứng dụng nào được cấu hình tải.

    + {isSuperAdmin && ( + + )} +
    + )} + + {!loading && apps.length > 0 && ( +
    + + + + + + + + {isSuperAdmin && } + + + + {apps.map((app) => ( + + + + + + {isSuperAdmin && ( + + )} + + ))} + +
    Nền tảngỨng dụngMô tảĐường dẫn tảiThao tác
    +
    + + {app.platform} +
    +
    + {app.name} + + {app.description || '—'} + + e.currentTarget.style.textDecoration = 'underline'} + onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'} + > + + Tải ứng dụng + + +
    + + +
    +
    +
    + )} +
    +
    + + {showModal && ( +
    setShowModal(false)}> +
    e.stopPropagation()}> +
    +

    {editingApp ? 'Cập nhật ứng dụng' : 'Thêm ứng dụng tải'}

    + +
    +
    +
    + + + + + + + +
    +
    +
    + + +
    +
    +
    + )} +
    + ); +}; diff --git a/management/src/navigation.ts b/management/src/navigation.ts index fa483c8..9e22a5a 100644 --- a/management/src/navigation.ts +++ b/management/src/navigation.ts @@ -1,6 +1,6 @@ export type SystemSection = 'organization' | 'network' | 'templates'; -export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs'; +export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications'; export interface NavEntry { id: string; @@ -28,6 +28,7 @@ export const TAB_LABELS: Record = { system: 'Hệ thống', profile: 'Tài khoản của tôi', 'student-affairs': 'Công Tác Sinh Viên', + applications: 'Ứng Dụng', }; const HISTORY_KEY = 'sc_nav_history'; @@ -42,7 +43,8 @@ function isTabId(value: string | null): value is TabId { value === 'exams' || value === 'system' || value === 'profile' || - value === 'student-affairs' + value === 'student-affairs' || + value === 'applications' ); } diff --git a/server/internal/db/db.go b/server/internal/db/db.go index ccb972a..4db04e4 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -67,6 +67,7 @@ func AutoMigrate(db *gorm.DB) error { &models.StaffGitHubRepo{}, &models.SystemSetting{}, &models.GuideLink{}, + &models.AppDownload{}, ); err != nil { return err } diff --git a/server/internal/handlers/handlers_app_downloads.go b/server/internal/handlers/handlers_app_downloads.go new file mode 100644 index 0000000..986d781 --- /dev/null +++ b/server/internal/handlers/handlers_app_downloads.go @@ -0,0 +1,148 @@ +package handlers + +import ( + "strconv" + "server/internal/models" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// GET /api/public/app-downloads +func ListAppDownloadsPublicHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + var apps []models.AppDownload + if err := db.Order("created_at desc").Find(&apps).Error; err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách ứng dụng"}) + } + return c.JSON(fiber.Map{"data": apps}) + } +} + +// GET /api/app-downloads +func ListAppDownloadsHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + var apps []models.AppDownload + if err := db.Order("created_at desc").Find(&apps).Error; err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách ứng dụng"}) + } + return c.JSON(fiber.Map{"data": apps}) + } +} + +// POST /api/app-downloads +func CreateAppDownloadHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + if !isSuperAdmin(c) { + return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"}) + } + + type Request struct { + Name string `json:"name"` + Description string `json:"description"` + DownloadURL string `json:"downloadUrl"` + Platform string `json:"platform"` + } + var req Request + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) + } + if req.Name == "" || req.DownloadURL == "" { + return c.Status(400).JSON(fiber.Map{"error": "Tên ứng dụng và đường dẫn tải không được để trống"}) + } + + app := models.AppDownload{ + Name: req.Name, + Description: req.Description, + DownloadURL: req.DownloadURL, + Platform: req.Platform, + } + if err := db.Create(&app).Error; err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Lưu ứng dụng thất bại"}) + } + + return c.JSON(fiber.Map{"ok": true, "data": app}) + } +} + +// PUT /api/app-downloads/:id +func UpdateAppDownloadHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + if !isSuperAdmin(c) { + return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"}) + } + + idParam := c.Params("id") + id, err := strconv.ParseUint(idParam, 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"}) + } + + var app models.AppDownload + if err := db.First(&app, uint(id)).Error; err != nil { + return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy ứng dụng"}) + } + + type Request struct { + Name string `json:"name"` + Description string `json:"description"` + DownloadURL string `json:"downloadUrl"` + Platform string `json:"platform"` + } + var req Request + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) + } + if req.Name == "" || req.DownloadURL == "" { + return c.Status(400).JSON(fiber.Map{"error": "Tên ứng dụng và đường dẫn tải không được để trống"}) + } + + app.Name = req.Name + app.Description = req.Description + app.DownloadURL = req.DownloadURL + app.Platform = req.Platform + + if err := db.Save(&app).Error; err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Cập nhật ứng dụng thất bại"}) + } + + return c.JSON(fiber.Map{"ok": true, "data": app}) + } +} + +// DELETE /api/app-downloads/:id +func DeleteAppDownloadHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + if !isSuperAdmin(c) { + return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"}) + } + + idParam := c.Params("id") + id, err := strconv.ParseUint(idParam, 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"}) + } + + var app models.AppDownload + if err := db.First(&app, uint(id)).Error; err != nil { + return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy ứng dụng"}) + } + + if err := db.Delete(&app).Error; err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Xóa ứng dụng thất bại"}) + } + + return c.JSON(fiber.Map{"ok": true}) + } +} + +// GET /api/public/guide-links +func ListGuideLinksPublicHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + var links []models.GuideLink + if err := db.Order("created_at desc").Find(&links).Error; err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách tài liệu"}) + } + return c.JSON(fiber.Map{"data": links}) + } +} diff --git a/server/internal/models/models.go b/server/internal/models/models.go index 7a033b9..4e17b00 100644 --- a/server/internal/models/models.go +++ b/server/internal/models/models.go @@ -199,4 +199,17 @@ type GuideLink struct { func (GuideLink) TableName() string { return "guide_links" } +// AppDownload đại diện cho ứng dụng và link tải +type AppDownload struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Name string `gorm:"column:name;size:255;not null" json:"name"` + Description string `gorm:"column:description;size:512" json:"description"` + DownloadURL string `gorm:"column:download_url;size:1024;not null" json:"downloadUrl"` + Platform string `gorm:"column:platform;size:64" json:"platform"` // "Windows", "macOS", "Linux", "Khác" +} + +func (AppDownload) TableName() string { return "app_downloads" } + diff --git a/server/main.go b/server/main.go index 6405e13..eca9d0a 100644 --- a/server/main.go +++ b/server/main.go @@ -107,6 +107,9 @@ func main() { return c.JSON(fiber.Map{"ok": true}) }) + api.Get("/public/app-downloads", handlers.ListAppDownloadsPublicHandler(gormDB)) + api.Get("/public/guide-links", handlers.ListGuideLinksPublicHandler(gormDB)) + // Management — yêu cầu đăng nhập staff staff := api.Group("", middleware.RequireStaff()) staff.Get("/auth/me", handlers.MeStaffHandler(gormDB)) @@ -207,6 +210,11 @@ func main() { staff.Put("/guide-links/:id", handlers.UpdateGuideLinkHandler(gormDB)) staff.Delete("/guide-links/:id", handlers.DeleteGuideLinkHandler(gormDB)) + staff.Get("/app-downloads", handlers.ListAppDownloadsHandler(gormDB)) + staff.Post("/app-downloads", handlers.CreateAppDownloadHandler(gormDB)) + staff.Put("/app-downloads/:id", handlers.UpdateAppDownloadHandler(gormDB)) + staff.Delete("/app-downloads/:id", handlers.DeleteAppDownloadHandler(gormDB)) + go func() { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() @@ -215,6 +223,8 @@ func main() { } }() + app.Static("/", "../landing_page") + port := getEnv("PORT", "8080") log.Printf("Server starting on port %s...", port) if err := app.Listen(":" + port); err != nil {