fix: 全面修复项目问题

后端修复:
- 验证码存储与校验机制
- 订单创建事务+库存扣减
- GetStats字段名错误
- VerifyEmail改为POST
- 供应商更新字段白名单
- 文件删除安全检查
- 抽奖安全随机数
- 用户管理CRUD
- 订单取消/确认收货
- 工单回复
- Toggle返回新数据
- 移除死代码

前端修复:
- 404兜底路由
- 401软跳转
- API层统一
- 面包屑补充banners
- 国际化完善
- 购物车并行删除
- 退出清理购物车
- 供应商Dashboard数据
- 工单详情页
- 订单取消/确认收货
This commit is contained in:
2026-05-06 09:16:34 +08:00
parent 739481b59f
commit 3a898e8aa0
52 changed files with 2807 additions and 920 deletions
+2
View File
@@ -5,7 +5,9 @@ vendor/
# Build outputs # Build outputs
frontend/dist/ frontend/dist/
backend/sale.db backend/sale.db
backend/cmd/server/sale.db
backend/sale backend/sale
backend/cmd/server/uploads/
# IDE # IDE
.idea/ .idea/
Binary file not shown.
+1
View File
@@ -161,6 +161,7 @@ func (h *ArticleHandler) TogglePin(c *gin.Context) {
} }
utils.DB.Model(&article).Update("is_pinned", !article.IsPinned) utils.DB.Model(&article).Update("is_pinned", !article.IsPinned)
utils.DB.First(&article, id)
c.JSON(http.StatusOK, gin.H{"data": article}) c.JSON(http.StatusOK, gin.H{"data": article})
} }
+111
View File
@@ -2,6 +2,8 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv"
"sync"
"time" "time"
"sale/internal/models" "sale/internal/models"
@@ -17,6 +19,43 @@ func NewAuthHandler() *AuthHandler {
return &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) { func (h *AuthHandler) Register(c *gin.Context) {
var req schemas.RegisterRequest var req schemas.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -68,6 +107,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
} }
verifyCode := utils.GenerateVerifyCode() verifyCode := utils.GenerateVerifyCode()
storeVerifyCode(req.Email, verifyCode)
utils.SendVerifyEmail(req.Email, verifyCode) utils.SendVerifyEmail(req.Email, verifyCode)
token, _ := utils.GenerateToken(user.ID, user.Role) token, _ := utils.GenerateToken(user.ID, user.Role)
@@ -137,6 +177,7 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
} }
code := utils.GenerateVerifyCode() code := utils.GenerateVerifyCode()
storeVerifyCode(req.Email, code)
utils.SendResetPasswordEmail(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"}) 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 return
} }
if !checkVerifyCode(req.Email, req.Code) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired verification code"})
return
}
var user models.User var user models.User
if err := utils.DB.Where("email = ?", req.Email).First(&user).Error; err != nil { if err := utils.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email"})
@@ -172,6 +218,11 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
return return
} }
if !checkVerifyCode(req.Email, req.Code) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired verification code"})
return
}
var user models.User var user models.User
if err := utils.DB.Where("email = ?", req.Email).First(&user).Error; err != nil { if err := utils.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "User not found"}) c.JSON(http.StatusBadRequest, gin.H{"error": "User not found"})
@@ -261,6 +312,7 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) {
} }
code := utils.GenerateVerifyCode() code := utils.GenerateVerifyCode()
storeVerifyCode(user.Email, code)
utils.SendVerifyEmail(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)}) 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"})
}
+141
View File
@@ -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})
}
+7 -4
View File
@@ -1,7 +1,8 @@
package handlers package handlers
import ( import (
"math/rand" "crypto/rand"
"math/big"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -193,7 +194,6 @@ func (h *LotteryHandler) Draw(c *gin.Context) {
} }
var winners []models.LotteryWinner var winners []models.LotteryWinner
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
for _, prize := range lottery.Prizes { for _, prize := range lottery.Prizes {
remaining := prize.Quantity remaining := prize.Quantity
@@ -223,7 +223,10 @@ func (h *LotteryHandler) Draw(c *gin.Context) {
selected := make(map[uint]bool) selected := make(map[uint]bool)
for remaining > 0 && len(selected) < len(selectedParticipants) { 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 cumWeight := 0
for _, p := range selectedParticipants { for _, p := range selectedParticipants {
if selected[p.UserID] { if selected[p.UserID] {
@@ -234,7 +237,7 @@ func (h *LotteryHandler) Draw(c *gin.Context) {
w = 1 w = 1
} }
cumWeight += w cumWeight += w
if cumWeight > r { if cumWeight > int(r.Int64()) {
winner := models.LotteryWinner{ winner := models.LotteryWinner{
LotteryID: lottery.ID, LotteryID: lottery.ID,
PrizeID: prize.ID, PrizeID: prize.ID,
+67 -6
View File
@@ -230,7 +230,9 @@ func (h *OrderHandler) Create(c *gin.Context) {
PaymentMethod: req.PaymentMethod, 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order"})
return return
} }
@@ -238,20 +240,41 @@ func (h *OrderHandler) Create(c *gin.Context) {
for i := range orderItems { for i := range orderItems {
orderItems[i].OrderID = order.ID 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 { for _, cart := range carts {
if cart.Product.RequireCredit { if cart.Product.RequireCredit {
utils.DB.Model(&models.User{}).Where("id = ?", userID). if err := tx.Model(&models.User{}).Where("id = ?", userID).
UpdateColumn("purchase_credits", utils.DB.Raw("purchase_credits - ?", cart.Product.CreditCost*cart.Quantity)) 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 { 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)) 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) utils.DB.Preload("OrderItems.Product").Preload("ShippingAddress").First(&order, order.ID)
c.JSON(http.StatusCreated, gin.H{"data": order}) 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}) 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"})
}
+20 -4
View File
@@ -57,7 +57,7 @@ func (h *SystemHandler) GetStats(c *gin.Context) {
utils.DB.Model(&models.User{}).Count(&userCount) utils.DB.Model(&models.User{}).Count(&userCount)
utils.DB.Model(&models.Product{}).Count(&productCount) utils.DB.Model(&models.Product{}).Count(&productCount)
utils.DB.Model(&models.Order{}).Count(&orderCount) 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{ c.JSON(http.StatusOK, gin.H{
"data": gin.H{ "data": gin.H{
@@ -173,13 +173,29 @@ func (h *SupplierHandler) Update(c *gin.Context) {
return return
} }
var updateData map[string]interface{} var req struct {
if err := c.ShouldBindJSON(&updateData); err != nil { 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return 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}) c.JSON(http.StatusOK, gin.H{"data": supplier})
} }
+33
View File
@@ -164,3 +164,36 @@ func (h *TicketHandler) Assign(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Ticket assigned successfully"}) 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})
}
+24 -28
View File
@@ -2,12 +2,10 @@ package handlers
import ( import (
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -104,35 +102,33 @@ func (h *UploadHandler) DeleteImage(c *gin.Context) {
return return
} }
filepath := filepath.Join("uploads/images", filename) if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
if err := os.Remove(filepath); err != nil { 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"}) c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
return 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"}) 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
}
}
+19 -8
View File
@@ -5,8 +5,6 @@ import (
"sale/internal/api/handlers" "sale/internal/api/handlers"
"sale/internal/api/middlewares" "sale/internal/api/middlewares"
"sale/internal/models"
"sale/internal/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -26,6 +24,7 @@ func SetupRoutes(r *gin.Engine) {
inventoryHandler := handlers.NewInventoryHandler() inventoryHandler := handlers.NewInventoryHandler()
articleHandler := handlers.NewArticleHandler() articleHandler := handlers.NewArticleHandler()
uploadHandler := handlers.NewUploadHandler() uploadHandler := handlers.NewUploadHandler()
bannerHandler := handlers.NewBannerHandler()
r.Use(middlewares.CORSMiddleware()) r.Use(middlewares.CORSMiddleware())
@@ -40,7 +39,7 @@ func SetupRoutes(r *gin.Engine) {
auth.POST("/login", authHandler.Login) auth.POST("/login", authHandler.Login)
auth.POST("/forgot-password", authHandler.ForgotPassword) auth.POST("/forgot-password", authHandler.ForgotPassword)
auth.POST("/reset-password", authHandler.ResetPassword) auth.POST("/reset-password", authHandler.ResetPassword)
auth.GET("/verify-email", authHandler.VerifyEmail) auth.POST("/verify-email", authHandler.VerifyEmail)
} }
articles := api.Group("/articles") articles := api.Group("/articles")
@@ -51,6 +50,8 @@ func SetupRoutes(r *gin.Engine) {
articles.POST("/crawl-ribenyan", articleHandler.CrawlRibenyan) articles.POST("/crawl-ribenyan", articleHandler.CrawlRibenyan)
} }
api.GET("/banners", bannerHandler.List)
api.GET("/categories", categoryHandler.List) api.GET("/categories", categoryHandler.List)
api.GET("/brands", brandHandler.List) api.GET("/brands", brandHandler.List)
api.GET("/products", productHandler.List) api.GET("/products", productHandler.List)
@@ -97,6 +98,8 @@ func SetupRoutes(r *gin.Engine) {
orders.GET("/:id", orderHandler.GetByID) orders.GET("/:id", orderHandler.GetByID)
orders.POST("", orderHandler.Create) orders.POST("", orderHandler.Create)
orders.POST("/:id/refund", orderHandler.Refund) orders.POST("/:id/refund", orderHandler.Refund)
orders.PUT("/:id/cancel", orderHandler.CancelOrder)
orders.PUT("/:id/confirm-receipt", orderHandler.ConfirmReceipt)
} }
lotteries := authed.Group("/lotteries") lotteries := authed.Group("/lotteries")
@@ -109,6 +112,7 @@ func SetupRoutes(r *gin.Engine) {
tickets.GET("", ticketHandler.List) tickets.GET("", ticketHandler.List)
tickets.POST("", ticketHandler.Create) tickets.POST("", ticketHandler.Create)
tickets.GET("/:id", ticketHandler.GetByID) tickets.GET("/:id", ticketHandler.GetByID)
tickets.POST("/:id/reply", ticketHandler.Reply)
} }
supplier := authed.Group("/supplier") supplier := authed.Group("/supplier")
@@ -126,11 +130,9 @@ func SetupRoutes(r *gin.Engine) {
admin.Use(middlewares.RoleMiddleware("admin")) admin.Use(middlewares.RoleMiddleware("admin"))
{ {
admin.GET("/stats", systemHandler.GetStats) admin.GET("/stats", systemHandler.GetStats)
admin.GET("/users", func(c *gin.Context) { admin.GET("/users", authHandler.AdminGetUsers)
var users []models.User admin.PUT("/users/:id", authHandler.AdminUpdateUser)
utils.DB.Select("id, username, email, role, purchase_credits, is_active, created_at").Find(&users) admin.DELETE("/users/:id", authHandler.AdminDeleteUser)
c.JSON(200, gin.H{"data": users})
})
adminCategories := admin.Group("/categories") adminCategories := admin.Group("/categories")
{ {
@@ -203,6 +205,15 @@ func SetupRoutes(r *gin.Engine) {
adminArticles.DELETE("/:id", articleHandler.Delete) adminArticles.DELETE("/:id", articleHandler.Delete)
adminArticles.PUT("/:id/pin", articleHandler.TogglePin) 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)
}
} }
} }
} }
+26
View File
@@ -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"
}
+1
View File
@@ -10,6 +10,7 @@ type Lottery struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;not null" json:"name"` Name string `gorm:"size:100;not null" json:"name"`
Description string `json:"description"` Description string `json:"description"`
Image string `gorm:"size:500" json:"image"`
StartTime time.Time `json:"start_time"` StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"` EndTime time.Time `json:"end_time"`
Cycle string `gorm:"size:20" json:"cycle"` Cycle string `gorm:"size:20" json:"cycle"`
+1
View File
@@ -70,6 +70,7 @@ func AutoMigrate() {
&models.SystemSetting{}, &models.SystemSetting{},
&models.SupplierAuthorization{}, &models.SupplierAuthorization{},
&models.Article{}, &models.Article{},
&models.Banner{},
) )
if err != nil { if err != nil {
log.Fatalf("Failed to migrate database: %v", err) log.Fatalf("Failed to migrate database: %v", err)
+19 -10
View File
@@ -29,7 +29,7 @@ export const authApi = {
login: (data: any) => api.post('/auth/login', data), login: (data: any) => api.post('/auth/login', data),
forgotPassword: (data: any) => api.post('/auth/forgot-password', data), forgotPassword: (data: any) => api.post('/auth/forgot-password', data),
resetPassword: (data: any) => api.post('/auth/reset-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'), sendVerifyCode: () => api.post('/users/send-verify-code'),
} }
@@ -72,6 +72,8 @@ export const orderApi = {
getById: (id: number) => api.get(`/orders/${id}`), getById: (id: number) => api.get(`/orders/${id}`),
create: (data: any) => api.post('/orders', data), create: (data: any) => api.post('/orders', data),
refund: (id: number, data: any) => api.post(`/orders/${id}/refund`, 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 = { export const articleApi = {
@@ -79,6 +81,10 @@ export const articleApi = {
getById: (id: number) => api.get(`/articles/${id}`), getById: (id: number) => api.get(`/articles/${id}`),
} }
export const bannerApi = {
list: () => api.get('/banners'),
}
export const lotteryApi = { export const lotteryApi = {
list: () => api.get('/lotteries'), list: () => api.get('/lotteries'),
getById: (id: number) => api.get(`/lotteries/${id}`), getById: (id: number) => api.get(`/lotteries/${id}`),
@@ -89,10 +95,7 @@ export const ticketApi = {
list: (params?: any) => api.get('/tickets', { params }), list: (params?: any) => api.get('/tickets', { params }),
create: (data: any) => api.post('/tickets', data), create: (data: any) => api.post('/tickets', data),
getById: (id: number) => api.get(`/tickets/${id}`), getById: (id: number) => api.get(`/tickets/${id}`),
} reply: (id: number, data: any) => api.post(`/tickets/${id}/reply`, data),
export const menuApi = {
list: () => api.get('/menus'),
} }
export const supplierApi = { export const supplierApi = {
@@ -105,6 +108,11 @@ export const supplierApi = {
} }
export const adminApi = { 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'), getCategories: () => api.get('/categories'),
createCategory: (data: any) => api.post('/admin/categories', data), createCategory: (data: any) => api.post('/admin/categories', data),
updateCategory: (id: number, data: any) => api.put(`/admin/categories/${id}`, 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), updateSMTP: (data: any) => api.put('/admin/settings/smtp', data),
updatePayment: (data: any) => api.put('/admin/settings/payment', 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'), getInventory: () => api.get('/admin/inventory'),
getArticles: (params?: any) => api.get('/admin/articles', { params }), 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), updateArticle: (id: number, data: any) => api.put(`/admin/articles/${id}`, data),
deleteArticle: (id: number) => api.delete(`/admin/articles/${id}`), deleteArticle: (id: number) => api.delete(`/admin/articles/${id}`),
togglePinArticle: (id: number) => api.put(`/admin/articles/${id}/pin`), 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`),
} }
+2
View File
@@ -123,6 +123,8 @@
"supplierManagement": "Supplier Management", "supplierManagement": "Supplier Management",
"lotteryManagement": "Lottery Management", "lotteryManagement": "Lottery Management",
"ticketManagement": "Ticket Management", "ticketManagement": "Ticket Management",
"articleManagement": "Article Management",
"bannerManagement": "Banner Management",
"systemSettings": "System Settings", "systemSettings": "System Settings",
"menuManagement": "Menu Management", "menuManagement": "Menu Management",
"smtpSettings": "SMTP Settings", "smtpSettings": "SMTP Settings",
+2
View File
@@ -123,6 +123,8 @@
"supplierManagement": "サプライヤー管理", "supplierManagement": "サプライヤー管理",
"lotteryManagement": "抽選管理", "lotteryManagement": "抽選管理",
"ticketManagement": "チケット管理", "ticketManagement": "チケット管理",
"articleManagement": "記事管理",
"bannerManagement": "バナー管理",
"systemSettings": "システム設定", "systemSettings": "システム設定",
"menuManagement": "メニュー管理", "menuManagement": "メニュー管理",
"smtpSettings": "SMTP設定", "smtpSettings": "SMTP設定",
+2
View File
@@ -123,6 +123,8 @@
"supplierManagement": "供货商管理", "supplierManagement": "供货商管理",
"lotteryManagement": "抽奖管理", "lotteryManagement": "抽奖管理",
"ticketManagement": "工单管理", "ticketManagement": "工单管理",
"articleManagement": "资讯管理",
"bannerManagement": "轮播图管理",
"systemSettings": "系统设置", "systemSettings": "系统设置",
"menuManagement": "菜单管理", "menuManagement": "菜单管理",
"smtpSettings": "SMTP设置", "smtpSettings": "SMTP设置",
+173 -8
View File
@@ -47,7 +47,11 @@
</router-link> </router-link>
<router-link to="/admin/articles" class="nav-link"> <router-link to="/admin/articles" class="nav-link">
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
<span class="nav-text">资讯管理</span> <span class="nav-text">{{ $t('admin.articleManagement') }}</span>
</router-link>
<router-link to="/admin/banners" class="nav-link">
<el-icon><Picture /></el-icon>
<span class="nav-text">{{ $t('admin.bannerManagement') }}</span>
</router-link> </router-link>
<router-link to="/admin/settings" class="nav-link"> <router-link to="/admin/settings" class="nav-link">
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
@@ -95,7 +99,45 @@
<button class="menu-btn" @click="mobileMenuOpen = !mobileMenuOpen"> <button class="menu-btn" @click="mobileMenuOpen = !mobileMenuOpen">
<el-icon><Expand /></el-icon> <el-icon><Expand /></el-icon>
</button> </button>
<h1 class="page-title">{{ currentPageTitle }}</h1> <nav class="breadcrumb">
<span class="breadcrumb-item" @click="$router.push('/admin')">
<el-icon><HomeFilled /></el-icon>
<span>首页</span>
</span>
<el-icon class="breadcrumb-sep"><ArrowRight /></el-icon>
<span class="breadcrumb-current">{{ currentPageTitle }}</span>
</nav>
<div class="topbar-actions">
<el-tooltip content="返回前台" placement="bottom">
<button class="action-btn" @click="router.push('/')">
<el-icon><HomeFilled /></el-icon>
</button>
</el-tooltip>
<el-tooltip content="全屏" placement="bottom">
<button class="action-btn" @click="toggleFullscreen">
<el-icon><FullScreen /></el-icon>
</button>
</el-tooltip>
<el-dropdown trigger="click" @command="handleCommand" placement="bottom-end">
<div class="user-dropdown">
<el-avatar :size="32" class="user-avatar">{{ user?.username?.charAt(0).toUpperCase() }}</el-avatar>
<span class="user-name">{{ user?.username }}</span>
<el-icon class="dropdown-arrow"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="home">
<el-icon><HomeFilled /></el-icon>
返回前台
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header> </header>
<main class="page-content"> <main class="page-content">
<router-view /> <router-view />
@@ -110,13 +152,15 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' 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 { useUserStore } from '../store/user'
import { useCartStore } from '../store/cart'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { locale, t } = useI18n() const { locale, t } = useI18n()
const userStore = useUserStore() const userStore = useUserStore()
const cartStore = useCartStore()
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)
@@ -132,6 +176,7 @@ const pageTitles: Record<string, string> = {
'/admin/lotteries': '抽奖管理', '/admin/lotteries': '抽奖管理',
'/admin/tickets': '工单管理', '/admin/tickets': '工单管理',
'/admin/articles': '资讯管理', '/admin/articles': '资讯管理',
'/admin/banners': '轮播图管理',
'/admin/settings': '系统设置' '/admin/settings': '系统设置'
} }
@@ -147,7 +192,15 @@ function changeLocale(lang: string) {
function handleCommand(command: string) { function handleCommand(command: string) {
switch (command) { switch (command) {
case 'home': router.push('/'); break 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()
} }
} }
</script> </script>
@@ -328,11 +381,99 @@ function handleCommand(command: string) {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
} }
.page-title { .breadcrumb {
font-size: 18px; display: flex;
font-weight: 600; 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; 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 { .page-content {
@@ -364,10 +505,30 @@ function handleCommand(command: string) {
margin-left: 0; margin-left: 0;
} }
.topbar {
padding: 0 12px;
}
.menu-btn { .menu-btn {
display: flex; display: flex;
} }
.breadcrumb-item span {
display: none;
}
.user-name {
display: none;
}
.dropdown-arrow {
display: none;
}
.user-dropdown {
padding: 4px;
}
.collapse-btn { .collapse-btn {
display: none; display: none;
} }
@@ -379,5 +540,9 @@ function handleCommand(command: string) {
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
z-index: 99; z-index: 99;
} }
.page-content {
padding: 16px 12px;
}
} }
</style> </style>
+3 -1
View File
@@ -70,11 +70,13 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { DataAnalysis, List, Box, Fold, Expand } from '@element-plus/icons-vue' import { DataAnalysis, List, Box, Fold, Expand } from '@element-plus/icons-vue'
import { useUserStore } from '../store/user' import { useUserStore } from '../store/user'
import { useCartStore } from '../store/cart'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
const userStore = useUserStore() const userStore = useUserStore()
const cartStore = useCartStore()
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)
@@ -93,7 +95,7 @@ const currentPageTitle = computed(() => {
function handleCommand(command: string) { function handleCommand(command: string) {
switch (command) { switch (command) {
case 'home': router.push('/'); break case 'home': router.push('/'); break
case 'logout': userStore.logout(); router.push('/login'); break case 'logout': userStore.logout(); cartStore.clearCart(); router.push('/login'); break
} }
} }
</script> </script>
@@ -117,8 +117,8 @@ import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { HomeFilled, Goods, Document, Trophy, ShoppingCart, Compass, User, Fold, Expand, Search } from '@element-plus/icons-vue' import { HomeFilled, Goods, Document, Trophy, ShoppingCart, Compass, User, Fold, Expand, Search } from '@element-plus/icons-vue'
import { useUserStore } from '../../store/user' import { useUserStore } from '../store/user'
import { useCartStore } from '../../store/cart' import { useCartStore } from '../store/cart'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -147,7 +147,7 @@ function handleCommand(command: string) {
case 'orders': router.push('/orders'); break case 'orders': router.push('/orders'); break
case 'admin': router.push('/admin'); break case 'admin': router.push('/admin'); break
case 'supplier': router.push('/supplier'); 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) { @media (max-width: 768px) {
.layout-wrapper {
width: 100%;
overflow-x: hidden;
}
.sidebar { .sidebar {
transform: translateX(-100%); transform: translateX(-100%);
width: 220px !important; width: 220px !important;
@@ -458,6 +463,9 @@ if (isLoggedIn.value) cartStore.fetchCart()
.main-container, .main-container,
.main-container.sidebar-collapsed { .main-container.sidebar-collapsed {
margin-left: 0; margin-left: 0;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
} }
.menu-btn { .menu-btn {
@@ -468,6 +476,14 @@ if (isLoggedIn.value) cartStore.fetchCart()
display: none; display: none;
} }
.page-content {
padding: 16px;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
box-sizing: border-box;
}
.overlay { .overlay {
display: block; display: block;
position: fixed; position: fixed;
+9 -2
View File
@@ -10,9 +10,9 @@ const routes = [
}, },
{ {
path: '/', path: '/',
component: () => import('../layouts/scheme4/UserLayout.vue'), component: () => import('../layouts/UserLayout.vue'),
children: [ 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', name: 'Products', component: () => import('../views/user/Products.vue') },
{ path: 'products/:id', name: 'ProductDetail', component: () => import('../views/user/ProductDetail.vue') }, { path: 'products/:id', name: 'ProductDetail', component: () => import('../views/user/ProductDetail.vue') },
{ path: 'cart', name: 'Cart', component: () => import('../views/user/Cart.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: 'lotteries/:id', name: 'LotteryDetail', component: () => import('../views/user/LotteryDetail.vue') },
{ path: 'tickets', name: 'Tickets', component: () => import('../views/user/Tickets.vue') }, { path: 'tickets', name: 'Tickets', component: () => import('../views/user/Tickets.vue') },
{ path: 'tickets/create', name: 'CreateTicket', component: () => import('../views/user/CreateTicket.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', name: 'Articles', component: () => import('../views/user/Articles.vue') },
{ path: 'articles/:id', name: 'ArticleDetail', component: () => import('../views/user/ArticleDetail.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 } }, { 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: 'tickets', name: 'AdminTickets', component: () => import('../views/admin/Tickets.vue') },
{ path: 'settings', name: 'AdminSettings', component: () => import('../views/admin/Settings.vue') }, { path: 'settings', name: 'AdminSettings', component: () => import('../views/admin/Settings.vue') },
{ path: 'articles', name: 'AdminArticles', component: () => import('../views/admin/Articles.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: 'inventory', name: 'SupplierInventory', component: () => import('../views/supplier/Inventory.vue') },
], ],
}, },
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('../views/NotFound.vue'),
},
] ]
const router = createRouter({ const router = createRouter({
+9 -5
View File
@@ -13,7 +13,6 @@ export const useCartStore = defineStore('cart', () => {
try { try {
const res: any = await cartApi.list() const res: any = await cartApi.list()
items.value = res.data || [] items.value = res.data || []
calcTotals()
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -35,12 +34,16 @@ export const useCartStore = defineStore('cart', () => {
} }
async function removeItems(ids: number[]) { async function removeItems(ids: number[]) {
for (const id of ids) { await Promise.all(ids.map(id => cartApi.delete(id)))
await cartApi.delete(id)
}
await fetchCart() await fetchCart()
} }
function clearCart() {
items.value = []
totalAmount.value = 0
totalCount.value = 0
}
function calcTotals() { function calcTotals() {
totalAmount.value = items.value.reduce((sum, item) => sum + (item.product?.price || 0) * item.quantity, 0) 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) totalCount.value = items.value.reduce((sum, item) => sum + item.quantity, 0)
@@ -57,6 +60,7 @@ export const useCartStore = defineStore('cart', () => {
addItem, addItem,
updateItem, updateItem,
removeItem, removeItem,
removeItems removeItems,
clearCart
} }
}) })
+10 -2
View File
@@ -2,10 +2,18 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { authApi, userApi } from '../api' 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', () => { export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '') const token = ref(localStorage.getItem('token') || '')
const userStr = localStorage.getItem('user') const user = ref<any>(safeParseUser(localStorage.getItem('user')))
const user = ref<any>(userStr && userStr !== 'undefined' ? JSON.parse(userStr) : null)
const isLoggedIn = computed(() => !!token.value) const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin') const isAdmin = computed(() => user.value?.role === 'admin')
+3 -1
View File
@@ -1,5 +1,6 @@
import axios from 'axios' import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import router from '../router'
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api', 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('token')
localStorage.removeItem('user') localStorage.removeItem('user')
if (hadToken) { if (hadToken) {
window.location.href = '/login' ElMessage.error('登录已过期,请重新登录')
router.push('/login')
} }
} else { } else {
ElMessage.error(message) ElMessage.error(message)
+43
View File
@@ -0,0 +1,43 @@
<template>
<div class="not-found">
<div class="error-code">404</div>
<h2>页面不存在</h2>
<p>您访问的页面不存在或已被移除</p>
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.not-found {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.error-code {
font-size: 120px;
font-weight: 700;
color: rgba(78, 110, 242, 0.3);
line-height: 1;
margin-bottom: 16px;
}
h2 {
font-size: 24px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 8px;
}
p {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 24px;
}
</style>
+81 -3
View File
@@ -1,7 +1,6 @@
<template> <template>
<div class="articles-page"> <div class="articles-page">
<div class="page-header"> <div class="page-header">
<h2 class="page-title">资讯管理</h2>
<el-button type="primary" @click="showAdd = true">创建资讯</el-button> <el-button type="primary" @click="showAdd = true">创建资讯</el-button>
</div> </div>
<div class="table-card"> <div class="table-card">
@@ -40,7 +39,21 @@
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item> <el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
<el-form-item label="摘要"><el-input v-model="form.summary" type="textarea" :rows="2" /></el-form-item> <el-form-item label="摘要"><el-input v-model="form.summary" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="内容"><el-input v-model="form.content" type="textarea" :rows="10" /></el-form-item> <el-form-item label="内容"><el-input v-model="form.content" type="textarea" :rows="10" /></el-form-item>
<el-form-item label="封面图"><el-input v-model="form.cover_image" placeholder="URL" /></el-form-item> <el-form-item label="封面图">
<el-upload
class="cover-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleCoverSuccess"
:before-upload="beforeCoverUpload"
accept="image/*"
>
<img v-if="form.cover_image" :src="form.cover_image" class="cover-image" />
<el-icon v-else class="cover-uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="cover-tip">建议尺寸: 800x400px支持 jpgpngwebp 格式</div>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="form.sort_order" /></el-form-item> <el-form-item label="排序"><el-input-number v-model="form.sort_order" /></el-form-item>
<el-form-item label="发布"><el-switch v-model="form.is_published" /></el-form-item> <el-form-item label="发布"><el-switch v-model="form.is_published" /></el-form-item>
<el-form-item label="置顶"><el-switch v-model="form.is_pinned" /></el-form-item> <el-form-item label="置顶"><el-switch v-model="form.is_pinned" /></el-form-item>
@@ -56,8 +69,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { adminApi } from '../../api' import { adminApi } from '../../api'
const uploadUrl = import.meta.env.VITE_API_URL + '/upload'
const uploadHeaders = { Authorization: `Bearer ${localStorage.getItem('token')}` }
const articles = ref<any[]>([]) const articles = ref<any[]>([])
const showAdd = ref(false) const showAdd = ref(false)
const editing = ref<any>(null) const editing = ref<any>(null)
@@ -74,6 +91,30 @@ function handlePageChange() {
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
function beforeCoverUpload(file: File) {
const isImage = file.type.startsWith('image/')
const isLt5M = file.size / 1024 / 1024 < 5
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB!')
return false
}
return true
}
function handleCoverSuccess(response: any) {
if (response.url) {
form.cover_image = response.url
ElMessage.success('封面上传成功')
} else {
ElMessage.error('上传失败')
}
}
function formatDate(date: string) { function formatDate(date: string) {
if (!date) return '-' if (!date) return '-'
const d = new Date(date) const d = new Date(date)
@@ -127,9 +168,46 @@ onMounted(fetchArticles)
<style scoped lang="scss"> <style scoped lang="scss">
.articles-page { padding: 0; } .articles-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; } .table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
.cover-uploader {
:deep(.el-upload) {
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
width: 200px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: #4e6ef2;
}
}
}
.cover-image {
width: 200px;
height: 120px;
object-fit: cover;
border-radius: 8px;
}
.cover-uploader-icon {
font-size: 28px;
color: rgba(255, 255, 255, 0.4);
}
.cover-tip {
margin-top: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
.pagination-wrap { .pagination-wrap {
margin-top: 20px; margin-top: 20px;
display: flex; display: flex;
+331
View File
@@ -0,0 +1,331 @@
<template>
<div class="banners-page">
<div class="page-header">
<el-button type="primary" @click="openAdd">添加轮播图</el-button>
</div>
<div class="table-card">
<el-table :data="banners">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="图片" width="150">
<template #default="{ row }">
<img :src="getImageUrl(row.image)" style="width: 120px; height: 60px; object-fit: cover; border-radius: 4px;" />
</template>
</el-table-column>
<el-table-column prop="title" label="标题" />
<el-table-column prop="desc" label="描述" />
<el-table-column prop="button" label="按钮文字" width="120" />
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="editBanner(row)">编辑</el-button>
<el-button link :type="row.is_active ? 'warning' : 'success'" size="small" @click="toggleStatus(row)">
{{ row.is_active ? '禁用' : '启用' }}
</el-button>
<el-button link type="danger" size="small" @click="deleteBanner(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog v-model="showAdd" :title="editing ? '编辑轮播图' : '添加轮播图'" width="600px">
<el-form :model="form" label-width="100px">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.desc" type="textarea" :rows="2" placeholder="请输入描述" />
</el-form-item>
<el-form-item label="图片" required>
<el-upload
class="banner-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImageSuccess"
:before-upload="beforeImageUpload"
accept="image/*"
>
<img v-if="form.image" :src="form.image" class="banner-image" />
<el-icon v-else class="banner-uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="image-tip">建议尺寸: 1920x400px支持 jpgpngwebp 格式</div>
</el-form-item>
<el-form-item label="链接类型">
<el-radio-group v-model="linkType" @change="handleLinkTypeChange">
<el-radio-button value="custom">自定义</el-radio-button>
<el-radio-button value="product">商品</el-radio-button>
<el-radio-button value="article">资讯</el-radio-button>
<el-radio-button value="lottery">活动</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="链接" v-if="linkType === 'custom'">
<el-input v-model="form.link" placeholder="点击跳转链接,如: /products" />
</el-form-item>
<el-form-item label="选择商品" v-if="linkType === 'product'">
<el-select v-model="selectedProduct" placeholder="请选择商品" filterable @change="handleProductSelect" style="width: 100%">
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="选择资讯" v-if="linkType === 'article'">
<el-select v-model="selectedArticle" placeholder="请选择资讯" filterable @change="handleArticleSelect" style="width: 100%">
<el-option v-for="a in articles" :key="a.id" :label="a.title" :value="a.id" />
</el-select>
</el-form-item>
<el-form-item label="选择活动" v-if="linkType === 'lottery'">
<el-select v-model="selectedLottery" placeholder="请选择活动" filterable @change="handleLotterySelect" style="width: 100%">
<el-option v-for="l in lotteries" :key="l.id" :label="l.name" :value="l.id" />
</el-select>
</el-form-item>
<el-form-item label="按钮文字">
<el-input v-model="form.button" placeholder="如: 立即选购" />
</el-form-item>
<el-form-item label="背景颜色">
<el-input v-model="form.bg_color" placeholder="如: linear-gradient(135deg, #4e6ef2 0%, #7c5cfc 100%)" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" :min="0" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAdd = false">取消</el-button>
<el-button type="primary" @click="saveBanner">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { adminApi, productApi, articleApi, lotteryApi } from '../../api'
import { getImageUrl } from '../../utils/image'
const uploadUrl = import.meta.env.VITE_API_URL + '/upload'
const uploadHeaders = { Authorization: `Bearer ${localStorage.getItem('token')}` }
const banners = ref<any[]>([])
const products = ref<any[]>([])
const articles = ref<any[]>([])
const lotteries = ref<any[]>([])
const showAdd = ref(false)
const editing = ref<any>(null)
const linkType = ref('custom')
const selectedProduct = ref<number | null>(null)
const selectedArticle = ref<number | null>(null)
const selectedLottery = ref<number | null>(null)
const form = reactive({
title: '',
desc: '',
image: '',
link: '',
button: '',
bg_color: '',
sort_order: 0,
is_active: true
})
async function fetchBanners() {
const res: any = await adminApi.getBanners()
banners.value = res.data || []
}
async function fetchProducts() {
const res: any = await productApi.list({ page: 1, page_size: 100 })
products.value = res.data || []
}
async function fetchArticles() {
const res: any = await articleApi.list({ page: 1, page_size: 100 })
articles.value = res.data || []
}
async function fetchLotteries() {
const res: any = await lotteryApi.list()
lotteries.value = res.data || []
}
function handleLinkTypeChange() {
form.link = ''
selectedProduct.value = null
selectedArticle.value = null
selectedLottery.value = null
}
function handleProductSelect(productId: number) {
form.link = `/products/${productId}`
}
function handleArticleSelect(articleId: number) {
form.link = `/articles/${articleId}`
}
function handleLotterySelect(lotteryId: number) {
form.link = `/lotteries/${lotteryId}`
}
function openAdd() {
editing.value = null
linkType.value = 'custom'
selectedProduct.value = null
selectedArticle.value = null
selectedLottery.value = null
Object.assign(form, { title: '', desc: '', image: '', link: '', button: '', bg_color: '', sort_order: 0, is_active: true })
showAdd.value = true
}
function editBanner(row: any) {
editing.value = row
Object.assign(form, {
title: row.title,
desc: row.desc || '',
image: row.image || '',
link: row.link || '',
button: row.button || '',
bg_color: row.bg_color || '',
sort_order: row.sort_order,
is_active: row.is_active
})
// 根据链接判断类型
if (row.link) {
if (row.link.startsWith('/products/')) {
linkType.value = 'product'
const id = parseInt(row.link.split('/')[2])
selectedProduct.value = isNaN(id) ? null : id
} else if (row.link.startsWith('/articles/')) {
linkType.value = 'article'
const id = parseInt(row.link.split('/')[2])
selectedArticle.value = isNaN(id) ? null : id
} else if (row.link.startsWith('/lotteries/')) {
linkType.value = 'lottery'
const id = parseInt(row.link.split('/')[2])
selectedLottery.value = isNaN(id) ? null : id
} else {
linkType.value = 'custom'
}
} else {
linkType.value = 'custom'
}
showAdd.value = true
}
function beforeImageUpload(file: File) {
const isImage = file.type.startsWith('image/')
const isLt5M = file.size / 1024 / 1024 < 5
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB!')
return false
}
return true
}
function handleImageSuccess(response: any) {
if (response.url) {
form.image = response.url
ElMessage.success('图片上传成功')
} else {
ElMessage.error('上传失败')
}
}
async function saveBanner() {
if (!form.title || !form.image) {
ElMessage.warning('请填写必填项')
return
}
if (editing.value) {
await adminApi.updateBanner(editing.value.id, form)
} else {
await adminApi.createBanner(form)
}
ElMessage.success('保存成功')
showAdd.value = false
editing.value = null
await fetchBanners()
}
async function toggleStatus(row: any) {
await adminApi.toggleBanner(row.id)
ElMessage.success('操作成功')
await fetchBanners()
}
async function deleteBanner(id: number) {
await ElMessageBox.confirm('确定删除此轮播图?', '确认删除')
await adminApi.deleteBanner(id)
ElMessage.success('删除成功')
await fetchBanners()
}
onMounted(async () => {
await Promise.all([
fetchBanners(),
fetchProducts(),
fetchArticles(),
fetchLotteries()
])
})
</script>
<style scoped lang="scss">
.banners-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
.banner-uploader {
:deep(.el-upload) {
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
width: 300px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: #4e6ef2;
}
}
}
.banner-image {
width: 300px;
height: 120px;
object-fit: cover;
border-radius: 8px;
}
.banner-uploader-icon {
font-size: 28px;
color: rgba(255, 255, 255, 0.4);
}
.image-tip {
margin-top: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
</style>
-2
View File
@@ -1,7 +1,6 @@
<template> <template>
<div class="categories-page"> <div class="categories-page">
<div class="page-header"> <div class="page-header">
<h2 class="page-title">分类管理</h2>
<el-button type="primary" @click="openAdd">创建分类</el-button> <el-button type="primary" @click="openAdd">创建分类</el-button>
</div> </div>
<div class="table-card"> <div class="table-card">
@@ -114,7 +113,6 @@ onMounted(fetchCategories)
<style scoped lang="scss"> <style scoped lang="scss">
.categories-page { padding: 0; } .categories-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; } .table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
.pagination-wrap { .pagination-wrap {
+67 -38
View File
@@ -1,57 +1,48 @@
<template> <template>
<div class="dashboard-page"> <div class="dashboard-page">
<h2 class="page-title">管理后台</h2> <div class="stat-cards">
<el-row :gutter="16"> <div class="stat-card">
<el-col :span="6"> <div class="stat-icon users"><el-icon><User /></el-icon></div>
<div class="stat-card"> <div class="stat-info">
<div class="stat-icon users"><el-icon><User /></el-icon></div> <p class="stat-value">{{ stats.users }}</p>
<div class="stat-info"> <p class="stat-label">用户数</p>
<p class="stat-value">{{ stats.users }}</p>
<p class="stat-label">用户数</p>
</div>
</div> </div>
</el-col> </div>
<el-col :span="6"> <div class="stat-card">
<div class="stat-card"> <div class="stat-icon products"><el-icon><Goods /></el-icon></div>
<div class="stat-icon products"><el-icon><Goods /></el-icon></div> <div class="stat-info">
<div class="stat-info"> <p class="stat-value">{{ stats.products }}</p>
<p class="stat-value">{{ stats.products }}</p> <p class="stat-label">商品数</p>
<p class="stat-label">商品数</p>
</div>
</div> </div>
</el-col> </div>
<el-col :span="6"> <div class="stat-card">
<div class="stat-card"> <div class="stat-icon orders"><el-icon><List /></el-icon></div>
<div class="stat-icon orders"><el-icon><List /></el-icon></div> <div class="stat-info">
<div class="stat-info"> <p class="stat-value">{{ stats.orders }}</p>
<p class="stat-value">{{ stats.orders }}</p> <p class="stat-label">订单数</p>
<p class="stat-label">订单数</p>
</div>
</div> </div>
</el-col> </div>
<el-col :span="6"> <div class="stat-card">
<div class="stat-card"> <div class="stat-icon revenue"><el-icon><Money /></el-icon></div>
<div class="stat-icon revenue"><el-icon><Money /></el-icon></div> <div class="stat-info">
<div class="stat-info"> <p class="stat-value">{{ stats.revenue }}</p>
<p class="stat-value">{{ stats.revenue }}</p> <p class="stat-label">营收</p>
<p class="stat-label">营收</p>
</div>
</div> </div>
</el-col> </div>
</el-row> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { User, Goods, List, Money } from '@element-plus/icons-vue' import { User, Goods, List, Money } from '@element-plus/icons-vue'
import api from '../../utils/request' import { adminApi } from '../../api'
const stats = ref({ users: '--', products: '--', orders: '--', revenue: '--' }) const stats = ref({ users: '--', products: '--', orders: '--', revenue: '--' })
onMounted(async () => { onMounted(async () => {
try { try {
const res: any = await api.get('/admin/stats') const res: any = await adminApi.getStats()
if (res.data) { if (res.data) {
stats.value.users = res.data.users || 0 stats.value.users = res.data.users || 0
stats.value.products = res.data.products || 0 stats.value.products = res.data.products || 0
@@ -66,7 +57,13 @@ onMounted(async () => {
<style scoped lang="scss"> <style scoped lang="scss">
.dashboard-page { padding: 0; } .dashboard-page { padding: 0; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; margin-bottom: 24px; }
.stat-cards {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.stat-card { .stat-card {
background: #2d2d44; background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.06);
@@ -75,7 +72,10 @@ onMounted(async () => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
flex: 1 1 200px;
min-width: 200px;
} }
.stat-icon { .stat-icon {
width: 48px; width: 48px;
height: 48px; height: 48px;
@@ -84,6 +84,7 @@ onMounted(async () => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 24px; font-size: 24px;
flex-shrink: 0;
} }
.stat-icon.users { background: rgba(78, 110, 242, 0.15); color: #4e6ef2; } .stat-icon.users { background: rgba(78, 110, 242, 0.15); color: #4e6ef2; }
.stat-icon.products { background: rgba(16, 185, 129, 0.15); color: #10b981; } .stat-icon.products { background: rgba(16, 185, 129, 0.15); color: #10b981; }
@@ -92,4 +93,32 @@ onMounted(async () => {
.stat-info { flex: 1; } .stat-info { flex: 1; }
.stat-value { font-size: 28px; font-weight: 700; color: #fff; } .stat-value { font-size: 28px; font-weight: 700; color: #fff; }
.stat-label { font-size: 14px; color: rgba(255, 255, 255, 0.5); margin-top: 4px; } .stat-label { font-size: 14px; color: rgba(255, 255, 255, 0.5); margin-top: 4px; }
@media (max-width: 768px) {
.stat-cards {
gap: 12px;
}
.stat-card {
padding: 16px;
gap: 12px;
flex: 1 1 calc(50% - 6px);
min-width: 140px;
}
.stat-icon {
width: 40px;
height: 40px;
font-size: 20px;
border-radius: 10px;
}
.stat-value {
font-size: 22px;
}
.stat-label {
font-size: 12px;
}
}
</style> </style>
+84 -3
View File
@@ -1,7 +1,6 @@
<template> <template>
<div class="lotteries-page"> <div class="lotteries-page">
<div class="page-header"> <div class="page-header">
<h2 class="page-title">抽奖管理</h2>
<el-button type="primary" @click="openAdd">创建抽奖</el-button> <el-button type="primary" @click="openAdd">创建抽奖</el-button>
</div> </div>
<div class="table-card"> <div class="table-card">
@@ -45,6 +44,21 @@
<el-form-item label="抽奖描述"> <el-form-item label="抽奖描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入抽奖描述" /> <el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入抽奖描述" />
</el-form-item> </el-form-item>
<el-form-item label="活动图片">
<el-upload
class="lottery-image-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImageSuccess"
:before-upload="beforeImageUpload"
accept="image/*"
>
<img v-if="form.image" :src="form.image" class="lottery-image" />
<el-icon v-else class="lottery-uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="image-tip">建议尺寸: 400x300px支持 jpgpngwebp 格式</div>
</el-form-item>
<el-form-item label="开始时间" required> <el-form-item label="开始时间" required>
<el-date-picker <el-date-picker
v-model="form.start_time" v-model="form.start_time"
@@ -142,8 +156,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { adminApi } from '../../api' import { adminApi } from '../../api'
const uploadUrl = import.meta.env.VITE_API_URL + '/upload'
const uploadHeaders = { Authorization: `Bearer ${localStorage.getItem('token')}` }
const lotteries = ref<any[]>([]) const lotteries = ref<any[]>([])
const showAdd = ref(false) const showAdd = ref(false)
const showPrize = ref(false) const showPrize = ref(false)
@@ -159,6 +177,7 @@ const paginatedLotteries = computed(() => {
const form = reactive({ const form = reactive({
name: '', name: '',
description: '', description: '',
image: '',
start_time: '', start_time: '',
end_time: '', end_time: '',
cycle: 'daily', cycle: 'daily',
@@ -186,6 +205,30 @@ function handlePageChange() {
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
function beforeImageUpload(file: File) {
const isImage = file.type.startsWith('image/')
const isLt5M = file.size / 1024 / 1024 < 5
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB!')
return false
}
return true
}
function handleImageSuccess(response: any) {
if (response.url) {
form.image = response.url
ElMessage.success('图片上传成功')
} else {
ElMessage.error('上传失败')
}
}
async function fetchLotteries() { async function fetchLotteries() {
const res: any = await adminApi.getLotteries() const res: any = await adminApi.getLotteries()
lotteries.value = res.data || [] lotteries.value = res.data || []
@@ -193,7 +236,7 @@ async function fetchLotteries() {
function openAdd() { function openAdd() {
editing.value = null editing.value = null
Object.assign(form, { name: '', description: '', start_time: '', end_time: '', cycle: 'daily', daily_quota: undefined, total_quota: undefined, registration_validity: undefined }) Object.assign(form, { name: '', description: '', image: '', start_time: '', end_time: '', cycle: 'daily', daily_quota: undefined, total_quota: undefined, registration_validity: undefined })
showAdd.value = true showAdd.value = true
} }
@@ -202,6 +245,7 @@ function editLottery(row: any) {
Object.assign(form, { Object.assign(form, {
name: row.name, name: row.name,
description: row.description || '', description: row.description || '',
image: row.image || '',
start_time: row.start_time, start_time: row.start_time,
end_time: row.end_time, end_time: row.end_time,
cycle: row.cycle || 'daily', cycle: row.cycle || 'daily',
@@ -267,9 +311,46 @@ onMounted(fetchLotteries)
<style scoped lang="scss"> <style scoped lang="scss">
.lotteries-page { padding: 0; } .lotteries-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; } .table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
.lottery-image-uploader {
:deep(.el-upload) {
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
width: 200px;
height: 150px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: #4e6ef2;
}
}
}
.lottery-image {
width: 200px;
height: 150px;
object-fit: cover;
border-radius: 8px;
}
.lottery-uploader-icon {
font-size: 28px;
color: rgba(255, 255, 255, 0.4);
}
.image-tip {
margin-top: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
.pagination-wrap { .pagination-wrap {
margin-top: 20px; margin-top: 20px;
display: flex; display: flex;
-2
View File
@@ -1,7 +1,6 @@
<template> <template>
<div class="orders-page"> <div class="orders-page">
<div class="page-header"> <div class="page-header">
<h2 class="page-title">订单管理</h2>
<el-button @click="exportOrders">导出订单</el-button> <el-button @click="exportOrders">导出订单</el-button>
</div> </div>
<div class="table-card"> <div class="table-card">
@@ -91,7 +90,6 @@ onMounted(fetchOrders)
<style scoped lang="scss"> <style scoped lang="scss">
.orders-page { padding: 0; } .orders-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; } .table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
.pagination-wrap { .pagination-wrap {
-2
View File
@@ -1,7 +1,6 @@
<template> <template>
<div class="products-page"> <div class="products-page">
<div class="page-header"> <div class="page-header">
<h2 class="page-title">商品管理</h2>
<el-button type="primary" @click="openAdd">创建商品</el-button> <el-button type="primary" @click="openAdd">创建商品</el-button>
</div> </div>
<div class="table-card"> <div class="table-card">
@@ -354,7 +353,6 @@ onMounted(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.products-page { padding: 0; } .products-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; } .table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
.table-image-placeholder { width: 50px; height: 50px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.04); border-radius: 4px; color: rgba(255,255,255,0.3); } .table-image-placeholder { width: 50px; height: 50px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.04); border-radius: 4px; color: rgba(255,255,255,0.3); }
.upload-tip { font-size: 12px; color: rgba(255,255,255,0.4); margin-top: 8px; } .upload-tip { font-size: 12px; color: rgba(255,255,255,0.4); margin-top: 8px; }
-5
View File
@@ -1,8 +1,5 @@
<template> <template>
<div class="settings-page"> <div class="settings-page">
<div class="page-header">
<h2 class="page-title">系统设置</h2>
</div>
<div class="settings-card"> <div class="settings-card">
<el-tabs> <el-tabs>
<el-tab-pane label="基础设置"> <el-tab-pane label="基础设置">
@@ -116,8 +113,6 @@ onMounted(fetchSettings)
<style scoped lang="scss"> <style scoped lang="scss">
.settings-page { padding: 0; } .settings-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
.settings-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 24px; } .settings-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 24px; }
:deep(.el-checkbox__label) { color: rgba(255, 255, 255, 0.8); } :deep(.el-checkbox__label) { color: rgba(255, 255, 255, 0.8); }
-2
View File
@@ -1,7 +1,6 @@
<template> <template>
<div class="suppliers-page"> <div class="suppliers-page">
<div class="page-header"> <div class="page-header">
<h2 class="page-title">供应商管理</h2>
<el-button type="primary" @click="showAdd = true">创建供应商</el-button> <el-button type="primary" @click="showAdd = true">创建供应商</el-button>
</div> </div>
<div class="table-card"> <div class="table-card">
@@ -110,7 +109,6 @@ onMounted(fetchSuppliers)
<style scoped lang="scss"> <style scoped lang="scss">
.suppliers-page { padding: 0; } .suppliers-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; } .table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
.pagination-wrap { .pagination-wrap {
-5
View File
@@ -1,8 +1,5 @@
<template> <template>
<div class="tickets-page"> <div class="tickets-page">
<div class="page-header">
<h2 class="page-title">工单管理</h2>
</div>
<div class="table-card"> <div class="table-card">
<el-table :data="paginatedTickets"> <el-table :data="paginatedTickets">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
@@ -85,8 +82,6 @@ onMounted(fetchTickets)
<style scoped lang="scss"> <style scoped lang="scss">
.tickets-page { padding: 0; } .tickets-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; } .table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
.pagination-wrap { .pagination-wrap {
+85 -10
View File
@@ -1,8 +1,5 @@
<template> <template>
<div class="users-page"> <div class="users-page">
<div class="page-header">
<h2 class="page-title">用户管理</h2>
</div>
<div class="table-card"> <div class="table-card">
<el-table :data="paginatedUsers"> <el-table :data="paginatedUsers">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
@@ -16,11 +13,24 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="purchase_credits" label="积分" width="100" /> <el-table-column prop="purchase_credits" label="积分" width="100" />
<el-table-column prop="is_active" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间" width="180"> <el-table-column prop="created_at" label="注册时间" width="180">
<template #default="{ row }"> <template #default="{ row }">
{{ formatDate(row.created_at) }} {{ formatDate(row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)" :disabled="row.role === 'admin'">删除</el-button>
</template>
</el-table-column>
</el-table> </el-table>
<div class="pagination-wrap"> <div class="pagination-wrap">
<el-pagination <el-pagination
@@ -32,12 +42,38 @@
/> />
</div> </div>
</div> </div>
<el-dialog v-model="editVisible" title="编辑用户" width="480px">
<el-form :model="editForm" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="editForm.username" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="editForm.email" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="editForm.role">
<el-option label="用户" value="user" />
<el-option label="供应商" value="supplier" />
<el-option label="管理员" value="admin" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="editForm.is_active" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="handleEdit" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import api from '../../utils/request' import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const users = ref<any[]>([]) const users = ref<any[]>([])
const page = ref(1) const page = ref(1)
@@ -48,16 +84,24 @@ const paginatedUsers = computed(() => {
return users.value.slice(start, start + pageSize) return users.value.slice(start, start + pageSize)
}) })
const editVisible = ref(false)
const editForm = ref<any>({})
const saving = ref(false)
function handlePageChange() { function handlePageChange() {
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
onMounted(async () => { async function fetchUsers() {
try { try {
const res: any = await api.get('/admin/users') const res: any = await adminApi.getUsers()
users.value = res.data || [] users.value = res.data || []
} catch {} } catch {
}) ElMessage.error('获取用户列表失败')
}
}
onMounted(fetchUsers)
function formatDate(date: string) { function formatDate(date: string) {
if (!date) return '-' if (!date) return '-'
@@ -69,12 +113,43 @@ function formatDate(date: string) {
const minutes = String(d.getMinutes()).padStart(2, '0') const minutes = String(d.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}` return `${year}-${month}-${day} ${hours}:${minutes}`
} }
function openEdit(row: any) {
editForm.value = { ...row }
editVisible.value = true
}
async function handleEdit() {
saving.value = true
try {
await adminApi.updateUser(editForm.value.id, {
username: editForm.value.username,
email: editForm.value.email,
role: editForm.value.role,
is_active: editForm.value.is_active,
})
ElMessage.success('更新成功')
editVisible.value = false
fetchUsers()
} catch {
ElMessage.error('更新失败')
} finally {
saving.value = false
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确定要删除该用户吗?', '提示', { type: 'warning' })
await adminApi.deleteUser(row.id)
ElMessage.success('删除成功')
fetchUsers()
} catch {}
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.users-page { padding: 0; } .users-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; } .table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
.pagination-wrap { .pagination-wrap {
-523
View File
@@ -1,523 +0,0 @@
<template>
<div class="home-page">
<div class="banner-section">
<el-carousel height="280px" :interval="5000" arrow="hover" indicator-position="inside">
<el-carousel-item v-for="(banner, index) in banners" :key="index">
<div class="banner-slide" :style="{ background: banner.bg }">
<div class="banner-content">
<h2>{{ banner.title }}</h2>
<p>{{ banner.desc }}</p>
<button class="banner-btn" @click="$router.push(banner.link)">{{ banner.btn }}</button>
</div>
<div class="banner-icon">
<el-icon :size="80"><component :is="banner.icon" /></el-icon>
</div>
</div>
</el-carousel-item>
</el-carousel>
</div>
<div class="quick-actions">
<div class="action-item" @click="$router.push('/products')">
<div class="action-icon" style="background: rgba(78, 110, 242, 0.15); color: #4e6ef2;">
<el-icon size="22"><Goods /></el-icon>
</div>
<span>商品</span>
</div>
<div class="action-item" @click="$router.push('/lotteries')">
<div class="action-icon" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b;">
<el-icon size="22"><Trophy /></el-icon>
</div>
<span>抽奖</span>
</div>
<div class="action-item" @click="$router.push('/articles')">
<div class="action-icon" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">
<el-icon size="22"><Document /></el-icon>
</div>
<span>资讯</span>
</div>
<div class="action-item" @click="$router.push('/cart')">
<div class="action-icon" style="background: rgba(124, 92, 252, 0.15); color: #7c5cfc;">
<el-icon size="22"><ShoppingCart /></el-icon>
</div>
<span>购物车</span>
</div>
</div>
<div class="section" v-if="lotteries.length">
<div class="section-head">
<h2>热门活动</h2>
</div>
<div class="lottery-cards">
<div v-for="l in lotteries" :key="l.id" class="lottery-card" @click="$router.push(`/lotteries/${l.id}`)">
<div class="lottery-icon">
<el-icon size="20"><Trophy /></el-icon>
</div>
<div class="lottery-info">
<h3>{{ l.name }}</h3>
<p>{{ l.description }}</p>
</div>
<div class="lottery-right">
<span class="lottery-status" :class="l.status === 'active' ? 'active' : ''">
{{ l.status === 'active' ? '进行中' : '已结束' }}
</span>
<button class="lottery-btn">参与</button>
</div>
</div>
</div>
</div>
<div class="section" v-if="articles.length">
<div class="section-head">
<h2>最新资讯</h2>
<span class="section-more" @click="$router.push('/articles')">查看全部 </span>
</div>
<div class="article-list">
<div v-for="a in articles" :key="a.id" class="article-item" @click="$router.push(`/articles/${a.id}`)">
<div class="article-icon">
<el-icon size="18"><Document /></el-icon>
</div>
<div class="article-info">
<h3>{{ a.title }}</h3>
<p>{{ a.summary || a.content?.slice(0, 60) }}...</p>
</div>
<span class="article-date">{{ formatDate(a.created_at) }}</span>
</div>
</div>
</div>
<div class="section" v-if="categories.length">
<div class="section-head">
<h2>热门分类</h2>
<span class="section-more" @click="$router.push('/products')">查看全部 </span>
</div>
<div class="category-scroll">
<div v-for="cat in categories" :key="cat.id" class="category-card" @click="goCategory(cat.id)">
<div class="category-icon">
<el-icon size="24"><Folder /></el-icon>
</div>
<div class="category-name">{{ cat.name }}</div>
<div class="category-count">{{ cat.product_count || 0 }} </div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { Goods, Trophy, Document, ShoppingCart, Folder, Present, Star, Timer } from '@element-plus/icons-vue'
import { categoryApi, lotteryApi, articleApi } from '../../api'
const router = useRouter()
const categories = ref<any[]>([])
const lotteries = ref<any[]>([])
const articles = ref<any[]>([])
const banners = [
{ title: '新品上市', desc: '精选优质商品,限时特惠', btn: '立即选购', link: '/products', icon: shallowRef(Present), bg: 'linear-gradient(135deg, #4e6ef2 0%, #7c5cfc 100%)' },
{ title: '幸运抽奖', desc: '参与抽奖赢取好礼', btn: '参与活动', link: '/lotteries', icon: shallowRef(Star), bg: 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)' },
{ title: '限时秒杀', desc: '每日精选,超值优惠', btn: '查看详情', link: '/products', icon: shallowRef(Timer), bg: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' },
]
function goCategory(id: number) {
router.push({ path: '/products', query: { category_id: String(id) } })
}
function formatDate(date: string) {
if (!date) return '-'
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
onMounted(async () => {
try {
const [catRes, lotRes, artRes]: any[] = await Promise.all([
categoryApi.list(),
lotteryApi.list(),
articleApi.list({ page: 1, page_size: 5 }),
])
categories.value = (catRes.data || []).slice(0, 6)
lotteries.value = (lotRes.data || []).slice(0, 3)
articles.value = (artRes.data || []).slice(0, 5)
} catch (e) {
console.log('加载失败:', e)
}
})
</script>
<style scoped lang="scss">
.home-page {
max-width: 1200px;
margin: 0 auto;
}
.banner-section {
margin-bottom: 24px;
}
.banner-slide {
height: 280px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 60px;
border-radius: 12px;
position: relative;
overflow: hidden;
}
.banner-content {
z-index: 1;
}
.banner-content h2 {
font-size: 32px;
font-weight: 700;
color: #fff;
margin-bottom: 12px;
}
.banner-content p {
font-size: 16px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 24px;
}
.banner-btn {
padding: 12px 32px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
color: #fff;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(8px);
}
.banner-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.banner-icon {
color: rgba(255, 255, 255, 0.2);
}
.quick-actions {
display: flex;
gap: 12px;
margin-bottom: 28px;
}
.action-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px;
background: #2d2d44;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.action-item:hover {
background: #35355a;
transform: translateY(-2px);
}
.action-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.action-item span {
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.section {
margin-bottom: 28px;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.section-head h2 {
font-size: 17px;
font-weight: 600;
color: #fff;
}
.section-more {
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
cursor: pointer;
transition: color 0.15s;
}
.section-more:hover {
color: #4e6ef2;
}
.category-scroll {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
}
.category-card {
flex-shrink: 0;
width: 140px;
padding: 16px;
background: #2d2d44;
border-radius: 12px;
text-align: center;
cursor: pointer;
transition: all 0.15s;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.category-card:hover {
background: #35355a;
transform: translateY(-2px);
}
.category-icon {
width: 48px;
height: 48px;
margin: 0 auto 10px;
border-radius: 12px;
background: rgba(78, 110, 242, 0.12);
display: flex;
align-items: center;
justify-content: center;
color: #4e6ef2;
}
.category-name {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 4px;
}
.category-count {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
}
.lottery-cards {
display: flex;
flex-direction: column;
gap: 10px;
}
.lottery-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background: #2d2d44;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.lottery-card:hover {
background: #35355a;
}
.lottery-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.lottery-info {
flex: 1;
min-width: 0;
}
.lottery-info h3 {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 4px;
}
.lottery-info p {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lottery-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.lottery-status {
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.4);
}
.lottery-status.active {
background: rgba(16, 185, 129, 0.12);
color: #10b981;
}
.lottery-btn {
padding: 6px 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: #fff;
background: #4e6ef2;
border: none;
cursor: pointer;
transition: background 0.15s;
}
.lottery-btn:hover {
background: #5a7af5;
}
.article-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.article-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: #2d2d44;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.article-item:hover {
background: #35355a;
}
.article-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(16, 185, 129, 0.12);
display: flex;
align-items: center;
justify-content: center;
color: #10b981;
flex-shrink: 0;
}
.article-info {
flex: 1;
min-width: 0;
}
.article-info h3 {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-info p {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-date {
font-size: 12px;
color: rgba(255, 255, 255, 0.25);
flex-shrink: 0;
}
:deep(.el-carousel__indicators--inside) {
bottom: 12px;
}
:deep(.el-carousel__indicator--horizontal .el-carousel__button) {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s;
}
:deep(.el-carousel__indicator--horizontal.is-active .el-carousel__button) {
width: 24px;
border-radius: 4px;
background: #4e6ef2;
}
@media (max-width: 768px) {
.banner-slide {
height: 200px;
padding: 0 24px;
}
.banner-content h2 {
font-size: 24px;
}
.banner-icon {
display: none;
}
.quick-actions {
flex-wrap: wrap;
}
.action-item {
flex: 1 1 45%;
}
}
</style>
+51 -30
View File
@@ -1,35 +1,28 @@
<template> <template>
<div class="dashboard-page"> <div class="dashboard-page">
<h2 class="page-title">{{ $t('supplier.dashboard') }}</h2> <div class="stat-cards">
<el-row :gutter="16"> <div class="stat-card">
<el-col :span="8"> <div class="stat-icon pending"><el-icon><Clock /></el-icon></div>
<div class="stat-card"> <div class="stat-info">
<div class="stat-icon pending"><el-icon><Clock /></el-icon></div> <p class="stat-value">{{ stats.pending }}</p>
<div class="stat-info"> <p class="stat-label">待处理订单</p>
<p class="stat-value">{{ stats.pending }}</p>
<p class="stat-label">待处理订单</p>
</div>
</div> </div>
</el-col> </div>
<el-col :span="8"> <div class="stat-card">
<div class="stat-card"> <div class="stat-icon total"><el-icon><List /></el-icon></div>
<div class="stat-icon total"><el-icon><List /></el-icon></div> <div class="stat-info">
<div class="stat-info"> <p class="stat-value">{{ stats.total }}</p>
<p class="stat-value">{{ stats.total }}</p> <p class="stat-label">总订单数</p>
<p class="stat-label">总订单数</p>
</div>
</div> </div>
</el-col> </div>
<el-col :span="8"> <div class="stat-card">
<div class="stat-card"> <div class="stat-icon products"><el-icon><Goods /></el-icon></div>
<div class="stat-icon products"><el-icon><Goods /></el-icon></div> <div class="stat-info">
<div class="stat-info"> <p class="stat-value">{{ stats.products }}</p>
<p class="stat-value">{{ stats.products }}</p> <p class="stat-label">商品数量</p>
<p class="stat-label">商品数量</p>
</div>
</div> </div>
</el-col> </div>
</el-row> </div>
</div> </div>
</template> </template>
@@ -42,17 +35,28 @@ const stats = ref({ pending: '--', total: '--', products: '--' })
onMounted(async () => { onMounted(async () => {
try { try {
const res: any = await supplierApi.getOrders() const [ordersRes, inventoryRes]: any[] = await Promise.all([
const orders = res.data || [] supplierApi.getOrders(),
supplierApi.getInventory(),
])
const orders = ordersRes.data || []
const inventory = inventoryRes.data || []
stats.value.pending = orders.filter((o: any) => o.status === 'pending_confirm').length stats.value.pending = orders.filter((o: any) => o.status === 'pending_confirm').length
stats.value.total = orders.length stats.value.total = orders.length
stats.value.products = inventory.length
} catch {} } catch {}
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.dashboard-page { padding: 0; } .dashboard-page { padding: 0; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; margin-bottom: 24px; }
.stat-cards {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.stat-card { .stat-card {
background: #2d2d44; background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.06);
@@ -61,7 +65,10 @@ onMounted(async () => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
flex: 1 1 200px;
min-width: 200px;
} }
.stat-icon { .stat-icon {
width: 48px; width: 48px;
height: 48px; height: 48px;
@@ -70,6 +77,7 @@ onMounted(async () => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 24px; font-size: 24px;
flex-shrink: 0;
} }
.stat-icon.pending { background: rgba(245, 158, 11, 0.15); color: #f59e0b; } .stat-icon.pending { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
.stat-icon.total { background: rgba(78, 110, 242, 0.15); color: #4e6ef2; } .stat-icon.total { background: rgba(78, 110, 242, 0.15); color: #4e6ef2; }
@@ -77,4 +85,17 @@ onMounted(async () => {
.stat-info { flex: 1; } .stat-info { flex: 1; }
.stat-value { font-size: 28px; font-weight: 700; color: #fff; } .stat-value { font-size: 28px; font-weight: 700; color: #fff; }
.stat-label { font-size: 14px; color: rgba(255, 255, 255, 0.5); margin-top: 4px; } .stat-label { font-size: 14px; color: rgba(255, 255, 255, 0.5); margin-top: 4px; }
@media (max-width: 768px) {
.stat-cards { gap: 12px; }
.stat-card {
padding: 16px;
gap: 12px;
flex: 1 1 calc(50% - 6px);
min-width: 140px;
}
.stat-icon { width: 40px; height: 40px; font-size: 20px; border-radius: 10px; }
.stat-value { font-size: 22px; }
.stat-label { font-size: 12px; }
}
</style> </style>
+13 -10
View File
@@ -1,8 +1,5 @@
<template> <template>
<div class="inventory-page"> <div class="inventory-page">
<div class="page-header">
<h2 class="page-title">{{ $t('supplier.inventoryManagement') }}</h2>
</div>
<div class="table-card"> <div class="table-card">
<el-table :data="inventory"> <el-table :data="inventory">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
@@ -27,14 +24,22 @@ import { supplierApi } from '../../api'
const inventory = ref<any[]>([]) const inventory = ref<any[]>([])
async function fetchInventory() { async function fetchInventory() {
const res: any = await supplierApi.getInventory() try {
inventory.value = (res.data || []).map((i: any) => ({ ...i, newQty: i.quantity })) const res: any = await supplierApi.getInventory()
inventory.value = (res.data || []).map((i: any) => ({ ...i, newQty: i.quantity }))
} catch {
ElMessage.error('获取库存失败')
}
} }
async function updateQty(row: any) { async function updateQty(row: any) {
await supplierApi.updateInventory(row.id, { quantity: row.newQty }) try {
ElMessage.success('Updated') await supplierApi.updateInventory(row.id, { quantity: row.newQty })
await fetchInventory() ElMessage.success('更新成功')
await fetchInventory()
} catch {
ElMessage.error('更新失败')
}
} }
onMounted(fetchInventory) onMounted(fetchInventory)
@@ -42,7 +47,5 @@ onMounted(fetchInventory)
<style scoped lang="scss"> <style scoped lang="scss">
.inventory-page { padding: 0; } .inventory-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; } .table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
</style> </style>
+26 -19
View File
@@ -1,21 +1,18 @@
<template> <template>
<div class="orders-page"> <div class="orders-page">
<div class="page-header">
<h2 class="page-title">{{ $t('supplier.orderManagement') }}</h2>
</div>
<div class="table-card"> <div class="table-card">
<el-table :data="orders"> <el-table :data="orders">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="total_amount" label="Amount" width="100"><template #default="{ row }">¥{{ row.total_amount }}</template></el-table-column> <el-table-column prop="total_amount" label="金额" width="100"><template #default="{ row }">¥{{ row.total_amount }}</template></el-table-column>
<el-table-column prop="status" label="Status" width="120"> <el-table-column prop="status" label="状态" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag> <el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="Created" width="180"> <el-table-column label="创建时间" width="180">
<template #default="{ row }">{{ formatDate(row.created_at) }}</template> <template #default="{ row }">{{ formatDate(row.created_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="Actions" width="200"> <el-table-column label="操作" width="200">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="row.status === 'pending_confirm'" link type="primary" size="small" @click="confirmOrder(row.id)">{{ $t('supplier.confirmOrder') }}</el-button> <el-button v-if="row.status === 'pending_confirm'" link type="primary" size="small" @click="confirmOrder(row.id)">{{ $t('supplier.confirmOrder') }}</el-button>
<el-button v-if="row.status === 'pending_ship'" link type="success" size="small" @click="openShip(row)">{{ $t('supplier.shipOrder') }}</el-button> <el-button v-if="row.status === 'pending_ship'" link type="success" size="small" @click="openShip(row)">{{ $t('supplier.shipOrder') }}</el-button>
@@ -71,14 +68,22 @@ function formatDate(date: string) {
} }
async function fetchOrders() { async function fetchOrders() {
const res: any = await supplierApi.getOrders() try {
orders.value = res.data || [] const res: any = await supplierApi.getOrders()
orders.value = res.data || []
} catch {
ElMessage.error('获取订单失败')
}
} }
async function confirmOrder(id: number) { async function confirmOrder(id: number) {
await supplierApi.confirmOrder(id) try {
ElMessage.success('Order confirmed') await supplierApi.confirmOrder(id)
await fetchOrders() ElMessage.success('订单已确认')
await fetchOrders()
} catch {
ElMessage.error('确认失败')
}
} }
function openShip(row: any) { function openShip(row: any) {
@@ -91,11 +96,15 @@ async function shipOrder() {
ElMessage.warning('请输入快递单号') ElMessage.warning('请输入快递单号')
return return
} }
await supplierApi.shipOrder(shipOrderId.value, shipForm) try {
ElMessage.success('Order shipped') await supplierApi.shipOrder(shipOrderId.value, shipForm)
showShip.value = false ElMessage.success('发货成功')
Object.assign(shipForm, { tracking_number: '', shipping_photo: '', express_photo: '', customs_photo: '' }) showShip.value = false
await fetchOrders() Object.assign(shipForm, { tracking_number: '', shipping_photo: '', express_photo: '', customs_photo: '' })
await fetchOrders()
} catch {
ElMessage.error('发货失败')
}
} }
onMounted(fetchOrders) onMounted(fetchOrders)
@@ -103,7 +112,5 @@ onMounted(fetchOrders)
<style scoped lang="scss"> <style scoped lang="scss">
.orders-page { padding: 0; } .orders-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; } .table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
</style> </style>
-2
View File
@@ -445,8 +445,6 @@ onMounted(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.cart-page { .cart-page {
max-width: 1200px;
margin: 0 auto;
padding-bottom: 40px; padding-bottom: 40px;
} }
+737 -165
View File
@@ -1,218 +1,790 @@
<template> <template>
<div class="home-page"> <div class="home-page">
<section class="hero"> <div class="banner-section">
<div class="hero-content"> <el-carousel height="280px" :interval="5000" arrow="hover" trigger="click">
<h1>Discover Premium Products</h1> <el-carousel-item v-for="(banner, index) in banners" :key="index">
<p>Find the best products from trusted suppliers worldwide</p> <div class="banner-slide" :style="{ background: banner.bg_color || banner.bg }">
<el-button type="primary" size="large" @click="$router.push('/products')"> <img v-if="banner.image" :src="banner.image" class="banner-bg-image" />
{{ $t('common.products') }} <div class="banner-content">
</el-button> <h2>{{ banner.title }}</h2>
</div> <p>{{ banner.desc }}</p>
</section> <button class="banner-btn" @click="$router.push(banner.link)">{{ banner.button || banner.btn }}</button>
</div>
<section class="section"> <div class="banner-icon" v-if="banner.icon">
<div class="section-header"> <el-icon :size="80"><component :is="banner.icon" /></el-icon>
<h2 class="section-title">{{ $t('product.category') }}</h2>
</div>
<div class="category-grid">
<div v-for="cat in categories" :key="cat.id" class="category-card" @click="goCategory(cat.id)">
<div class="category-icon">
<el-icon size="32"><Folder /></el-icon>
</div>
<span class="category-name">{{ cat.name }}</span>
</div>
</div>
</section>
<section class="section">
<div class="section-header">
<h2 class="section-title">{{ $t('common.products') }}</h2>
<el-button link @click="$router.push('/products')">{{ $t('common.search') }} </el-button>
</div>
<div class="product-grid">
<div v-for="p in products" :key="p.id" class="product-card" @click="$router.push(`/products/${p.id}`)">
<div class="product-image">
<el-image v-if="getFirstImage(p.images)" :src="getFirstImage(p.images)" fit="cover" lazy>
<template #error>
<div class="image-placeholder">
<el-icon size="48"><Goods /></el-icon>
</div>
</template>
</el-image>
<div v-else class="image-placeholder">
<el-icon size="48"><Goods /></el-icon>
</div> </div>
</div> </div>
<div class="product-info"> </el-carousel-item>
<h3 class="product-name">{{ p.name }}</h3> </el-carousel>
<p class="product-price">¥{{ p.price }}</p> </div>
<div class="product-tags">
<el-tag v-if="p.require_credit" size="small" type="warning">{{ $t('product.requireCredit') }}</el-tag>
<el-tag v-if="p.credit_reward > 0" size="small" type="success">+{{ p.credit_reward }} {{ $t('product.creditReward') }}</el-tag>
</div>
</div>
</div>
</div>
</section>
<section class="section" v-if="lotteries.length"> <div class="quick-actions">
<div class="section-header"> <div class="action-item" @click="$router.push('/products')">
<h2 class="section-title">{{ $t('lottery.title') }}</h2> <div class="action-icon" style="background: rgba(78, 110, 242, 0.15); color: #4e6ef2;">
<el-button link @click="$router.push('/lotteries')">View All </el-button> <el-icon size="22"><Goods /></el-icon>
</div>
<span>商品</span>
</div> </div>
<div class="lottery-grid"> <div class="action-item" @click="$router.push('/lotteries')">
<div class="action-icon" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b;">
<el-icon size="22"><Trophy /></el-icon>
</div>
<span>抽奖</span>
</div>
<div class="action-item" @click="$router.push('/articles')">
<div class="action-icon" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">
<el-icon size="22"><Document /></el-icon>
</div>
<span>资讯</span>
</div>
<div class="action-item" @click="$router.push('/cart')">
<div class="action-icon" style="background: rgba(124, 92, 252, 0.15); color: #7c5cfc;">
<el-icon size="22"><ShoppingCart /></el-icon>
</div>
<span>购物车</span>
</div>
</div>
<div class="section" v-if="lotteries.length">
<div class="section-head">
<h2>热门活动</h2>
</div>
<div class="lottery-cards">
<div v-for="l in lotteries" :key="l.id" class="lottery-card" @click="$router.push(`/lotteries/${l.id}`)"> <div v-for="l in lotteries" :key="l.id" class="lottery-card" @click="$router.push(`/lotteries/${l.id}`)">
<h3>{{ l.name }}</h3> <div class="lottery-icon">
<p>{{ l.description }}</p> <el-icon size="20"><Trophy /></el-icon>
<el-button type="primary" size="small">{{ $t('lottery.register') }}</el-button> </div>
<div class="lottery-info">
<h3>{{ l.name }}</h3>
<p>{{ l.description }}</p>
</div>
<div class="lottery-right">
<span class="lottery-status" :class="l.status === 'active' ? 'active' : ''">
{{ l.status === 'active' ? '进行中' : '已结束' }}
</span>
<button class="lottery-btn">参与</button>
</div>
</div> </div>
</div> </div>
</section> </div>
<div class="section" v-if="articles.length">
<div class="section-head">
<h2>最新资讯</h2>
<span class="section-more" @click="$router.push('/articles')">查看全部 </span>
</div>
<div class="article-list">
<div v-for="a in articles" :key="a.id" class="article-item" @click="$router.push(`/articles/${a.id}`)">
<div class="article-icon">
<el-icon size="18"><Document /></el-icon>
</div>
<div class="article-info">
<h3>{{ a.title }}</h3>
<p>{{ a.summary || a.content?.slice(0, 60) }}...</p>
</div>
<span class="article-date">{{ formatDate(a.created_at) }}</span>
</div>
</div>
</div>
<div class="section" v-if="categories.length">
<div class="section-head">
<h2>热门分类</h2>
<span class="section-more" @click="$router.push('/products')">查看全部 </span>
</div>
<div class="category-list">
<div v-for="cat in categories" :key="cat.id" class="category-item" @click="goCategory(cat.id)">
<div class="category-icon">
<el-icon size="18"><Folder /></el-icon>
</div>
<div class="category-info">
<h3>{{ cat.name }}</h3>
<p>{{ cat.product_count || 0 }} 件商品</p>
</div>
<el-icon class="category-arrow"><ArrowRight /></el-icon>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, shallowRef } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Folder, Goods } from '@element-plus/icons-vue' import { Goods, Trophy, Document, ShoppingCart, Folder, Present, Star, Timer, ArrowRight } from '@element-plus/icons-vue'
import { categoryApi, productApi, lotteryApi } from '../../api' import { categoryApi, lotteryApi, articleApi, bannerApi } from '../../api'
import { getFirstImage } from '../../utils/image'
const router = useRouter() const router = useRouter()
const categories = ref<any[]>([]) const categories = ref<any[]>([])
const products = ref<any[]>([])
const lotteries = ref<any[]>([]) const lotteries = ref<any[]>([])
const articles = ref<any[]>([])
const banners = ref<any[]>([])
const defaultBanners = [
{ title: '新品上市', desc: '精选优质商品,限时特惠', btn: '立即选购', link: '/products', icon: shallowRef(Present), bg: 'linear-gradient(135deg, #4e6ef2 0%, #7c5cfc 100%)' },
{ title: '幸运抽奖', desc: '参与抽奖赢取好礼', btn: '参与活动', link: '/lotteries', icon: shallowRef(Star), bg: 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)' },
{ title: '限时秒杀', desc: '每日精选,超值优惠', btn: '查看详情', link: '/products', icon: shallowRef(Timer), bg: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' },
]
function goCategory(id: number) { function goCategory(id: number) {
router.push({ path: '/products', query: { category_id: String(id) } }) router.push({ path: '/products', query: { category_id: String(id) } })
} }
function formatDate(date: string) {
if (!date) return '-'
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
onMounted(async () => { onMounted(async () => {
try { try {
const [catRes, prodRes, lotRes]: any[] = await Promise.all([ const [catRes, lotRes, artRes, banRes]: any[] = await Promise.all([
categoryApi.list(), categoryApi.list(),
productApi.list({ page: 1, page_size: 8 }),
lotteryApi.list(), lotteryApi.list(),
articleApi.list({ page: 1, page_size: 5 }),
bannerApi.list(),
]) ])
categories.value = catRes.data || [] categories.value = (catRes.data || []).slice(0, 6)
products.value = prodRes.data || []
lotteries.value = (lotRes.data || []).slice(0, 3) lotteries.value = (lotRes.data || []).slice(0, 3)
} catch {} articles.value = (artRes.data || []).slice(0, 5)
if (banRes.data && banRes.data.length > 0) {
banners.value = banRes.data
} else {
banners.value = defaultBanners as any
}
} catch (e) {
console.log('加载失败:', e)
banners.value = defaultBanners as any
}
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.hero { .home-page {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); padding: 0;
color: #fff; width: 100%;
padding: 80px 24px; max-width: 100vw;
text-align: center; overflow-x: hidden;
box-sizing: border-box;
h1 { font-size: 42px; font-weight: 700; margin-bottom: 16px; }
p { font-size: 18px; opacity: 0.8; margin-bottom: 32px; }
} }
.section { .banner-section {
max-width: 1200px;
margin: 0 auto;
padding: 40px 24px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px; margin-bottom: 24px;
} width: 100%;
max-width: 100%;
.section-title {
color: #fff;
font-size: 24px;
font-weight: 600;
}
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.category-card {
background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 24px 16px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
&:hover { transform: translateY(-4px); border-color: rgba(78, 110, 242, 0.3); }
}
.category-icon { margin-bottom: 8px; color: #4e6ef2; }
.category-name { font-size: 14px; font-weight: 500; color: rgba(255, 255, 255, 0.85); }
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 20px;
}
.product-card {
background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
overflow: hidden; overflow: hidden;
cursor: pointer; position: relative;
transition: all 0.3s;
&:hover { transform: translateY(-4px); border-color: rgba(78, 110, 242, 0.3); }
} }
.product-image { .banner-slide {
height: 200px; height: 280px;
background: rgba(255, 255, 255, 0.04); display: flex;
align-items: center;
justify-content: space-between;
padding: 0 60px;
border-radius: 12px;
position: relative;
overflow: hidden;
width: 100%;
box-sizing: border-box;
}
.banner-bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
}
.banner-content {
z-index: 1;
position: relative;
}
.banner-content h2 {
font-size: 32px;
font-weight: 700;
color: #fff;
margin-bottom: 12px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.banner-content p {
font-size: 16px;
color: rgba(255, 255, 255, 0.95);
margin-bottom: 24px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.banner-btn {
padding: 12px 32px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
color: #fff;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(8px);
}
.banner-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.banner-icon {
color: rgba(255, 255, 255, 0.2);
}
.quick-actions {
display: flex;
gap: 12px;
margin-bottom: 28px;
width: 100%;
box-sizing: border-box;
}
.action-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px;
background: #2d2d44;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.action-item:hover {
background: #35355a;
transform: translateY(-2px);
}
.action-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
.el-image {
width: 100%;
height: 100%;
}
.image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.15);
}
} }
.product-info { padding: 16px; } .action-item span {
.product-name { font-size: 16px; font-weight: 500; margin-bottom: 8px; color: rgba(255, 255, 255, 0.9); } font-size: 13px;
.product-price { font-size: 20px; font-weight: 700; color: #4e6ef2; margin-bottom: 8px; } font-weight: 500;
.product-tags { display: flex; gap: 8px; } color: rgba(255, 255, 255, 0.7);
}
.lottery-grid { .section {
display: grid; margin-bottom: 28px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); width: 100%;
gap: 20px; box-sizing: border-box;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.section-head h2 {
font-size: 17px;
font-weight: 600;
color: #fff;
}
.section-more {
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
cursor: pointer;
transition: color 0.15s;
}
.section-more:hover {
color: #4e6ef2;
}
.category-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.category-item {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background: #2d2d44;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.category-item:hover {
background: #35355a;
}
.category-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, #f59e0b, #f97316);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.category-info {
flex: 1;
min-width: 0;
}
.category-info h3 {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 4px;
}
.category-info p {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-arrow {
color: rgba(255, 255, 255, 0.25);
flex-shrink: 0;
}
.lottery-cards {
display: flex;
flex-direction: column;
gap: 10px;
} }
.lottery-card { .lottery-card {
background: linear-gradient(135deg, #4e6ef2, #7c5cfc); display: flex;
color: #fff; align-items: center;
gap: 14px;
padding: 16px;
background: #2d2d44;
border-radius: 12px; border-radius: 12px;
padding: 24px;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.15s;
border: 1px solid rgba(255, 255, 255, 0.04);
}
h3 { margin-bottom: 8px; } .lottery-card:hover {
p { opacity: 0.8; margin-bottom: 16px; font-size: 14px; } background: #35355a;
}
.lottery-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.lottery-info {
flex: 1;
min-width: 0;
}
.lottery-info h3 {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 4px;
}
.lottery-info p {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lottery-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.lottery-status {
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.4);
}
.lottery-status.active {
background: rgba(16, 185, 129, 0.12);
color: #10b981;
}
.lottery-btn {
padding: 6px 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: #fff;
background: #4e6ef2;
border: none;
cursor: pointer;
transition: background 0.15s;
}
.lottery-btn:hover {
background: #5a7af5;
}
.article-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.article-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: #2d2d44;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.article-item:hover {
background: #35355a;
}
.article-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(16, 185, 129, 0.12);
display: flex;
align-items: center;
justify-content: center;
color: #10b981;
flex-shrink: 0;
}
.article-info {
flex: 1;
min-width: 0;
}
.article-info h3 {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-info p {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-date {
font-size: 12px;
color: rgba(255, 255, 255, 0.25);
flex-shrink: 0;
}
:deep(.el-carousel) {
position: relative !important;
}
:deep(.el-carousel__container) {
position: relative !important;
height: 280px;
}
:deep(.el-carousel__arrow) {
top: 50% !important;
transform: translateY(-50%) !important;
background: rgba(0, 0, 0, 0.3) !important;
color: #fff !important;
border: none !important;
width: 36px !important;
height: 36px !important;
font-size: 16px !important;
margin-top: 0 !important;
}
:deep(.el-carousel__arrow:hover) {
background: rgba(0, 0, 0, 0.5) !important;
}
:deep(.el-carousel__arrow--left) {
left: 16px !important;
}
:deep(.el-carousel__arrow--right) {
right: 16px !important;
}
:deep(.el-carousel__indicators) {
position: absolute !important;
bottom: 16px !important;
left: 50% !important;
transform: translateX(-50%) !important;
z-index: 10 !important;
margin: 0 !important;
padding: 0 !important;
}
:deep(.el-carousel__indicator--horizontal .el-carousel__button) {
background: rgba(255, 255, 255, 0.3);
width: 24px;
height: 4px;
border-radius: 2px;
}
:deep(.el-carousel__indicator--horizontal.is-active .el-carousel__button) {
background: #fff;
}
@media (max-width: 768px) {
.home-page {
padding: 0;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
}
.banner-section {
margin-bottom: 16px;
width: 100%;
max-width: 100vw;
position: relative;
}
.banner-slide {
height: 200px;
padding: 0 20px;
border-radius: 0;
width: 100%;
max-width: 100vw;
box-sizing: border-box;
}
.banner-content h2 {
font-size: 20px;
margin-bottom: 8px;
}
.banner-content p {
font-size: 14px;
margin-bottom: 16px;
}
.banner-btn {
padding: 8px 20px;
font-size: 13px;
}
.banner-icon {
display: none;
}
.quick-actions {
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
padding: 0 12px;
}
.action-item {
flex: 1 1 45%;
padding: 12px;
}
.action-icon {
width: 36px;
height: 36px;
}
.action-item span {
font-size: 12px;
}
.section {
margin-bottom: 20px;
padding: 0 12px;
width: 100% !important;
max-width: 100vw !important;
box-sizing: border-box !important;
overflow-x: hidden !important;
}
.section-head {
margin-bottom: 12px;
}
.section-head h2 {
font-size: 16px;
}
.section-more {
font-size: 12px;
}
.category-list {
gap: 8px;
}
.category-item {
padding: 12px;
gap: 10px;
}
.category-icon {
width: 36px;
height: 36px;
}
.category-info h3 {
font-size: 13px;
}
.category-info p {
font-size: 11px;
}
.lottery-cards {
gap: 8px;
}
.lottery-card {
padding: 12px;
gap: 10px;
}
.lottery-icon {
width: 36px;
height: 36px;
}
.lottery-info h3 {
font-size: 13px;
}
.lottery-info p {
font-size: 11px;
}
.lottery-status {
font-size: 11px;
padding: 2px 8px;
}
.lottery-btn {
padding: 5px 12px;
font-size: 11px;
}
.article-list {
gap: 6px;
}
.article-item {
padding: 10px;
gap: 10px;
}
.article-icon {
width: 28px;
height: 28px;
}
.article-info h3 {
font-size: 13px;
}
.article-info p {
font-size: 11px;
}
.article-date {
font-size: 11px;
}
:deep(.el-carousel) {
position: relative !important;
}
:deep(.el-carousel__container) {
height: 200px !important;
}
:deep(.el-carousel__arrow) {
width: 28px !important;
height: 28px !important;
font-size: 14px !important;
top: 50% !important;
transform: translateY(-50%) !important;
margin-top: 0 !important;
}
:deep(.el-carousel__arrow--left) {
left: 10px !important;
}
:deep(.el-carousel__arrow--right) {
right: 10px !important;
}
:deep(.el-carousel__indicators) {
bottom: 12px !important;
position: absolute !important;
left: 50% !important;
transform: translateX(-50%) !important;
z-index: 10 !important;
}
:deep(.el-carousel__indicator--horizontal .el-carousel__button) {
width: 20px;
height: 3px;
}
} }
</style> </style>
+32 -5
View File
@@ -15,9 +15,11 @@
<el-table-column label="创建时间" width="180"> <el-table-column label="创建时间" width="180">
<template #default="{ row }">{{ formatDate(row.created_at) }}</template> <template #default="{ row }">{{ formatDate(row.created_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column :label="$t('common.edit')" width="120"> <el-table-column label="操作" width="200">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="$router.push(`/orders/${row.id}`)">查看</el-button> <el-button link type="primary" size="small" @click="$router.push(`/orders/${row.id}`)">查看</el-button>
<el-button v-if="row.status === 'pending_payment' || row.status === 'pending_confirm'" link type="warning" size="small" @click="cancelOrder(row.id)">取消</el-button>
<el-button v-if="row.status === 'shipped'" link type="success" size="small" @click="confirmReceipt(row.id)">确认收货</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -27,6 +29,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { orderApi } from '../../api' import { orderApi } from '../../api'
import BackNav from '../../components/BackNav.vue' import BackNav from '../../components/BackNav.vue'
@@ -54,10 +57,34 @@ function formatDate(date: string) {
return `${year}-${month}-${day} ${hours}:${minutes}` return `${year}-${month}-${day} ${hours}:${minutes}`
} }
onMounted(async () => { async function fetchOrders() {
const res: any = await orderApi.list() try {
orders.value = res.data || [] const res: any = await orderApi.list()
}) orders.value = res.data || []
} catch {
ElMessage.error('获取订单失败')
}
}
async function cancelOrder(id: number) {
try {
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', { type: 'warning' })
await orderApi.cancel(id)
ElMessage.success('订单已取消')
fetchOrders()
} catch {}
}
async function confirmReceipt(id: number) {
try {
await ElMessageBox.confirm('确认已收到商品?', '提示', { type: 'info' })
await orderApi.confirmReceipt(id)
ElMessage.success('已确认收货')
fetchOrders()
} catch {}
}
onMounted(fetchOrders)
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
+1 -2
View File
@@ -94,8 +94,7 @@ onMounted(async () => {
<style scoped lang="scss"> <style scoped lang="scss">
.products-page { .products-page {
max-width: 1200px; padding: 0;
margin: 0 auto;
} }
.page-header { .page-header {
+166
View File
@@ -0,0 +1,166 @@
<template>
<div class="ticket-detail-page">
<BackNav />
<div v-if="ticket" class="ticket-card">
<div class="ticket-header">
<h2>{{ ticket.subject }}</h2>
<el-tag :type="statusType(ticket.status)">{{ statusText(ticket.status) }}</el-tag>
</div>
<div class="ticket-meta">
<span>分类{{ ticket.category }}</span>
<span>创建时间{{ formatDate(ticket.created_at) }}</span>
</div>
<div class="ticket-content">
<p>{{ ticket.content }}</p>
</div>
<div v-if="ticket.reply" class="reply-section">
<h3>回复</h3>
<div class="reply-content" v-html="formatReply(ticket.reply)"></div>
</div>
<div v-if="ticket.status !== 'resolved' && ticket.status !== 'closed'" class="reply-form">
<h3>追加回复</h3>
<el-input v-model="replyContent" type="textarea" :rows="4" placeholder="请输入回复内容" />
<el-button type="primary" @click="submitReply" :loading="submitting" style="margin-top: 12px">提交回复</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ticketApi } from '../../api'
import BackNav from '../../components/BackNav.vue'
const route = useRoute()
const ticket = ref<any>(null)
const replyContent = ref('')
const submitting = ref(false)
const statusMap: Record<string, string> = {
pending: '待处理', processing: '处理中', resolved: '已解决', closed: '已关闭',
}
const statusTypeMap: Record<string, string> = {
pending: 'warning', processing: 'primary', resolved: 'success', closed: 'info',
}
function statusText(s: string) { return statusMap[s] || s }
function statusType(s: string) { return statusTypeMap[s] || 'info' }
function formatDate(date: string) {
if (!date) return '-'
const d = new Date(date)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatReply(reply: string) {
return reply.replace(/\n/g, '<br>').replace(/---/g, '<hr style="border-color: rgba(255,255,255,0.1); margin: 12px 0;">')
}
async function fetchTicket() {
try {
const id = Number(route.params.id)
const res: any = await ticketApi.getById(id)
ticket.value = res.data
} catch {
ElMessage.error('获取工单失败')
}
}
async function submitReply() {
if (!replyContent.value.trim()) {
ElMessage.warning('请输入回复内容')
return
}
submitting.value = true
try {
await ticketApi.reply(Number(route.params.id), { content: replyContent.value })
ElMessage.success('回复成功')
replyContent.value = ''
fetchTicket()
} catch {
ElMessage.error('回复失败')
} finally {
submitting.value = false
}
}
onMounted(fetchTicket)
</script>
<style scoped lang="scss">
.ticket-detail-page { padding: 0; }
.ticket-card {
background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 24px;
}
.ticket-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
h2 {
font-size: 18px;
font-weight: 600;
color: #fff;
margin: 0;
}
}
.ticket-meta {
display: flex;
gap: 16px;
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 16px;
}
.ticket-content {
padding: 16px;
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.6;
margin-bottom: 20px;
}
.reply-section {
margin-bottom: 20px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
h3 {
font-size: 15px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 12px;
}
}
.reply-content {
padding: 16px;
background: rgba(78, 110, 242, 0.08);
border-radius: 8px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.6;
}
.reply-form {
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
h3 {
font-size: 15px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 12px;
}
}
</style>
+6 -1
View File
@@ -7,7 +7,7 @@
<div class="table-card"> <div class="table-card">
<el-table :data="tickets"> <el-table :data="tickets">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" :label="$t('ticket.subject')" /> <el-table-column prop="subject" :label="$t('ticket.subject')" />
<el-table-column prop="status" :label="$t('ticket.status')" width="120"> <el-table-column prop="status" :label="$t('ticket.status')" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag> <el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
@@ -16,6 +16,11 @@
<el-table-column label="创建时间" width="180"> <el-table-column label="创建时间" width="180">
<template #default="{ row }">{{ formatDate(row.created_at) }}</template> <template #default="{ row }">{{ formatDate(row.created_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="$router.push(`/tickets/${row.id}`)">查看</el-button>
</template>
</el-table-column>
</el-table> </el-table>
</div> </div>
</div> </div>
+1 -1
View File
@@ -108,7 +108,7 @@ def main():
print("请先创建管理员账户") print("请先创建管理员账户")
return return
with open('ribenyan_products.json', 'r', encoding='utf-8') as f: with open('../ribenyan_products.json', 'r', encoding='utf-8') as f:
products = json.load(f) products = json.load(f)
print(f"读取到 {len(products)} 个商品") print(f"读取到 {len(products)} 个商品")
+81
View File
@@ -0,0 +1,81 @@
import sqlite3
from datetime import datetime
# 连接数据库
conn = sqlite3.connect('../backend/cmd/server/sale.db')
cursor = conn.cursor()
# 轮播图测试数据
banners = [
{
'title': '新品上市',
'desc': '精选优质商品,限时特惠',
'image': 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=1920&h=400&fit=crop',
'link': '/products',
'button': '立即选购',
'bg_color': 'linear-gradient(135deg, #4e6ef2 0%, #7c5cfc 100%)',
'sort_order': 1,
'is_active': 1
},
{
'title': '幸运抽奖',
'desc': '参与抽奖赢取好礼',
'image': 'https://images.unsplash.com/photo-1511895426328-dc8714191300?w=1920&h=400&fit=crop',
'link': '/lotteries',
'button': '参与活动',
'bg_color': 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)',
'sort_order': 2,
'is_active': 1
},
{
'title': '限时秒杀',
'desc': '每日精选,超值优惠',
'image': 'https://images.unsplash.com/photo-1607082348824-0a96f2a4b9da?w=1920&h=400&fit=crop',
'link': '/products',
'button': '查看详情',
'bg_color': 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
'sort_order': 3,
'is_active': 1
},
{
'title': '会员专享',
'desc': '注册即送积分,享受更多优惠',
'image': 'https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?w=1920&h=400&fit=crop',
'link': '/register',
'button': '立即注册',
'bg_color': 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)',
'sort_order': 4,
'is_active': 1
}
]
# 插入数据
for banner in banners:
cursor.execute('''
INSERT INTO banners (title, desc, image, link, button, bg_color, sort_order, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
banner['title'],
banner['desc'],
banner['image'],
banner['link'],
banner['button'],
banner['bg_color'],
banner['sort_order'],
banner['is_active'],
datetime.now().isoformat(),
datetime.now().isoformat()
))
conn.commit()
# 查询验证
cursor.execute('SELECT id, title, link, is_active FROM banners ORDER BY sort_order')
results = cursor.fetchall()
print(f"✅ 成功插入 {len(banners)} 条轮播图数据:\n")
for row in results:
status = "启用" if row[3] else "禁用"
print(f"ID: {row[0]}, 标题: {row[1]}, 链接: {row[2]}, 状态: {status}")
conn.close()
+207
View File
@@ -0,0 +1,207 @@
import sqlite3
import random
from datetime import datetime, timedelta
# 连接数据库
conn = sqlite3.connect('e:/Code/sale/backend/cmd/server/sale.db')
cursor = conn.cursor()
# 抽奖活动数据
lotteries = [
{
'name': '新年大抽奖',
'description': '参与新年抽奖,赢取丰厚奖品!iPhone、iPad、AirPods等你来拿!',
'image': 'https://images.unsplash.com/photo-1513151233558-d860c5398176?w=400&h=300&fit=crop',
'start_time': datetime.now().isoformat(),
'end_time': (datetime.now() + timedelta(days=30)).isoformat(),
'cycle': 'daily',
'daily_quota': 100,
'total_quota': 3000,
'registration_validity': 7,
'is_active': 1
},
{
'name': '会员专属抽奖',
'description': '会员专属福利,积分抽奖赢好礼!',
'image': 'https://images.unsplash.com/photo-1511895426328-dc8714191300?w=400&h=300&fit=crop',
'start_time': datetime.now().isoformat(),
'end_time': (datetime.now() + timedelta(days=60)).isoformat(),
'cycle': 'weekly',
'daily_quota': 50,
'total_quota': 1000,
'registration_validity': 14,
'is_active': 1
},
{
'name': '春季特惠抽奖',
'description': '春季特惠活动,购物即可参与抽奖!',
'image': 'https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=400&h=300&fit=crop',
'start_time': datetime.now().isoformat(),
'end_time': (datetime.now() + timedelta(days=45)).isoformat(),
'cycle': 'monthly',
'daily_quota': 200,
'total_quota': 5000,
'registration_validity': 30,
'is_active': 1
},
{
'name': '限时秒杀抽奖',
'description': '限时秒杀活动,参与抽奖享更多优惠!',
'image': 'https://images.unsplash.com/photo-1607082348824-0a96f2a4b9da?w=400&h=300&fit=crop',
'start_time': datetime.now().isoformat(),
'end_time': (datetime.now() + timedelta(days=15)).isoformat(),
'cycle': 'daily',
'daily_quota': 150,
'total_quota': 2000,
'registration_validity': 5,
'is_active': 1
},
{
'name': '品牌联合抽奖',
'description': '多个品牌联合抽奖,奖品更丰厚!',
'image': 'https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?w=400&h=300&fit=crop',
'start_time': datetime.now().isoformat(),
'end_time': (datetime.now() + timedelta(days=90)).isoformat(),
'cycle': 'weekly',
'daily_quota': 80,
'total_quota': 1500,
'registration_validity': 10,
'is_active': 1
}
]
# 插入抽奖活动
for lottery in lotteries:
cursor.execute('''
INSERT INTO lotteries (name, description, image, start_time, end_time, cycle, daily_quota, total_quota, registration_validity, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
lottery['name'],
lottery['description'],
lottery['image'],
lottery['start_time'],
lottery['end_time'],
lottery['cycle'],
lottery['daily_quota'],
lottery['total_quota'],
lottery['registration_validity'],
lottery['is_active'],
datetime.now().isoformat(),
datetime.now().isoformat()
))
print(f"✅ 成功插入 {len(lotteries)} 个抽奖活动")
# 资讯数据
articles = [
{
'title': '2024年电商行业发展趋势分析',
'content': '随着技术的不断发展,电商行业正在经历前所未有的变革。本文将深入分析2024年电商行业的发展趋势,包括人工智能、社交电商、直播带货等多个方面。人工智能技术的应用让个性化推荐更加精准,社交电商正在改变传统的购物方式,直播带货已经成为品牌营销的重要渠道。',
'summary': '深入分析2024年电商行业发展趋势,涵盖AI、社交电商、直播带货等热门话题',
'cover_image': 'https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?w=800&h=400&fit=crop',
'is_published': 1,
'is_pinned': 1,
'sort_order': 1
},
{
'title': '如何选择适合自己的商品',
'content': '在众多商品中选择适合自己的产品并不容易。本文将从价格、质量、品牌、用户评价等多个维度,为您提供详细的选购指南。首先,要明确自己的需求和预算;其次,要关注商品的质量和售后服务;最后,要参考其他用户的评价和建议。',
'summary': '从价格、质量、品牌等多维度提供商品选购指南',
'cover_image': 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop',
'is_published': 1,
'is_pinned': 0,
'sort_order': 2
},
{
'title': '平台优惠活动全攻略',
'content': '想要在购物时获得更多优惠?本文为您详细介绍平台的各种优惠活动,包括满减、折扣、优惠券、积分兑换等多种方式。了解这些优惠规则,让您在购物时省更多钱。满减活动通常在特定节日举行,折扣商品需要及时关注,优惠券可以通过多种渠道获取。',
'summary': '详细介绍平台满减、折扣、优惠券等各种优惠活动',
'cover_image': 'https://images.unsplash.com/photo-1607082348824-0a96f2a4b9da?w=800&h=400&fit=crop',
'is_published': 1,
'is_pinned': 0,
'sort_order': 3
},
{
'title': '新品上市:春季系列商品推荐',
'content': '春季新品已经上市!本文为您推荐本季最热门的商品系列,包括服装、配饰、家居用品等多个品类。春季系列以清新、活力为主题,采用最新的设计理念和优质材料,为您带来全新的使用体验。',
'summary': '推荐春季最热门的新品系列,涵盖多个品类',
'cover_image': 'https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=800&h=400&fit=crop',
'is_published': 1,
'is_pinned': 1,
'sort_order': 4
},
{
'title': '用户购物安全指南',
'content': '在网上购物时,安全是最重要的。本文为您提供全面的购物安全指南,包括如何识别假冒商品、保护个人信息、安全支付等多个方面。建议您选择官方渠道购买商品,注意查看商品评价和卖家信誉,使用安全的支付方式。',
'summary': '提供全面的网购安全指南,保护您的购物安全',
'cover_image': 'https://images.unsplash.com/photo-1563013544-824ae1b704d3?w=800&h=400&fit=crop',
'is_published': 1,
'is_pinned': 0,
'sort_order': 5
},
{
'title': '积分使用技巧大公开',
'content': '积分是平台会员的重要福利,但很多人不知道如何高效使用积分。本文将为您详细介绍积分的获取方式和使用技巧,让您在购物时获得更多实惠。积分可以通过购物、签到、参与活动等多种方式获取,可以用来兑换商品、抵扣现金等。',
'summary': '详细介绍积分获取方式和使用技巧',
'cover_image': 'https://images.unsplash.com/photo-1556742111-a301076d9c18?w=800&h=400&fit=crop',
'is_published': 1,
'is_pinned': 0,
'sort_order': 6
},
{
'title': '售后服务政策详解',
'content': '了解平台的售后服务政策,让您购物更放心。本文详细介绍了退换货政策、质量保证、维修服务等多个方面。我们承诺7天无理由退换货,30天质量问题包退,1年质量问题保修。让您购物无忧。',
'summary': '详细介绍平台退换货、质量保证等售后服务政策',
'cover_image': 'https://images.unsplash.com/photo-1553877522-43269d4ea984?w=800&h=400&fit=crop',
'is_published': 1,
'is_pinned': 0,
'sort_order': 7
},
{
'title': '会员等级权益全解析',
'content': '平台会员分为多个等级,不同等级享受不同的权益。本文为您详细解析各个等级的权益,包括折扣力度、积分倍率、专属客服等。等级越高,享受的权益越多。升级方式包括购物金额、积分数量、活跃度等多个维度。',
'summary': '详细解析各等级会员权益和升级方式',
'cover_image': 'https://images.unsplash.com/photo-1511895426328-dc8714191300?w=800&h=400&fit=crop',
'is_published': 1,
'is_pinned': 0,
'sort_order': 8
}
]
# 插入资讯
for article in articles:
cursor.execute('''
INSERT INTO articles (title, content, summary, cover_image, is_published, is_pinned, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
article['title'],
article['content'],
article['summary'],
article['cover_image'],
article['is_published'],
article['is_pinned'],
article['sort_order'],
datetime.now().isoformat(),
datetime.now().isoformat()
))
print(f"✅ 成功插入 {len(articles)} 篇资讯")
# 查询验证
cursor.execute('SELECT id, name, is_active FROM lotteries ORDER BY created_at DESC LIMIT 5')
lottery_results = cursor.fetchall()
cursor.execute('SELECT id, title, is_pinned FROM articles ORDER BY sort_order ASC LIMIT 5')
article_results = cursor.fetchall()
print("\n抽奖活动:")
for row in lottery_results:
status = "启用" if row[2] else "禁用"
print(f"ID: {row[0]}, 名称: {row[1]}, 状态: {status}")
print("\n资讯文章:")
for row in article_results:
pinned = "置顶" if row[2] else "普通"
print(f"ID: {row[0]}, 标题: {row[1]}, 状态: {pinned}")
conn.close()
+53
View File
@@ -0,0 +1,53 @@
import sqlite3
from datetime import datetime, timedelta
# 连接数据库
conn = sqlite3.connect('e:/Code/sale/backend/cmd/server/sale.db')
cursor = conn.cursor()
# 插入抽奖活动
lotteries_data = [
('新年大抽奖', '参与新年抽奖,赢取丰厚奖品!iPhone、iPad、AirPods等你来拿!', 'https://images.unsplash.com/photo-1513151233558-d860c5398176?w=400&h=300&fit=crop', 'daily', 100, 3000, 7),
('会员专属抽奖', '会员专属福利,积分抽奖赢好礼!', 'https://images.unsplash.com/photo-1511895426328-dc8714191300?w=400&h=300&fit=crop', 'weekly', 50, 1000, 14),
('春季特惠抽奖', '春季特惠活动,购物即可参与抽奖!', 'https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=400&h=300&fit=crop', 'monthly', 200, 5000, 30),
('限时秒杀抽奖', '限时秒杀活动,参与抽奖享更多优惠!', 'https://images.unsplash.com/photo-1607082348824-0a96f2a4b9da?w=400&h=300&fit=crop', 'daily', 150, 2000, 5),
('品牌联合抽奖', '多个品牌联合抽奖,奖品更丰厚!', 'https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?w=400&h=300&fit=crop', 'weekly', 80, 1500, 10)
]
for name, desc, image, cycle, daily_q, total_q, validity in lotteries_data:
cursor.execute('''
INSERT INTO lotteries (name, description, image, start_time, end_time, cycle, daily_quota, total_quota, registration_validity, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
''', (name, desc, image, datetime.now().isoformat(), (datetime.now() + timedelta(days=30)).isoformat(), cycle, daily_q, total_q, validity, datetime.now().isoformat(), datetime.now().isoformat()))
# 插入资讯
articles_data = [
('2024年电商行业发展趋势分析', '随着技术的不断发展,电商行业正在经历前所未有的变革。本文将深入分析2024年电商行业的发展趋势,包括人工智能、社交电商、直播带货等多个方面。', '深入分析2024年电商行业发展趋势', 'https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?w=800&h=400&fit=crop', 1, 1),
('如何选择适合自己的商品', '在众多商品中选择适合自己的产品并不容易。本文将从价格、质量、品牌、用户评价等多个维度,为您提供详细的选购指南。', '从价格、质量、品牌等多维度提供商品选购指南', 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop', 1, 0),
('平台优惠活动全攻略', '想要在购物时获得更多优惠?本文为您详细介绍平台的各种优惠活动,包括满减、折扣、优惠券、积分兑换等多种方式。', '详细介绍平台满减、折扣、优惠券等各种优惠活动', 'https://images.unsplash.com/photo-1607082348824-0a96f2a4b9da?w=800&h=400&fit=crop', 1, 0),
('新品上市:春季系列商品推荐', '春季新品已经上市!本文为您推荐本季最热门的商品系列,包括服装、配饰、家居用品等多个品类。', '推荐春季最热门的新品系列', 'https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=800&h=400&fit=crop', 1, 1),
('用户购物安全指南', '在网上购物时,安全是最重要的。本文为您提供全面的购物安全指南,包括如何识别假冒商品、保护个人信息、安全支付等多个方面。', '提供全面的网购安全指南', 'https://images.unsplash.com/photo-1563013544-824ae1b704d3?w=800&h=400&fit=crop', 1, 0),
('积分使用技巧大公开', '积分是平台会员的重要福利,但很多人不知道如何高效使用积分。本文将为您详细介绍积分的获取方式和使用技巧。', '详细介绍积分获取方式和使用技巧', 'https://images.unsplash.com/photo-1556742111-a301076d9c18?w=800&h=400&fit=crop', 1, 0),
('售后服务政策详解', '了解平台的售后服务政策,让您购物更放心。本文详细介绍了退换货政策、质量保证、维修服务等多个方面。', '详细介绍平台退换货、质量保证等售后服务政策', 'https://images.unsplash.com/photo-1553877522-43269d4ea984?w=800&h=400&fit=crop', 1, 0),
('会员等级权益全解析', '平台会员分为多个等级,不同等级享受不同的权益。本文为您详细解析各个等级的权益,包括折扣力度、积分倍率、专属客服等。', '详细解析各等级会员权益和升级方式', 'https://images.unsplash.com/photo-1511895426328-dc8714191300?w=800&h=400&fit=crop', 1, 0)
]
for i, (title, content, summary, cover, published, pinned) in enumerate(articles_data, 1):
cursor.execute('''
INSERT INTO articles (title, content, summary, cover_image, is_published, is_pinned, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (title, content, summary, cover, published, pinned, i, datetime.now().isoformat(), datetime.now().isoformat()))
conn.commit()
# 验证
cursor.execute('SELECT COUNT(*) FROM lotteries')
lottery_count = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM articles')
article_count = cursor.fetchone()[0]
print(f"✅ 成功插入 {lottery_count} 个抽奖活动")
print(f"✅ 成功插入 {article_count} 篇资讯")
conn.close()
+30
View File
@@ -0,0 +1,30 @@
import sqlite3
import bcrypt
# 连接数据库
conn = sqlite3.connect('../backend/cmd/server/sale.db')
cursor = conn.cursor()
# 生成密码哈希
password = 'admin123'
hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
# 更新管理员密码
cursor.execute('UPDATE users SET password_hash = ? WHERE email = ?', (hashed, 'admin@viaeon.com'))
conn.commit()
# 验证更新
cursor.execute('SELECT email, password_hash FROM users WHERE email = ?', ('admin@viaeon.com',))
result = cursor.fetchone()
print(f"邮箱: {result[0]}")
print(f"密码哈希: {result[1]}")
print(f"密码已更新为: {password}")
# 测试密码验证
if bcrypt.checkpw(password.encode('utf-8'), result[1].encode('utf-8')):
print("✅ 密码验证成功!")
else:
print("❌ 密码验证失败!")
conn.close()