diff --git a/.gitignore b/.gitignore index 7ec6a6e..5cf62a3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,9 @@ vendor/ # Build outputs frontend/dist/ backend/sale.db +backend/cmd/server/sale.db backend/sale +backend/cmd/server/uploads/ # IDE .idea/ diff --git a/backend/cmd/server/sale.db b/backend/cmd/server/sale.db deleted file mode 100644 index 1d93588..0000000 Binary files a/backend/cmd/server/sale.db and /dev/null differ diff --git a/backend/internal/api/handlers/article.go b/backend/internal/api/handlers/article.go index 47cf5d5..02b1967 100644 --- a/backend/internal/api/handlers/article.go +++ b/backend/internal/api/handlers/article.go @@ -161,6 +161,7 @@ func (h *ArticleHandler) TogglePin(c *gin.Context) { } utils.DB.Model(&article).Update("is_pinned", !article.IsPinned) + utils.DB.First(&article, id) c.JSON(http.StatusOK, gin.H{"data": article}) } diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go index f165db1..d59497e 100644 --- a/backend/internal/api/handlers/auth.go +++ b/backend/internal/api/handlers/auth.go @@ -2,6 +2,8 @@ package handlers import ( "net/http" + "strconv" + "sync" "time" "sale/internal/models" @@ -17,6 +19,43 @@ func NewAuthHandler() *AuthHandler { return &AuthHandler{} } +var ( + verifyCodes = make(map[string]verifyCodeEntry) + verifyCodesMux sync.Mutex +) + +type verifyCodeEntry struct { + Code string + ExpiresAt time.Time +} + +func storeVerifyCode(email, code string) { + verifyCodesMux.Lock() + defer verifyCodesMux.Unlock() + verifyCodes[email] = verifyCodeEntry{ + Code: code, + ExpiresAt: time.Now().Add(30 * time.Minute), + } +} + +func checkVerifyCode(email, code string) bool { + verifyCodesMux.Lock() + defer verifyCodesMux.Unlock() + entry, ok := verifyCodes[email] + if !ok { + return false + } + if time.Now().After(entry.ExpiresAt) { + delete(verifyCodes, email) + return false + } + if entry.Code != code { + return false + } + delete(verifyCodes, email) + return true +} + func (h *AuthHandler) Register(c *gin.Context) { var req schemas.RegisterRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -68,6 +107,7 @@ func (h *AuthHandler) Register(c *gin.Context) { } verifyCode := utils.GenerateVerifyCode() + storeVerifyCode(req.Email, verifyCode) utils.SendVerifyEmail(req.Email, verifyCode) token, _ := utils.GenerateToken(user.ID, user.Role) @@ -137,6 +177,7 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) { } code := utils.GenerateVerifyCode() + storeVerifyCode(req.Email, code) utils.SendResetPasswordEmail(req.Email, code) c.JSON(http.StatusOK, gin.H{"message": "If the email exists, a verification code has been sent"}) @@ -149,6 +190,11 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) { return } + if !checkVerifyCode(req.Email, req.Code) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired verification code"}) + return + } + var user models.User if err := utils.DB.Where("email = ?", req.Email).First(&user).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email"}) @@ -172,6 +218,11 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) { return } + if !checkVerifyCode(req.Email, req.Code) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired verification code"}) + return + } + var user models.User if err := utils.DB.Where("email = ?", req.Email).First(&user).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "User not found"}) @@ -261,6 +312,7 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) { } code := utils.GenerateVerifyCode() + storeVerifyCode(user.Email, code) utils.SendVerifyEmail(user.Email, code) c.JSON(http.StatusOK, gin.H{"message": "Verification code sent", "expires_at": time.Now().Add(30 * time.Minute)}) @@ -320,3 +372,62 @@ func (h *AuthHandler) Install(c *gin.Context) { }, }) } + +func (h *AuthHandler) AdminGetUsers(c *gin.Context) { + var users []models.User + utils.DB.Select("id, username, email, role, purchase_credits, is_active, email_verified, created_at").Find(&users) + c.JSON(http.StatusOK, gin.H{"data": users}) +} + +func (h *AuthHandler) AdminUpdateUser(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var user models.User + if err := utils.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + var req struct { + Username *string `json:"username"` + Email *string `json:"email"` + Role *string `json:"role"` + IsActive *bool `json:"is_active"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := make(map[string]interface{}) + if req.Username != nil { + updates["username"] = *req.Username + } + if req.Email != nil { + updates["email"] = *req.Email + } + if req.Role != nil { + updates["role"] = *req.Role + } + if req.IsActive != nil { + updates["is_active"] = *req.IsActive + } + + utils.DB.Model(&user).Updates(updates) + utils.DB.First(&user, id) + c.JSON(http.StatusOK, gin.H{"data": user}) +} + +func (h *AuthHandler) AdminDeleteUser(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var user models.User + if err := utils.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + if user.Role == "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Cannot delete admin user"}) + return + } + utils.DB.Delete(&user) + c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"}) +} diff --git a/backend/internal/api/handlers/banner.go b/backend/internal/api/handlers/banner.go new file mode 100644 index 0000000..e738e01 --- /dev/null +++ b/backend/internal/api/handlers/banner.go @@ -0,0 +1,141 @@ +package handlers + +import ( + "net/http" + "strconv" + + "sale/internal/models" + "sale/internal/utils" + + "github.com/gin-gonic/gin" +) + +type BannerHandler struct{} + +func NewBannerHandler() *BannerHandler { + return &BannerHandler{} +} + +func (h *BannerHandler) List(c *gin.Context) { + var banners []models.Banner + utils.DB.Where("is_active = ?", true). + Order("sort_order ASC, created_at DESC"). + Find(&banners) + + c.JSON(http.StatusOK, gin.H{"data": banners}) +} + +func (h *BannerHandler) AdminList(c *gin.Context) { + var banners []models.Banner + utils.DB.Order("sort_order ASC, created_at DESC").Find(&banners) + + c.JSON(http.StatusOK, gin.H{"data": banners}) +} + +func (h *BannerHandler) Create(c *gin.Context) { + var req struct { + Title string `json:"title" binding:"required"` + Desc string `json:"desc"` + Image string `json:"image" binding:"required"` + Link string `json:"link"` + Button string `json:"button"` + BgColor string `json:"bg_color"` + SortOrder int `json:"sort_order"` + IsActive bool `json:"is_active"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + banner := models.Banner{ + Title: req.Title, + Desc: req.Desc, + Image: req.Image, + Link: req.Link, + Button: req.Button, + BgColor: req.BgColor, + SortOrder: req.SortOrder, + IsActive: req.IsActive, + } + + utils.DB.Create(&banner) + c.JSON(http.StatusCreated, gin.H{"data": banner}) +} + +func (h *BannerHandler) Update(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var banner models.Banner + if err := utils.DB.First(&banner, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Banner not found"}) + return + } + + var req struct { + Title *string `json:"title"` + Desc *string `json:"desc"` + Image *string `json:"image"` + Link *string `json:"link"` + Button *string `json:"button"` + BgColor *string `json:"bg_color"` + SortOrder *int `json:"sort_order"` + IsActive *bool `json:"is_active"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := make(map[string]interface{}) + if req.Title != nil { + updates["title"] = *req.Title + } + if req.Desc != nil { + updates["desc"] = *req.Desc + } + if req.Image != nil { + updates["image"] = *req.Image + } + if req.Link != nil { + updates["link"] = *req.Link + } + if req.Button != nil { + updates["button"] = *req.Button + } + if req.BgColor != nil { + updates["bg_color"] = *req.BgColor + } + if req.SortOrder != nil { + updates["sort_order"] = *req.SortOrder + } + if req.IsActive != nil { + updates["is_active"] = *req.IsActive + } + + utils.DB.Model(&banner).Updates(updates) + c.JSON(http.StatusOK, gin.H{"data": banner}) +} + +func (h *BannerHandler) Delete(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := utils.DB.Delete(&models.Banner{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete banner"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Banner deleted successfully"}) +} + +func (h *BannerHandler) ToggleActive(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var banner models.Banner + if err := utils.DB.First(&banner, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Banner not found"}) + return + } + + utils.DB.Model(&banner).Update("is_active", !banner.IsActive) + utils.DB.First(&banner, id) + c.JSON(http.StatusOK, gin.H{"data": banner}) +} diff --git a/backend/internal/api/handlers/lottery.go b/backend/internal/api/handlers/lottery.go index 8b7dc9d..ad35db6 100644 --- a/backend/internal/api/handlers/lottery.go +++ b/backend/internal/api/handlers/lottery.go @@ -1,7 +1,8 @@ package handlers import ( - "math/rand" + "crypto/rand" + "math/big" "net/http" "strconv" "time" @@ -193,7 +194,6 @@ func (h *LotteryHandler) Draw(c *gin.Context) { } var winners []models.LotteryWinner - rng := rand.New(rand.NewSource(time.Now().UnixNano())) for _, prize := range lottery.Prizes { remaining := prize.Quantity @@ -223,7 +223,10 @@ func (h *LotteryHandler) Draw(c *gin.Context) { selected := make(map[uint]bool) for remaining > 0 && len(selected) < len(selectedParticipants) { - r := rng.Intn(totalWeight) + r, err := rand.Int(rand.Reader, big.NewInt(int64(totalWeight))) + if err != nil { + break + } cumWeight := 0 for _, p := range selectedParticipants { if selected[p.UserID] { @@ -234,7 +237,7 @@ func (h *LotteryHandler) Draw(c *gin.Context) { w = 1 } cumWeight += w - if cumWeight > r { + if cumWeight > int(r.Int64()) { winner := models.LotteryWinner{ LotteryID: lottery.ID, PrizeID: prize.ID, diff --git a/backend/internal/api/handlers/order.go b/backend/internal/api/handlers/order.go index f4105de..7ec608f 100644 --- a/backend/internal/api/handlers/order.go +++ b/backend/internal/api/handlers/order.go @@ -230,7 +230,9 @@ func (h *OrderHandler) Create(c *gin.Context) { PaymentMethod: req.PaymentMethod, } - if err := utils.DB.Create(&order).Error; err != nil { + tx := utils.DB.Begin() + if err := tx.Create(&order).Error; err != nil { + tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order"}) return } @@ -238,20 +240,41 @@ func (h *OrderHandler) Create(c *gin.Context) { for i := range orderItems { orderItems[i].OrderID = order.ID } - utils.DB.Create(&orderItems) + if err := tx.Create(&orderItems).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order items"}) + return + } for _, cart := range carts { if cart.Product.RequireCredit { - utils.DB.Model(&models.User{}).Where("id = ?", userID). - UpdateColumn("purchase_credits", utils.DB.Raw("purchase_credits - ?", cart.Product.CreditCost*cart.Quantity)) + if err := tx.Model(&models.User{}).Where("id = ?", userID). + UpdateColumn("purchase_credits", utils.DB.Raw("purchase_credits - ?", cart.Product.CreditCost*cart.Quantity)).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to deduct credits"}) + return + } } if cart.Product.CreditReward > 0 { - utils.DB.Model(&models.User{}).Where("id = ?", userID). + tx.Model(&models.User{}).Where("id = ?", userID). UpdateColumn("purchase_credits", utils.DB.Raw("purchase_credits + ?", cart.Product.CreditReward*cart.Quantity)) } + + var inventory models.Inventory + if err := tx.Where("product_id = ?", cart.ProductID).First(&inventory).Error; err == nil { + if inventory.Quantity >= cart.Quantity { + tx.Model(&inventory).UpdateColumn("quantity", inventory.Quantity-cart.Quantity) + } + } } - utils.DB.Where("user_id = ?", userID).Delete(&models.Cart{}) + if err := tx.Where("user_id = ?", userID).Delete(&models.Cart{}).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear cart"}) + return + } + + tx.Commit() utils.DB.Preload("OrderItems.Product").Preload("ShippingAddress").First(&order, order.ID) c.JSON(http.StatusCreated, gin.H{"data": order}) @@ -412,3 +435,41 @@ func (h *OrderHandler) Export(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": orders}) } + +func (h *OrderHandler) CancelOrder(c *gin.Context) { + userID := c.GetUint("user_id") + id, _ := strconv.Atoi(c.Param("id")) + + var order models.Order + if err := utils.DB.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Order not found"}) + return + } + + if order.Status != models.OrderStatusPendingPayment && order.Status != models.OrderStatusPendingConfirm { + c.JSON(http.StatusBadRequest, gin.H{"error": "Order cannot be cancelled"}) + return + } + + utils.DB.Model(&order).Update("status", models.OrderStatusCancelled) + c.JSON(http.StatusOK, gin.H{"message": "Order cancelled successfully"}) +} + +func (h *OrderHandler) ConfirmReceipt(c *gin.Context) { + userID := c.GetUint("user_id") + id, _ := strconv.Atoi(c.Param("id")) + + var order models.Order + if err := utils.DB.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Order not found"}) + return + } + + if order.Status != models.OrderStatusShipped { + c.JSON(http.StatusBadRequest, gin.H{"error": "Order cannot be confirmed"}) + return + } + + utils.DB.Model(&order).Update("status", models.OrderStatusCompleted) + c.JSON(http.StatusOK, gin.H{"message": "Order confirmed successfully"}) +} diff --git a/backend/internal/api/handlers/system.go b/backend/internal/api/handlers/system.go index 7c70287..13dd9a1 100644 --- a/backend/internal/api/handlers/system.go +++ b/backend/internal/api/handlers/system.go @@ -57,7 +57,7 @@ func (h *SystemHandler) GetStats(c *gin.Context) { utils.DB.Model(&models.User{}).Count(&userCount) utils.DB.Model(&models.Product{}).Count(&productCount) utils.DB.Model(&models.Order{}).Count(&orderCount) - utils.DB.Model(&models.Order{}).Where("status = ?", "completed").Select("COALESCE(SUM(total), 0)").Scan(&totalRevenue) + utils.DB.Model(&models.Order{}).Where("status = ?", "completed").Select("COALESCE(SUM(total_amount), 0)").Scan(&totalRevenue) c.JSON(http.StatusOK, gin.H{ "data": gin.H{ @@ -173,13 +173,29 @@ func (h *SupplierHandler) Update(c *gin.Context) { return } - var updateData map[string]interface{} - if err := c.ShouldBindJSON(&updateData); err != nil { + var req struct { + Username *string `json:"username"` + Email *string `json:"email"` + IsActive *bool `json:"is_active"` + } + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - utils.DB.Model(&supplier).Updates(updateData) + updates := make(map[string]interface{}) + if req.Username != nil { + updates["username"] = *req.Username + } + if req.Email != nil { + updates["email"] = *req.Email + } + if req.IsActive != nil { + updates["is_active"] = *req.IsActive + } + + utils.DB.Model(&supplier).Updates(updates) + utils.DB.Where("id = ?", id).First(&supplier) c.JSON(http.StatusOK, gin.H{"data": supplier}) } diff --git a/backend/internal/api/handlers/ticket.go b/backend/internal/api/handlers/ticket.go index 3d4c911..9d961d6 100644 --- a/backend/internal/api/handlers/ticket.go +++ b/backend/internal/api/handlers/ticket.go @@ -164,3 +164,36 @@ func (h *TicketHandler) Assign(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Ticket assigned successfully"}) } + +func (h *TicketHandler) Reply(c *gin.Context) { + userID := c.GetUint("user_id") + id, _ := strconv.Atoi(c.Param("id")) + + var ticket models.Ticket + if err := utils.DB.Where("id = ? AND user_id = ?", id, userID).First(&ticket).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Ticket not found"}) + return + } + + var req struct { + Content string `json:"content" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + reply := ticket.Reply + if reply != "" { + reply += "\n\n---\n\n" + } + reply += req.Content + + utils.DB.Model(&ticket).Updates(map[string]interface{}{ + "reply": reply, + "status": models.TicketStatusPending, + }) + + utils.DB.First(&ticket, id) + c.JSON(http.StatusOK, gin.H{"data": ticket}) +} diff --git a/backend/internal/api/handlers/upload.go b/backend/internal/api/handlers/upload.go index 46a2557..1268fc1 100644 --- a/backend/internal/api/handlers/upload.go +++ b/backend/internal/api/handlers/upload.go @@ -2,12 +2,10 @@ package handlers import ( "fmt" - "io" "net/http" "os" "path/filepath" "strings" - "time" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -104,35 +102,33 @@ func (h *UploadHandler) DeleteImage(c *gin.Context) { return } - filepath := filepath.Join("uploads/images", filename) - if err := os.Remove(filepath); err != nil { + if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filename"}) + return + } + + filePath := filepath.Join("uploads/images", filename) + filePath, err := filepath.Abs(filePath) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file path"}) + return + } + + absUploadDir, _ := filepath.Abs("uploads/images") + if !strings.HasPrefix(filePath, absUploadDir) { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + if _, err := os.Stat(filePath); os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) return } + if err := os.Remove(filePath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "File deleted successfully"}) } - -func (h *UploadHandler) ExportOrders(c *gin.Context) { - filename := fmt.Sprintf("orders_%s.csv", time.Now().Format("20060102150405")) - filepath := filepath.Join("uploads", filename) - - file, err := os.Create(filepath) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create file"}) - return - } - defer file.Close() - - c.Header("Content-Description", "File Transfer") - c.Header("Content-Transfer-Encoding", "binary") - c.Header("Content-Disposition", "attachment; filename="+filename) - c.Header("Content-Type", "text/csv") - - file.Seek(0, 0) - _, err = io.Copy(c.Writer, file) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send file"}) - return - } -} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index cfc33af..eacf057 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -5,8 +5,6 @@ import ( "sale/internal/api/handlers" "sale/internal/api/middlewares" - "sale/internal/models" - "sale/internal/utils" "github.com/gin-gonic/gin" ) @@ -26,6 +24,7 @@ func SetupRoutes(r *gin.Engine) { inventoryHandler := handlers.NewInventoryHandler() articleHandler := handlers.NewArticleHandler() uploadHandler := handlers.NewUploadHandler() + bannerHandler := handlers.NewBannerHandler() r.Use(middlewares.CORSMiddleware()) @@ -40,7 +39,7 @@ func SetupRoutes(r *gin.Engine) { auth.POST("/login", authHandler.Login) auth.POST("/forgot-password", authHandler.ForgotPassword) auth.POST("/reset-password", authHandler.ResetPassword) - auth.GET("/verify-email", authHandler.VerifyEmail) + auth.POST("/verify-email", authHandler.VerifyEmail) } articles := api.Group("/articles") @@ -51,6 +50,8 @@ func SetupRoutes(r *gin.Engine) { articles.POST("/crawl-ribenyan", articleHandler.CrawlRibenyan) } + api.GET("/banners", bannerHandler.List) + api.GET("/categories", categoryHandler.List) api.GET("/brands", brandHandler.List) api.GET("/products", productHandler.List) @@ -97,6 +98,8 @@ func SetupRoutes(r *gin.Engine) { orders.GET("/:id", orderHandler.GetByID) orders.POST("", orderHandler.Create) orders.POST("/:id/refund", orderHandler.Refund) + orders.PUT("/:id/cancel", orderHandler.CancelOrder) + orders.PUT("/:id/confirm-receipt", orderHandler.ConfirmReceipt) } lotteries := authed.Group("/lotteries") @@ -109,6 +112,7 @@ func SetupRoutes(r *gin.Engine) { tickets.GET("", ticketHandler.List) tickets.POST("", ticketHandler.Create) tickets.GET("/:id", ticketHandler.GetByID) + tickets.POST("/:id/reply", ticketHandler.Reply) } supplier := authed.Group("/supplier") @@ -126,11 +130,9 @@ func SetupRoutes(r *gin.Engine) { admin.Use(middlewares.RoleMiddleware("admin")) { admin.GET("/stats", systemHandler.GetStats) - admin.GET("/users", func(c *gin.Context) { - var users []models.User - utils.DB.Select("id, username, email, role, purchase_credits, is_active, created_at").Find(&users) - c.JSON(200, gin.H{"data": users}) - }) + admin.GET("/users", authHandler.AdminGetUsers) + admin.PUT("/users/:id", authHandler.AdminUpdateUser) + admin.DELETE("/users/:id", authHandler.AdminDeleteUser) adminCategories := admin.Group("/categories") { @@ -203,6 +205,15 @@ func SetupRoutes(r *gin.Engine) { adminArticles.DELETE("/:id", articleHandler.Delete) adminArticles.PUT("/:id/pin", articleHandler.TogglePin) } + + adminBanners := admin.Group("/banners") + { + adminBanners.GET("", bannerHandler.AdminList) + adminBanners.POST("", bannerHandler.Create) + adminBanners.PUT("/:id", bannerHandler.Update) + adminBanners.DELETE("/:id", bannerHandler.Delete) + adminBanners.PUT("/:id/toggle", bannerHandler.ToggleActive) + } } } } diff --git a/backend/internal/models/banner.go b/backend/internal/models/banner.go new file mode 100644 index 0000000..10c54c8 --- /dev/null +++ b/backend/internal/models/banner.go @@ -0,0 +1,26 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type Banner struct { + ID uint `gorm:"primaryKey" json:"id"` + Title string `gorm:"size:100;not null" json:"title"` + Desc string `gorm:"size:200" json:"desc"` + Image string `gorm:"size:500;not null" json:"image"` + Link string `gorm:"size:200" json:"link"` + Button string `gorm:"size:50" json:"button"` + BgColor string `gorm:"size:50" json:"bg_color"` + SortOrder int `gorm:"default:0" json:"sort_order"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (Banner) TableName() string { + return "banners" +} diff --git a/backend/internal/models/lottery.go b/backend/internal/models/lottery.go index ecdfd09..3c69e71 100644 --- a/backend/internal/models/lottery.go +++ b/backend/internal/models/lottery.go @@ -10,6 +10,7 @@ type Lottery struct { ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"size:100;not null" json:"name"` Description string `json:"description"` + Image string `gorm:"size:500" json:"image"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` Cycle string `gorm:"size:20" json:"cycle"` diff --git a/backend/internal/utils/database.go b/backend/internal/utils/database.go index dbb73a5..6d83ad4 100644 --- a/backend/internal/utils/database.go +++ b/backend/internal/utils/database.go @@ -70,6 +70,7 @@ func AutoMigrate() { &models.SystemSetting{}, &models.SupplierAuthorization{}, &models.Article{}, + &models.Banner{}, ) if err != nil { log.Fatalf("Failed to migrate database: %v", err) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 4be5c4d..2151d5c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -29,7 +29,7 @@ export const authApi = { login: (data: any) => api.post('/auth/login', data), forgotPassword: (data: any) => api.post('/auth/forgot-password', data), resetPassword: (data: any) => api.post('/auth/reset-password', data), - verifyEmail: (params: any) => api.get('/auth/verify-email', { params }), + verifyEmail: (data: any) => api.post('/auth/verify-email', data), sendVerifyCode: () => api.post('/users/send-verify-code'), } @@ -72,6 +72,8 @@ export const orderApi = { getById: (id: number) => api.get(`/orders/${id}`), create: (data: any) => api.post('/orders', data), refund: (id: number, data: any) => api.post(`/orders/${id}/refund`, data), + cancel: (id: number) => api.put(`/orders/${id}/cancel`), + confirmReceipt: (id: number) => api.put(`/orders/${id}/confirm-receipt`), } export const articleApi = { @@ -79,6 +81,10 @@ export const articleApi = { getById: (id: number) => api.get(`/articles/${id}`), } +export const bannerApi = { + list: () => api.get('/banners'), +} + export const lotteryApi = { list: () => api.get('/lotteries'), getById: (id: number) => api.get(`/lotteries/${id}`), @@ -89,10 +95,7 @@ export const ticketApi = { list: (params?: any) => api.get('/tickets', { params }), create: (data: any) => api.post('/tickets', data), getById: (id: number) => api.get(`/tickets/${id}`), -} - -export const menuApi = { - list: () => api.get('/menus'), + reply: (id: number, data: any) => api.post(`/tickets/${id}/reply`, data), } export const supplierApi = { @@ -105,6 +108,11 @@ export const supplierApi = { } export const adminApi = { + getStats: () => api.get('/admin/stats'), + getUsers: () => api.get('/admin/users'), + updateUser: (id: number, data: any) => api.put(`/admin/users/${id}`, data), + deleteUser: (id: number) => api.delete(`/admin/users/${id}`), + getCategories: () => api.get('/categories'), createCategory: (data: any) => api.post('/admin/categories', data), updateCategory: (id: number, data: any) => api.put(`/admin/categories/${id}`, data), @@ -147,11 +155,6 @@ export const adminApi = { updateSMTP: (data: any) => api.put('/admin/settings/smtp', data), updatePayment: (data: any) => api.put('/admin/settings/payment', data), - getMenus: () => api.get('/admin/menus'), - createMenu: (data: any) => api.post('/admin/menus', data), - updateMenu: (id: number, data: any) => api.put(`/admin/menus/${id}`, data), - deleteMenu: (id: number) => api.delete(`/admin/menus/${id}`), - getInventory: () => api.get('/admin/inventory'), getArticles: (params?: any) => api.get('/admin/articles', { params }), @@ -159,4 +162,10 @@ export const adminApi = { updateArticle: (id: number, data: any) => api.put(`/admin/articles/${id}`, data), deleteArticle: (id: number) => api.delete(`/admin/articles/${id}`), togglePinArticle: (id: number) => api.put(`/admin/articles/${id}/pin`), + + getBanners: () => api.get('/admin/banners'), + createBanner: (data: any) => api.post('/admin/banners', data), + updateBanner: (id: number, data: any) => api.put(`/admin/banners/${id}`, data), + deleteBanner: (id: number) => api.delete(`/admin/banners/${id}`), + toggleBanner: (id: number) => api.put(`/admin/banners/${id}/toggle`), } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 20dd1a9..05987b1 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -123,6 +123,8 @@ "supplierManagement": "Supplier Management", "lotteryManagement": "Lottery Management", "ticketManagement": "Ticket Management", + "articleManagement": "Article Management", + "bannerManagement": "Banner Management", "systemSettings": "System Settings", "menuManagement": "Menu Management", "smtpSettings": "SMTP Settings", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 87edea5..8e8c9ae 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -123,6 +123,8 @@ "supplierManagement": "サプライヤー管理", "lotteryManagement": "抽選管理", "ticketManagement": "チケット管理", + "articleManagement": "記事管理", + "bannerManagement": "バナー管理", "systemSettings": "システム設定", "menuManagement": "メニュー管理", "smtpSettings": "SMTP設定", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 683a219..aa62e0f 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -123,6 +123,8 @@ "supplierManagement": "供货商管理", "lotteryManagement": "抽奖管理", "ticketManagement": "工单管理", + "articleManagement": "资讯管理", + "bannerManagement": "轮播图管理", "systemSettings": "系统设置", "menuManagement": "菜单管理", "smtpSettings": "SMTP设置", diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index a11509e..466c942 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -47,7 +47,11 @@ - 资讯管理 + {{ $t('admin.articleManagement') }} + + + + {{ $t('admin.bannerManagement') }} @@ -95,7 +99,45 @@ -

{{ currentPageTitle }}

+ +
+ + + + + + + +
+ {{ user?.username?.charAt(0).toUpperCase() }} + {{ user?.username }} + +
+ +
+
@@ -110,13 +152,15 @@ import { ref, computed } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' -import { DataAnalysis, User, Folder, Goods, List, Van, Trophy, ChatDotSquare, Setting, Document, Compass, Fold, Expand } from '@element-plus/icons-vue' +import { DataAnalysis, User, Folder, Goods, List, Van, Trophy, ChatDotSquare, Setting, Document, Picture, Compass, Fold, Expand, HomeFilled, FullScreen, ArrowDown, SwitchButton, ArrowRight } from '@element-plus/icons-vue' import { useUserStore } from '../store/user' +import { useCartStore } from '../store/cart' const router = useRouter() const route = useRoute() const { locale, t } = useI18n() const userStore = useUserStore() +const cartStore = useCartStore() const sidebarCollapsed = ref(false) const mobileMenuOpen = ref(false) @@ -132,6 +176,7 @@ const pageTitles: Record = { '/admin/lotteries': '抽奖管理', '/admin/tickets': '工单管理', '/admin/articles': '资讯管理', + '/admin/banners': '轮播图管理', '/admin/settings': '系统设置' } @@ -147,7 +192,15 @@ function changeLocale(lang: string) { function handleCommand(command: string) { switch (command) { case 'home': router.push('/'); break - case 'logout': userStore.logout(); router.push('/login'); break + case 'logout': userStore.logout(); cartStore.clearCart(); router.push('/login'); break + } +} + +function toggleFullscreen() { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen() + } else { + document.exitFullscreen() } } @@ -328,11 +381,99 @@ function handleCommand(command: string) { background: rgba(255, 255, 255, 0.06); } -.page-title { - font-size: 18px; - font-weight: 600; +.breadcrumb { + display: flex; + align-items: center; + gap: 4px; +} + +.breadcrumb-item { + display: inline-flex; + align-items: center; + gap: 4px; + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + transition: all 0.15s; +} + +.breadcrumb-item:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.06); +} + +.breadcrumb-sep { + font-size: 12px; + color: rgba(255, 255, 255, 0.2); +} + +.breadcrumb-current { + color: rgba(255, 255, 255, 0.9); + font-size: 13px; + font-weight: 500; + padding: 4px 8px; + background: rgba(255, 255, 255, 0.06); + border-radius: 6px; +} + +.topbar-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; +} + +.action-btn { + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.6); + background: none; + border: none; + cursor: pointer; + transition: all 0.15s; +} + +.action-btn:hover { + background: rgba(255, 255, 255, 0.06); color: #fff; - margin: 0; +} + +.user-dropdown { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s; +} + +.user-dropdown:hover { + background: rgba(255, 255, 255, 0.06); +} + +.user-avatar { + background: linear-gradient(135deg, #4e6ef2, #7c5cfc); + color: #fff; + font-size: 14px; + font-weight: 600; +} + +.user-name { + color: rgba(255, 255, 255, 0.9); + font-size: 14px; + font-weight: 500; +} + +.dropdown-arrow { + color: rgba(255, 255, 255, 0.4); + font-size: 12px; } .page-content { @@ -364,10 +505,30 @@ function handleCommand(command: string) { margin-left: 0; } + .topbar { + padding: 0 12px; + } + .menu-btn { display: flex; } + .breadcrumb-item span { + display: none; + } + + .user-name { + display: none; + } + + .dropdown-arrow { + display: none; + } + + .user-dropdown { + padding: 4px; + } + .collapse-btn { display: none; } @@ -379,5 +540,9 @@ function handleCommand(command: string) { background: rgba(0, 0, 0, 0.5); z-index: 99; } + + .page-content { + padding: 16px 12px; + } } diff --git a/frontend/src/layouts/SupplierLayout.vue b/frontend/src/layouts/SupplierLayout.vue index 318cb3c..315dc8e 100644 --- a/frontend/src/layouts/SupplierLayout.vue +++ b/frontend/src/layouts/SupplierLayout.vue @@ -70,11 +70,13 @@ import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import { DataAnalysis, List, Box, Fold, Expand } from '@element-plus/icons-vue' import { useUserStore } from '../store/user' +import { useCartStore } from '../store/cart' const router = useRouter() const route = useRoute() const { t } = useI18n() const userStore = useUserStore() +const cartStore = useCartStore() const sidebarCollapsed = ref(false) const mobileMenuOpen = ref(false) @@ -93,7 +95,7 @@ const currentPageTitle = computed(() => { function handleCommand(command: string) { switch (command) { case 'home': router.push('/'); break - case 'logout': userStore.logout(); router.push('/login'); break + case 'logout': userStore.logout(); cartStore.clearCart(); router.push('/login'); break } } diff --git a/frontend/src/layouts/scheme4/UserLayout.vue b/frontend/src/layouts/UserLayout.vue similarity index 96% rename from frontend/src/layouts/scheme4/UserLayout.vue rename to frontend/src/layouts/UserLayout.vue index b7b294e..7210b78 100644 --- a/frontend/src/layouts/scheme4/UserLayout.vue +++ b/frontend/src/layouts/UserLayout.vue @@ -117,8 +117,8 @@ import { ref, computed, watch } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import { HomeFilled, Goods, Document, Trophy, ShoppingCart, Compass, User, Fold, Expand, Search } from '@element-plus/icons-vue' -import { useUserStore } from '../../store/user' -import { useCartStore } from '../../store/cart' +import { useUserStore } from '../store/user' +import { useCartStore } from '../store/cart' const router = useRouter() const route = useRoute() @@ -147,7 +147,7 @@ function handleCommand(command: string) { case 'orders': router.push('/orders'); break case 'admin': router.push('/admin'); break case 'supplier': router.push('/supplier'); break - case 'logout': userStore.logout(); router.push('/login'); break + case 'logout': userStore.logout(); cartStore.clearCart(); router.push('/login'); break } } @@ -442,6 +442,11 @@ if (isLoggedIn.value) cartStore.fetchCart() } @media (max-width: 768px) { + .layout-wrapper { + width: 100%; + overflow-x: hidden; + } + .sidebar { transform: translateX(-100%); width: 220px !important; @@ -458,6 +463,9 @@ if (isLoggedIn.value) cartStore.fetchCart() .main-container, .main-container.sidebar-collapsed { margin-left: 0; + width: 100%; + max-width: 100vw; + overflow-x: hidden; } .menu-btn { @@ -468,6 +476,14 @@ if (isLoggedIn.value) cartStore.fetchCart() display: none; } + .page-content { + padding: 16px; + width: 100%; + max-width: 100vw; + overflow-x: hidden; + box-sizing: border-box; + } + .overlay { display: block; position: fixed; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index f2d027a..4bc49a9 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -10,9 +10,9 @@ const routes = [ }, { path: '/', - component: () => import('../layouts/scheme4/UserLayout.vue'), + component: () => import('../layouts/UserLayout.vue'), children: [ - { path: '', name: 'Home', component: () => import('../views/scheme4/Home.vue') }, + { path: '', name: 'Home', component: () => import('../views/user/Home.vue') }, { path: 'products', name: 'Products', component: () => import('../views/user/Products.vue') }, { path: 'products/:id', name: 'ProductDetail', component: () => import('../views/user/ProductDetail.vue') }, { path: 'cart', name: 'Cart', component: () => import('../views/user/Cart.vue') }, @@ -24,6 +24,7 @@ const routes = [ { path: 'lotteries/:id', name: 'LotteryDetail', component: () => import('../views/user/LotteryDetail.vue') }, { path: 'tickets', name: 'Tickets', component: () => import('../views/user/Tickets.vue') }, { path: 'tickets/create', name: 'CreateTicket', component: () => import('../views/user/CreateTicket.vue') }, + { path: 'tickets/:id', name: 'TicketDetail', component: () => import('../views/user/TicketDetail.vue') }, { path: 'articles', name: 'Articles', component: () => import('../views/user/Articles.vue') }, { path: 'articles/:id', name: 'ArticleDetail', component: () => import('../views/user/ArticleDetail.vue') }, { path: 'login', name: 'Login', component: () => import('../views/auth/Login.vue'), meta: { guest: true, hideSidebar: true } }, @@ -46,6 +47,7 @@ const routes = [ { path: 'tickets', name: 'AdminTickets', component: () => import('../views/admin/Tickets.vue') }, { path: 'settings', name: 'AdminSettings', component: () => import('../views/admin/Settings.vue') }, { path: 'articles', name: 'AdminArticles', component: () => import('../views/admin/Articles.vue') }, + { path: 'banners', name: 'AdminBanners', component: () => import('../views/admin/Banners.vue') }, ], }, { @@ -58,6 +60,11 @@ const routes = [ { path: 'inventory', name: 'SupplierInventory', component: () => import('../views/supplier/Inventory.vue') }, ], }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('../views/NotFound.vue'), + }, ] const router = createRouter({ diff --git a/frontend/src/store/cart.ts b/frontend/src/store/cart.ts index 96941a9..537e32d 100644 --- a/frontend/src/store/cart.ts +++ b/frontend/src/store/cart.ts @@ -13,7 +13,6 @@ export const useCartStore = defineStore('cart', () => { try { const res: any = await cartApi.list() items.value = res.data || [] - calcTotals() } finally { loading.value = false } @@ -35,12 +34,16 @@ export const useCartStore = defineStore('cart', () => { } async function removeItems(ids: number[]) { - for (const id of ids) { - await cartApi.delete(id) - } + await Promise.all(ids.map(id => cartApi.delete(id))) await fetchCart() } + function clearCart() { + items.value = [] + totalAmount.value = 0 + totalCount.value = 0 + } + function calcTotals() { totalAmount.value = items.value.reduce((sum, item) => sum + (item.product?.price || 0) * item.quantity, 0) totalCount.value = items.value.reduce((sum, item) => sum + item.quantity, 0) @@ -48,15 +51,16 @@ export const useCartStore = defineStore('cart', () => { watch(items, calcTotals, { deep: true }) - return { - items, - loading, - totalAmount, - totalCount, - fetchCart, - addItem, - updateItem, + return { + items, + loading, + totalAmount, + totalCount, + fetchCart, + addItem, + updateItem, removeItem, - removeItems + removeItems, + clearCart } }) diff --git a/frontend/src/store/user.ts b/frontend/src/store/user.ts index 3e75831..395e9a4 100644 --- a/frontend/src/store/user.ts +++ b/frontend/src/store/user.ts @@ -2,10 +2,18 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { authApi, userApi } from '../api' +function safeParseUser(str: string | null): any { + if (!str || str === 'undefined' || str === 'null') return null + try { + return JSON.parse(str) + } catch { + return null + } +} + export const useUserStore = defineStore('user', () => { const token = ref(localStorage.getItem('token') || '') - const userStr = localStorage.getItem('user') - const user = ref(userStr && userStr !== 'undefined' ? JSON.parse(userStr) : null) + const user = ref(safeParseUser(localStorage.getItem('user'))) const isLoggedIn = computed(() => !!token.value) const isAdmin = computed(() => user.value?.role === 'admin') diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts index adf5e7e..47902e3 100644 --- a/frontend/src/utils/request.ts +++ b/frontend/src/utils/request.ts @@ -1,5 +1,6 @@ import axios from 'axios' import { ElMessage } from 'element-plus' +import router from '../router' const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api', @@ -29,7 +30,8 @@ api.interceptors.response.use( localStorage.removeItem('token') localStorage.removeItem('user') if (hadToken) { - window.location.href = '/login' + ElMessage.error('登录已过期,请重新登录') + router.push('/login') } } else { ElMessage.error(message) diff --git a/frontend/src/views/NotFound.vue b/frontend/src/views/NotFound.vue new file mode 100644 index 0000000..0812daf --- /dev/null +++ b/frontend/src/views/NotFound.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/frontend/src/views/admin/Articles.vue b/frontend/src/views/admin/Articles.vue index f6c10a5..a45c236 100644 --- a/frontend/src/views/admin/Articles.vue +++ b/frontend/src/views/admin/Articles.vue @@ -1,7 +1,6 @@