feat(marketing): 新增营销图管理模块
- 新增 marketing 模块:model/repository/service/handler 四层架构 - 数据模型:marketing_categories、marketing_templates、marketing_user_downloads - 小程序端接口:分类列表、模板列表/详情、下载记录、广告回调 - 管理后台接口:分类/模板 CRUD、下载统计(X-Admin-Token 鉴权) - 路由注册:接入现有 AuthMiddleware,新增 AdminTokenMiddleware - Web 管理后台:单页面 Vue3 + Element Plus(分类管理、模板管理、数据概览) Closes #37, #38, #39, #40 Made-with: Cursor
This commit is contained in:
+18
-1
@@ -19,6 +19,10 @@ import (
|
|||||||
expiry "wx_service/internal/expiry"
|
expiry "wx_service/internal/expiry"
|
||||||
lawyerhandler "wx_service/internal/lawyer/handler"
|
lawyerhandler "wx_service/internal/lawyer/handler"
|
||||||
lawyerservice "wx_service/internal/lawyer/service"
|
lawyerservice "wx_service/internal/lawyer/service"
|
||||||
|
marketinghandler "wx_service/internal/marketing/handler"
|
||||||
|
marketingmodel "wx_service/internal/marketing/model"
|
||||||
|
marketingrepo "wx_service/internal/marketing/repository"
|
||||||
|
marketingservice "wx_service/internal/marketing/service"
|
||||||
membershiphandler "wx_service/internal/membership/handler"
|
membershiphandler "wx_service/internal/membership/handler"
|
||||||
membershipmodel "wx_service/internal/membership/model"
|
membershipmodel "wx_service/internal/membership/model"
|
||||||
membershipservice "wx_service/internal/membership/service"
|
membershipservice "wx_service/internal/membership/service"
|
||||||
@@ -65,6 +69,9 @@ func main() {
|
|||||||
&smokemodel.SmokeAIAdviceUnlock{},
|
&smokemodel.SmokeAIAdviceUnlock{},
|
||||||
&smokemodel.SmokeAINextSmoke{},
|
&smokemodel.SmokeAINextSmoke{},
|
||||||
&smokemodel.SmokeMotivationQuote{},
|
&smokemodel.SmokeMotivationQuote{},
|
||||||
|
&marketingmodel.MarketingCategory{},
|
||||||
|
&marketingmodel.MarketingTemplate{},
|
||||||
|
&marketingmodel.MarketingDownload{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Fatalf("auto migrate failed: %v", err)
|
log.Fatalf("auto migrate failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -127,8 +134,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
expiryHandler := expiry.NewHandler(expiryService)
|
expiryHandler := expiry.NewHandler(expiryService)
|
||||||
|
|
||||||
|
categoryRepo := marketingrepo.NewCategoryRepository(database.DB)
|
||||||
|
templateRepo := marketingrepo.NewTemplateRepository(database.DB)
|
||||||
|
downloadRepo := marketingrepo.NewDownloadRepository(database.DB)
|
||||||
|
categorySvc := marketingservice.NewCategoryService(categoryRepo)
|
||||||
|
templateSvc := marketingservice.NewTemplateService(templateRepo)
|
||||||
|
downloadSvc := marketingservice.NewDownloadService(downloadRepo, templateRepo)
|
||||||
|
marketingCategoryHandler := marketinghandler.NewCategoryHandler(categorySvc)
|
||||||
|
marketingTemplateHandler := marketinghandler.NewTemplateHandler(templateSvc)
|
||||||
|
marketingDownloadHandler := marketinghandler.NewDownloadHandler(downloadSvc)
|
||||||
|
|
||||||
// 6) 注册路由:把 URL 映射到 handler
|
// 6) 注册路由:把 URL 映射到 handler
|
||||||
routes.Register(router, database.DB, authHandler, videoHandler, smokeHandler, redeemCodeHandler, uploadHandler, oaOAuthHandler, sessionCache, lawyerHandler, expiryHandler)
|
routes.Register(router, database.DB, authHandler, videoHandler, smokeHandler, redeemCodeHandler, uploadHandler, oaOAuthHandler, sessionCache, lawyerHandler, expiryHandler, config.AppConfig.Admin.Token, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler)
|
||||||
|
|
||||||
// 7) 启动监听端口
|
// 7) 启动监听端口
|
||||||
addr := ":" + config.AppConfig.Server.Port
|
addr := ":" + config.AppConfig.Server.Port
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"wx_service/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AdminTokenMiddleware(adminToken string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if adminToken == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "未配置管理员口令"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := c.GetHeader("X-Admin-Token")
|
||||||
|
if token == "" || token != adminToken {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "无权限"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"wx_service/internal/marketing/service"
|
||||||
|
"wx_service/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CategoryHandler struct {
|
||||||
|
svc *service.CategoryService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCategoryHandler(svc *service.CategoryService) *CategoryHandler {
|
||||||
|
return &CategoryHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEnabled returns enabled categories for mini-program users.
|
||||||
|
func (h *CategoryHandler) ListEnabled(c *gin.Context) {
|
||||||
|
categories, err := h.svc.List(true)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(categories))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminList returns all categories (including disabled) for admin.
|
||||||
|
func (h *CategoryHandler) AdminList(c *gin.Context) {
|
||||||
|
categories, err := h.svc.List(false)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(categories))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CategoryHandler) AdminCreate(c *gin.Context) {
|
||||||
|
var req service.CategoryRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cat, err := h.svc.Create(req)
|
||||||
|
if err != nil {
|
||||||
|
if service.IsBadRequestError(err) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Success(cat))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CategoryHandler) AdminUpdate(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req service.CategoryRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cat, err := h.svc.Update(id, req)
|
||||||
|
if err != nil {
|
||||||
|
if service.IsNotFoundError(err) {
|
||||||
|
c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "分类不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if service.IsBadRequestError(err) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Success(cat))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CategoryHandler) AdminDelete(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.svc.Delete(id); err != nil {
|
||||||
|
if service.IsNotFoundError(err) {
|
||||||
|
c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "分类不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if service.IsBadRequestError(err) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Success(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseID(c *gin.Context) (uint, bool) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "id 参数错误"))
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint(id), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"wx_service/internal/marketing/service"
|
||||||
|
"wx_service/internal/middleware"
|
||||||
|
"wx_service/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DownloadHandler struct {
|
||||||
|
svc *service.DownloadService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDownloadHandler(svc *service.DownloadService) *DownloadHandler {
|
||||||
|
return &DownloadHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DownloadHandler) Create(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
|
||||||
|
var req service.CreateDownloadRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dl, err := h.svc.Create(user.ID, req)
|
||||||
|
if err != nil {
|
||||||
|
if service.IsBadRequestError(err) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Success(dl))
|
||||||
|
}
|
||||||
|
|
||||||
|
type adCallbackRequest struct {
|
||||||
|
DownloadID uint `json:"download_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DownloadHandler) AdCallback(c *gin.Context) {
|
||||||
|
var req adCallbackRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.svc.MarkAdCompleted(req.DownloadID); err != nil {
|
||||||
|
if service.IsNotFoundError(err) {
|
||||||
|
c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "记录不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Success(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DownloadHandler) ListByUser(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
page := parseIntQuery(c, "page", 1)
|
||||||
|
pageSize := parseIntQuery(c, "page_size", 20)
|
||||||
|
|
||||||
|
resp, err := h.svc.ListByUser(user.ID, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Success(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DownloadHandler) AdminStats(c *gin.Context) {
|
||||||
|
stats, err := h.svc.GetStats()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(stats))
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"wx_service/internal/marketing/repository"
|
||||||
|
"wx_service/internal/marketing/service"
|
||||||
|
"wx_service/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TemplateHandler struct {
|
||||||
|
svc *service.TemplateService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTemplateHandler(svc *service.TemplateService) *TemplateHandler {
|
||||||
|
return &TemplateHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEnabled returns enabled templates for mini-program users.
|
||||||
|
func (h *TemplateHandler) ListEnabled(c *gin.Context) {
|
||||||
|
categoryID := parseUintQuery(c, "category_id", 0)
|
||||||
|
page := parseIntQuery(c, "page", 1)
|
||||||
|
pageSize := parseIntQuery(c, "page_size", 20)
|
||||||
|
|
||||||
|
resp, err := h.svc.List(repository.TemplateListParams{
|
||||||
|
CategoryID: categoryID,
|
||||||
|
OnlyEnabled: true,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDetail returns a single template by ID.
|
||||||
|
func (h *TemplateHandler) GetDetail(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl, err := h.svc.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
if service.IsNotFoundError(err) {
|
||||||
|
c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "模板不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(tpl))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminList returns all templates for admin.
|
||||||
|
func (h *TemplateHandler) AdminList(c *gin.Context) {
|
||||||
|
categoryID := parseUintQuery(c, "category_id", 0)
|
||||||
|
page := parseIntQuery(c, "page", 1)
|
||||||
|
pageSize := parseIntQuery(c, "page_size", 20)
|
||||||
|
|
||||||
|
resp, err := h.svc.List(repository.TemplateListParams{
|
||||||
|
CategoryID: categoryID,
|
||||||
|
OnlyEnabled: false,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TemplateHandler) AdminCreate(c *gin.Context) {
|
||||||
|
var req service.TemplateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl, err := h.svc.Create(req)
|
||||||
|
if err != nil {
|
||||||
|
if service.IsBadRequestError(err) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Success(tpl))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TemplateHandler) AdminUpdate(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req service.TemplateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl, err := h.svc.Update(id, req)
|
||||||
|
if err != nil {
|
||||||
|
if service.IsNotFoundError(err) {
|
||||||
|
c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "模板不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if service.IsBadRequestError(err) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Success(tpl))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TemplateHandler) AdminDelete(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.svc.Delete(id); err != nil {
|
||||||
|
if service.IsNotFoundError(err) {
|
||||||
|
c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "模板不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Success(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntQuery(c *gin.Context, key string, defaultValue int) int {
|
||||||
|
v := c.Query(key)
|
||||||
|
if v == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUintQuery(c *gin.Context, key string, defaultValue uint) uint {
|
||||||
|
v := c.Query(key)
|
||||||
|
if v == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseUint(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return uint(parsed)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type MarketingCategory struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey;comment:主键ID"`
|
||||||
|
Name string `json:"name" gorm:"size:50;not null;comment:分类名称"`
|
||||||
|
SortOrder int `json:"sort_order" gorm:"default:0;comment:排序权重(越大越靠前)"`
|
||||||
|
Icon string `json:"icon" gorm:"size:500;comment:图标URL"`
|
||||||
|
Status int `json:"status" gorm:"default:1;index;comment:状态(1=启用,0=禁用)"`
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"comment:更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MarketingCategory) TableName() string {
|
||||||
|
return "marketing_categories"
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type MarketingDownload struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey;comment:主键ID"`
|
||||||
|
UserID uint `json:"user_id" gorm:"not null;index;comment:用户ID"`
|
||||||
|
TemplateID uint `json:"template_id" gorm:"not null;index;comment:模板ID"`
|
||||||
|
LogoURL string `json:"logo_url" gorm:"size:500;comment:用户Logo地址"`
|
||||||
|
LogoX float64 `json:"logo_x" gorm:"comment:Logo X坐标(相对比例)"`
|
||||||
|
LogoY float64 `json:"logo_y" gorm:"comment:Logo Y坐标(相对比例)"`
|
||||||
|
LogoW float64 `json:"logo_w" gorm:"comment:Logo 宽度(相对比例)"`
|
||||||
|
LogoH float64 `json:"logo_h" gorm:"comment:Logo 高度(相对比例)"`
|
||||||
|
AdCompleted bool `json:"ad_completed" gorm:"default:false;comment:是否已观看广告"`
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"`
|
||||||
|
|
||||||
|
Template *MarketingTemplate `json:"template,omitempty" gorm:"foreignKey:TemplateID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MarketingDownload) TableName() string {
|
||||||
|
return "marketing_user_downloads"
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type MarketingTemplate struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey;comment:主键ID"`
|
||||||
|
CategoryID uint `json:"category_id" gorm:"not null;index;comment:所属分类ID"`
|
||||||
|
Title string `json:"title" gorm:"size:100;not null;comment:模板名称"`
|
||||||
|
ImageURL string `json:"image_url" gorm:"size:500;not null;comment:模板图片URL"`
|
||||||
|
ThumbnailURL string `json:"thumbnail_url" gorm:"size:500;comment:缩略图URL"`
|
||||||
|
Width int `json:"width" gorm:"default:0;comment:图片宽度px"`
|
||||||
|
Height int `json:"height" gorm:"default:0;comment:图片高度px"`
|
||||||
|
SortOrder int `json:"sort_order" gorm:"default:0;comment:排序权重(越大越靠前)"`
|
||||||
|
Status int `json:"status" gorm:"default:1;index;comment:状态(1=启用,0=禁用)"`
|
||||||
|
DownloadCount int `json:"download_count" gorm:"default:0;comment:下载次数"`
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"comment:更新时间"`
|
||||||
|
|
||||||
|
Category *MarketingCategory `json:"category,omitempty" gorm:"foreignKey:CategoryID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MarketingTemplate) TableName() string {
|
||||||
|
return "marketing_templates"
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"wx_service/internal/marketing/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrCategoryNotFound = errors.New("marketing category not found")
|
||||||
|
|
||||||
|
type CategoryRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
|
||||||
|
return &CategoryRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CategoryRepository) Create(cat *model.MarketingCategory) error {
|
||||||
|
if err := r.db.Create(cat).Error; err != nil {
|
||||||
|
return fmt.Errorf("create marketing category: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CategoryRepository) Update(cat *model.MarketingCategory) error {
|
||||||
|
tx := r.db.Model(cat).Updates(map[string]interface{}{
|
||||||
|
"name": cat.Name,
|
||||||
|
"sort_order": cat.SortOrder,
|
||||||
|
"icon": cat.Icon,
|
||||||
|
"status": cat.Status,
|
||||||
|
})
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("update marketing category: %w", tx.Error)
|
||||||
|
}
|
||||||
|
if tx.RowsAffected == 0 {
|
||||||
|
return ErrCategoryNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CategoryRepository) Delete(id uint) error {
|
||||||
|
tx := r.db.Delete(&model.MarketingCategory{}, id)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("delete marketing category: %w", tx.Error)
|
||||||
|
}
|
||||||
|
if tx.RowsAffected == 0 {
|
||||||
|
return ErrCategoryNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CategoryRepository) FindByID(id uint) (*model.MarketingCategory, error) {
|
||||||
|
var cat model.MarketingCategory
|
||||||
|
err := r.db.First(&cat, id).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrCategoryNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find marketing category: %w", err)
|
||||||
|
}
|
||||||
|
return &cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAll returns all categories. If onlyEnabled is true, only status=1 records are returned.
|
||||||
|
func (r *CategoryRepository) FindAll(onlyEnabled bool) ([]model.MarketingCategory, error) {
|
||||||
|
query := r.db.Model(&model.MarketingCategory{})
|
||||||
|
if onlyEnabled {
|
||||||
|
query = query.Where("status = ?", 1)
|
||||||
|
}
|
||||||
|
query = query.Order("sort_order DESC, id ASC")
|
||||||
|
|
||||||
|
var categories []model.MarketingCategory
|
||||||
|
if err := query.Find(&categories).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("list marketing categories: %w", err)
|
||||||
|
}
|
||||||
|
return categories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CategoryRepository) HasTemplates(categoryID uint) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.MarketingTemplate{}).Where("category_id = ?", categoryID).Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("count templates by category: %w", err)
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"wx_service/internal/marketing/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrDownloadNotFound = errors.New("marketing download not found")
|
||||||
|
|
||||||
|
type DownloadRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDownloadRepository(db *gorm.DB) *DownloadRepository {
|
||||||
|
return &DownloadRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) Create(dl *model.MarketingDownload) error {
|
||||||
|
if err := r.db.Create(dl).Error; err != nil {
|
||||||
|
return fmt.Errorf("create marketing download: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) FindByID(id uint) (*model.MarketingDownload, error) {
|
||||||
|
var dl model.MarketingDownload
|
||||||
|
err := r.db.First(&dl, id).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrDownloadNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find marketing download: %w", err)
|
||||||
|
}
|
||||||
|
return &dl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) MarkAdCompleted(id uint) error {
|
||||||
|
tx := r.db.Model(&model.MarketingDownload{}).Where("id = ?", id).Update("ad_completed", true)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("mark ad completed: %w", tx.Error)
|
||||||
|
}
|
||||||
|
if tx.RowsAffected == 0 {
|
||||||
|
return ErrDownloadNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) FindByUser(userID uint, page, pageSize int) ([]model.MarketingDownload, int64, error) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.db.Model(&model.MarketingDownload{}).Where("user_id = ?", userID)
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count marketing downloads: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloads []model.MarketingDownload
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
if err := query.Preload("Template").Order("id DESC").Offset(offset).Limit(pageSize).Find(&downloads).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("list marketing downloads: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloads, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadStats struct {
|
||||||
|
TotalDownloads int64
|
||||||
|
TodayDownloads int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DownloadRepository) GetStats() (*DownloadStats, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&model.MarketingDownload{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("count total downloads: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var today int64
|
||||||
|
if err := r.db.Model(&model.MarketingDownload{}).
|
||||||
|
Where("DATE(created_at) = CURDATE()").
|
||||||
|
Count(&today).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("count today downloads: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DownloadStats{TotalDownloads: total, TodayDownloads: today}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"wx_service/internal/marketing/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrTemplateNotFound = errors.New("marketing template not found")
|
||||||
|
|
||||||
|
type TemplateRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTemplateRepository(db *gorm.DB) *TemplateRepository {
|
||||||
|
return &TemplateRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TemplateRepository) Create(tpl *model.MarketingTemplate) error {
|
||||||
|
if err := r.db.Create(tpl).Error; err != nil {
|
||||||
|
return fmt.Errorf("create marketing template: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TemplateRepository) Update(tpl *model.MarketingTemplate) error {
|
||||||
|
tx := r.db.Model(tpl).Updates(map[string]interface{}{
|
||||||
|
"category_id": tpl.CategoryID,
|
||||||
|
"title": tpl.Title,
|
||||||
|
"image_url": tpl.ImageURL,
|
||||||
|
"thumbnail_url": tpl.ThumbnailURL,
|
||||||
|
"width": tpl.Width,
|
||||||
|
"height": tpl.Height,
|
||||||
|
"sort_order": tpl.SortOrder,
|
||||||
|
"status": tpl.Status,
|
||||||
|
})
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("update marketing template: %w", tx.Error)
|
||||||
|
}
|
||||||
|
if tx.RowsAffected == 0 {
|
||||||
|
return ErrTemplateNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TemplateRepository) Delete(id uint) error {
|
||||||
|
tx := r.db.Delete(&model.MarketingTemplate{}, id)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("delete marketing template: %w", tx.Error)
|
||||||
|
}
|
||||||
|
if tx.RowsAffected == 0 {
|
||||||
|
return ErrTemplateNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TemplateRepository) FindByID(id uint) (*model.MarketingTemplate, error) {
|
||||||
|
var tpl model.MarketingTemplate
|
||||||
|
err := r.db.Preload("Category").First(&tpl, id).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrTemplateNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find marketing template: %w", err)
|
||||||
|
}
|
||||||
|
return &tpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateListParams struct {
|
||||||
|
CategoryID uint
|
||||||
|
OnlyEnabled bool
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TemplateRepository) FindList(params TemplateListParams) ([]model.MarketingTemplate, int64, error) {
|
||||||
|
if params.Page <= 0 {
|
||||||
|
params.Page = 1
|
||||||
|
}
|
||||||
|
if params.PageSize <= 0 {
|
||||||
|
params.PageSize = 20
|
||||||
|
}
|
||||||
|
if params.PageSize > 100 {
|
||||||
|
params.PageSize = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.db.Model(&model.MarketingTemplate{})
|
||||||
|
if params.CategoryID > 0 {
|
||||||
|
query = query.Where("category_id = ?", params.CategoryID)
|
||||||
|
}
|
||||||
|
if params.OnlyEnabled {
|
||||||
|
query = query.Where("status = ?", 1)
|
||||||
|
}
|
||||||
|
query = query.Order("sort_order DESC, id DESC")
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count marketing templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var templates []model.MarketingTemplate
|
||||||
|
offset := (params.Page - 1) * params.PageSize
|
||||||
|
if err := query.Preload("Category").Offset(offset).Limit(params.PageSize).Find(&templates).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("list marketing templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TemplateRepository) IncrementDownloadCount(id uint) error {
|
||||||
|
tx := r.db.Model(&model.MarketingTemplate{}).Where("id = ?", id).
|
||||||
|
UpdateColumn("download_count", gorm.Expr("download_count + 1"))
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("increment download count: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"wx_service/internal/marketing/model"
|
||||||
|
"wx_service/internal/marketing/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrCategoryNameRequired = errors.New("分类名称不能为空")
|
||||||
|
ErrCategoryNameTooLong = errors.New("分类名称不能超过50个字符")
|
||||||
|
ErrCategoryHasTemplates = errors.New("该分类下还有模板,无法删除")
|
||||||
|
)
|
||||||
|
|
||||||
|
type CategoryService struct {
|
||||||
|
repo *repository.CategoryRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCategoryService(repo *repository.CategoryRepository) *CategoryService {
|
||||||
|
return &CategoryService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CategoryService) Create(req CategoryRequest) (*model.MarketingCategory, error) {
|
||||||
|
if err := validateCategoryRequest(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
status := 1
|
||||||
|
if req.Status != nil {
|
||||||
|
status = *req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
cat := &model.MarketingCategory{
|
||||||
|
Name: strings.TrimSpace(req.Name),
|
||||||
|
SortOrder: req.SortOrder,
|
||||||
|
Icon: strings.TrimSpace(req.Icon),
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Create(cat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CategoryService) Update(id uint, req CategoryRequest) (*model.MarketingCategory, error) {
|
||||||
|
if err := validateCategoryRequest(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cat, err := s.repo.FindByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cat.Name = strings.TrimSpace(req.Name)
|
||||||
|
cat.SortOrder = req.SortOrder
|
||||||
|
cat.Icon = strings.TrimSpace(req.Icon)
|
||||||
|
if req.Status != nil {
|
||||||
|
cat.Status = *req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Update(cat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CategoryService) Delete(id uint) error {
|
||||||
|
has, err := s.repo.HasTemplates(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
return ErrCategoryHasTemplates
|
||||||
|
}
|
||||||
|
return s.repo.Delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CategoryService) GetByID(id uint) (*model.MarketingCategory, error) {
|
||||||
|
return s.repo.FindByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CategoryService) List(onlyEnabled bool) ([]model.MarketingCategory, error) {
|
||||||
|
return s.repo.FindAll(onlyEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCategoryRequest(req CategoryRequest) error {
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
return ErrCategoryNameRequired
|
||||||
|
}
|
||||||
|
if len(name) > 50 {
|
||||||
|
return ErrCategoryNameTooLong
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"wx_service/internal/marketing/model"
|
||||||
|
"wx_service/internal/marketing/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDownloadTemplateRequired = errors.New("请选择模板")
|
||||||
|
)
|
||||||
|
|
||||||
|
type DownloadService struct {
|
||||||
|
downloadRepo *repository.DownloadRepository
|
||||||
|
templateRepo *repository.TemplateRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDownloadService(downloadRepo *repository.DownloadRepository, templateRepo *repository.TemplateRepository) *DownloadService {
|
||||||
|
return &DownloadService{
|
||||||
|
downloadRepo: downloadRepo,
|
||||||
|
templateRepo: templateRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateDownloadRequest struct {
|
||||||
|
TemplateID uint `json:"template_id"`
|
||||||
|
LogoURL string `json:"logo_url"`
|
||||||
|
LogoX float64 `json:"logo_x"`
|
||||||
|
LogoY float64 `json:"logo_y"`
|
||||||
|
LogoW float64 `json:"logo_w"`
|
||||||
|
LogoH float64 `json:"logo_h"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadListResponse struct {
|
||||||
|
Downloads []model.MarketingDownload `json:"downloads"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DownloadService) Create(userID uint, req CreateDownloadRequest) (*model.MarketingDownload, error) {
|
||||||
|
if req.TemplateID == 0 {
|
||||||
|
return nil, ErrDownloadTemplateRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
dl := &model.MarketingDownload{
|
||||||
|
UserID: userID,
|
||||||
|
TemplateID: req.TemplateID,
|
||||||
|
LogoURL: req.LogoURL,
|
||||||
|
LogoX: req.LogoX,
|
||||||
|
LogoY: req.LogoY,
|
||||||
|
LogoW: req.LogoW,
|
||||||
|
LogoH: req.LogoH,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.downloadRepo.Create(dl); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.templateRepo.IncrementDownloadCount(req.TemplateID)
|
||||||
|
return dl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DownloadService) MarkAdCompleted(downloadID uint) error {
|
||||||
|
return s.downloadRepo.MarkAdCompleted(downloadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DownloadService) ListByUser(userID uint, page, pageSize int) (*DownloadListResponse, error) {
|
||||||
|
downloads, total, err := s.downloadRepo.FindByUser(userID, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &DownloadListResponse{
|
||||||
|
Downloads: downloads,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DownloadService) GetStats() (*repository.DownloadStats, error) {
|
||||||
|
return s.downloadRepo.GetStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBadRequestError checks if the error is a client-side validation error.
|
||||||
|
func IsBadRequestError(err error) bool {
|
||||||
|
return errors.Is(err, ErrDownloadTemplateRequired) ||
|
||||||
|
errors.Is(err, ErrCategoryNameRequired) ||
|
||||||
|
errors.Is(err, ErrCategoryNameTooLong) ||
|
||||||
|
errors.Is(err, ErrCategoryHasTemplates) ||
|
||||||
|
errors.Is(err, ErrTemplateTitleRequired) ||
|
||||||
|
errors.Is(err, ErrTemplateTitleTooLong) ||
|
||||||
|
errors.Is(err, ErrTemplateImageRequired) ||
|
||||||
|
errors.Is(err, ErrTemplateCategoryRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNotFoundError checks if the error is a not-found error.
|
||||||
|
func IsNotFoundError(err error) bool {
|
||||||
|
return errors.Is(err, repository.ErrCategoryNotFound) ||
|
||||||
|
errors.Is(err, repository.ErrTemplateNotFound) ||
|
||||||
|
errors.Is(err, repository.ErrDownloadNotFound)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"wx_service/internal/marketing/model"
|
||||||
|
"wx_service/internal/marketing/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTemplateTitleRequired = errors.New("模板名称不能为空")
|
||||||
|
ErrTemplateTitleTooLong = errors.New("模板名称不能超过100个字符")
|
||||||
|
ErrTemplateImageRequired = errors.New("模板图片不能为空")
|
||||||
|
ErrTemplateCategoryRequired = errors.New("请选择所属分类")
|
||||||
|
)
|
||||||
|
|
||||||
|
type TemplateService struct {
|
||||||
|
repo *repository.TemplateRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTemplateService(repo *repository.TemplateRepository) *TemplateService {
|
||||||
|
return &TemplateService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateRequest struct {
|
||||||
|
CategoryID uint `json:"category_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateListResponse struct {
|
||||||
|
Templates []model.MarketingTemplate `json:"templates"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TemplateService) Create(req TemplateRequest) (*model.MarketingTemplate, error) {
|
||||||
|
if err := validateTemplateRequest(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
status := 1
|
||||||
|
if req.Status != nil {
|
||||||
|
status = *req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl := &model.MarketingTemplate{
|
||||||
|
CategoryID: req.CategoryID,
|
||||||
|
Title: strings.TrimSpace(req.Title),
|
||||||
|
ImageURL: strings.TrimSpace(req.ImageURL),
|
||||||
|
ThumbnailURL: strings.TrimSpace(req.ThumbnailURL),
|
||||||
|
Width: req.Width,
|
||||||
|
Height: req.Height,
|
||||||
|
SortOrder: req.SortOrder,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Create(tpl); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TemplateService) Update(id uint, req TemplateRequest) (*model.MarketingTemplate, error) {
|
||||||
|
if err := validateTemplateRequest(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl, err := s.repo.FindByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl.CategoryID = req.CategoryID
|
||||||
|
tpl.Title = strings.TrimSpace(req.Title)
|
||||||
|
tpl.ImageURL = strings.TrimSpace(req.ImageURL)
|
||||||
|
tpl.ThumbnailURL = strings.TrimSpace(req.ThumbnailURL)
|
||||||
|
tpl.Width = req.Width
|
||||||
|
tpl.Height = req.Height
|
||||||
|
tpl.SortOrder = req.SortOrder
|
||||||
|
if req.Status != nil {
|
||||||
|
tpl.Status = *req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Update(tpl); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TemplateService) Delete(id uint) error {
|
||||||
|
return s.repo.Delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TemplateService) GetByID(id uint) (*model.MarketingTemplate, error) {
|
||||||
|
return s.repo.FindByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TemplateService) List(params repository.TemplateListParams) (*TemplateListResponse, error) {
|
||||||
|
templates, total, err := s.repo.FindList(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &TemplateListResponse{
|
||||||
|
Templates: templates,
|
||||||
|
Total: total,
|
||||||
|
Page: params.Page,
|
||||||
|
PageSize: params.PageSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTemplateRequest(req TemplateRequest) error {
|
||||||
|
title := strings.TrimSpace(req.Title)
|
||||||
|
if title == "" {
|
||||||
|
return ErrTemplateTitleRequired
|
||||||
|
}
|
||||||
|
if len(title) > 100 {
|
||||||
|
return ErrTemplateTitleTooLong
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.ImageURL) == "" {
|
||||||
|
return ErrTemplateImageRequired
|
||||||
|
}
|
||||||
|
if req.CategoryID == 0 {
|
||||||
|
return ErrTemplateCategoryRequired
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
marketinghandler "wx_service/internal/marketing/handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerMarketingRoutes(
|
||||||
|
api *gin.RouterGroup,
|
||||||
|
protected *gin.RouterGroup,
|
||||||
|
adminToken string,
|
||||||
|
categoryHandler *marketinghandler.CategoryHandler,
|
||||||
|
templateHandler *marketinghandler.TemplateHandler,
|
||||||
|
downloadHandler *marketinghandler.DownloadHandler,
|
||||||
|
) {
|
||||||
|
if categoryHandler == nil || templateHandler == nil || downloadHandler == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
marketing := api.Group("/marketing")
|
||||||
|
{
|
||||||
|
marketing.GET("/categories", categoryHandler.ListEnabled)
|
||||||
|
marketing.GET("/templates", templateHandler.ListEnabled)
|
||||||
|
marketing.GET("/templates/:id", templateHandler.GetDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
protectedMarketing := protected.Group("/marketing")
|
||||||
|
{
|
||||||
|
protectedMarketing.POST("/downloads", downloadHandler.Create)
|
||||||
|
protectedMarketing.POST("/ad_callback", downloadHandler.AdCallback)
|
||||||
|
protectedMarketing.GET("/downloads", downloadHandler.ListByUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := api.Group("/admin/marketing")
|
||||||
|
admin.Use(marketinghandler.AdminTokenMiddleware(adminToken))
|
||||||
|
{
|
||||||
|
admin.GET("/categories", categoryHandler.AdminList)
|
||||||
|
admin.POST("/categories", categoryHandler.AdminCreate)
|
||||||
|
admin.PUT("/categories/:id", categoryHandler.AdminUpdate)
|
||||||
|
admin.DELETE("/categories/:id", categoryHandler.AdminDelete)
|
||||||
|
|
||||||
|
admin.GET("/templates", templateHandler.AdminList)
|
||||||
|
admin.POST("/templates", templateHandler.AdminCreate)
|
||||||
|
admin.PUT("/templates/:id", templateHandler.AdminUpdate)
|
||||||
|
admin.DELETE("/templates/:id", templateHandler.AdminDelete)
|
||||||
|
|
||||||
|
admin.GET("/stats", downloadHandler.AdminStats)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
oahandler "wx_service/internal/common/wechat_official/handler"
|
oahandler "wx_service/internal/common/wechat_official/handler"
|
||||||
expiryhandler "wx_service/internal/expiry"
|
expiryhandler "wx_service/internal/expiry"
|
||||||
lawyerhandler "wx_service/internal/lawyer/handler"
|
lawyerhandler "wx_service/internal/lawyer/handler"
|
||||||
|
marketinghandler "wx_service/internal/marketing/handler"
|
||||||
membershiphandler "wx_service/internal/membership/handler"
|
membershiphandler "wx_service/internal/membership/handler"
|
||||||
"wx_service/internal/middleware"
|
"wx_service/internal/middleware"
|
||||||
rmhandler "wx_service/internal/remove_watermark/handler"
|
rmhandler "wx_service/internal/remove_watermark/handler"
|
||||||
@@ -30,6 +31,10 @@ func Register(
|
|||||||
sessionCache *rediscache.SessionUserCache,
|
sessionCache *rediscache.SessionUserCache,
|
||||||
lawyerHandler *lawyerhandler.LawyerHandler,
|
lawyerHandler *lawyerhandler.LawyerHandler,
|
||||||
expiryHandler *expiryhandler.Handler,
|
expiryHandler *expiryhandler.Handler,
|
||||||
|
adminToken string,
|
||||||
|
marketingCategoryHandler *marketinghandler.CategoryHandler,
|
||||||
|
marketingTemplateHandler *marketinghandler.TemplateHandler,
|
||||||
|
marketingDownloadHandler *marketinghandler.DownloadHandler,
|
||||||
) {
|
) {
|
||||||
// Register 用来集中注册所有 HTTP 路由,便于工程结构更清晰:
|
// Register 用来集中注册所有 HTTP 路由,便于工程结构更清晰:
|
||||||
// - main 只负责初始化(配置/DB/依赖注入)
|
// - main 只负责初始化(配置/DB/依赖注入)
|
||||||
@@ -57,6 +62,8 @@ func Register(
|
|||||||
registerMembershipRoutes(protected, redeemCodeHandler)
|
registerMembershipRoutes(protected, redeemCodeHandler)
|
||||||
registerSmokeRoutes(protected, smokeHandler)
|
registerSmokeRoutes(protected, smokeHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerMarketingRoutes(api, protected, adminToken, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保质期提醒模块使用独立前缀 /api/expiry,与现有 /api/v1 并存。
|
// 保质期提醒模块使用独立前缀 /api/expiry,与现有 /api/v1 并存。
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>营销图管理后台</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus/dist/index.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/element-plus/dist/index.full.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/element-plus/dist/locale/zh-cn.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; }
|
||||||
|
.login-wrapper { display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||||
|
.login-card { width: 400px; }
|
||||||
|
.app-header { background: #fff; padding: 16px 24px; border-bottom: 1px solid #e8e8e8; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.app-header h2 { font-size: 18px; color: #1a1a1a; }
|
||||||
|
.app-body { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||||
|
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||||
|
.stat-card { background: #fff; border-radius: 8px; padding: 20px; text-align: center; }
|
||||||
|
.stat-card .num { font-size: 28px; font-weight: 700; color: #409eff; }
|
||||||
|
.stat-card .label { font-size: 13px; color: #999; margin-top: 4px; }
|
||||||
|
.section-card { background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 24px; }
|
||||||
|
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.section-header h3 { font-size: 16px; color: #1a1a1a; }
|
||||||
|
.tpl-thumb { width: 80px; height: 80px; object-fit: cover; border-radius: 4px; border: 1px solid #eee; }
|
||||||
|
.icon-preview { width: 32px; height: 32px; object-fit: contain; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Login -->
|
||||||
|
<div v-if="!authenticated" class="login-wrapper">
|
||||||
|
<el-card class="login-card">
|
||||||
|
<template #header><h3>营销图管理后台</h3></template>
|
||||||
|
<el-form @submit.prevent="login">
|
||||||
|
<el-form-item label="API 地址">
|
||||||
|
<el-input v-model="apiBase" placeholder="http://localhost:8080"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="管理员口令">
|
||||||
|
<el-input v-model="adminToken" type="password" placeholder="X-Admin-Token" @keyup.enter="login"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" @click="login" :loading="loginLoading" style="width:100%">登录</el-button>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="app-header">
|
||||||
|
<h2>营销图管理后台</h2>
|
||||||
|
<el-button text @click="logout">退出</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="app-body">
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="num">{{ stats.categoryCount }}</div>
|
||||||
|
<div class="label">分类总数</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="num">{{ stats.templateCount }}</div>
|
||||||
|
<div class="label">模板总数</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="num">{{ stats.totalDownloads }}</div>
|
||||||
|
<div class="label">总下载次数</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="num">{{ stats.todayDownloads }}</div>
|
||||||
|
<div class="label">今日下载</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>分类管理</h3>
|
||||||
|
<el-button type="primary" size="small" @click="openCategoryDialog()">新增分类</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="categories" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="60"></el-table-column>
|
||||||
|
<el-table-column label="图标" width="60">
|
||||||
|
<template #default="{row}">
|
||||||
|
<img v-if="row.icon" :src="row.icon" class="icon-preview">
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="name" label="名称"></el-table-column>
|
||||||
|
<el-table-column prop="sort_order" label="排序" width="80"></el-table-column>
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="row.status===1?'success':'info'" size="small">{{ row.status===1?'启用':'禁用' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button text type="primary" size="small" @click="openCategoryDialog(row)">编辑</el-button>
|
||||||
|
<el-popconfirm title="确定删除?" @confirm="deleteCategory(row.id)">
|
||||||
|
<template #reference><el-button text type="danger" size="small">删除</el-button></template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Templates -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>模板管理</h3>
|
||||||
|
<div>
|
||||||
|
<el-select v-model="tplFilterCategory" placeholder="全部分类" clearable size="small" style="width:140px;margin-right:8px" @change="loadTemplates">
|
||||||
|
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id"></el-option>
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" size="small" @click="openTemplateDialog()">新增模板</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-table :data="templates" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="60"></el-table-column>
|
||||||
|
<el-table-column label="缩略图" width="100">
|
||||||
|
<template #default="{row}">
|
||||||
|
<img :src="row.thumbnail_url || row.image_url" class="tpl-thumb" @click="previewImage(row.image_url)">
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="title" label="名称"></el-table-column>
|
||||||
|
<el-table-column label="分类" width="100">
|
||||||
|
<template #default="{row}">{{ row.category ? row.category.name : '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="sort_order" label="排序" width="70"></el-table-column>
|
||||||
|
<el-table-column prop="download_count" label="下载" width="70"></el-table-column>
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="row.status===1?'success':'info'" size="small">{{ row.status===1?'启用':'禁用' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button text type="primary" size="small" @click="openTemplateDialog(row)">编辑</el-button>
|
||||||
|
<el-popconfirm title="确定删除?" @confirm="deleteTemplate(row.id)">
|
||||||
|
<template #reference><el-button text type="danger" size="small">删除</el-button></template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div style="margin-top:16px;text-align:right">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="tplPage"
|
||||||
|
:page-size="tplPageSize"
|
||||||
|
:total="tplTotal"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
@current-change="loadTemplates"
|
||||||
|
></el-pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Dialog -->
|
||||||
|
<el-dialog v-model="catDialogVisible" :title="catForm.id?'编辑分类':'新增分类'" width="460px">
|
||||||
|
<el-form :model="catForm" label-width="80px">
|
||||||
|
<el-form-item label="名称"><el-input v-model="catForm.name"></el-input></el-form-item>
|
||||||
|
<el-form-item label="图标URL"><el-input v-model="catForm.icon" placeholder="可选,图标地址"></el-input></el-form-item>
|
||||||
|
<el-form-item label="排序"><el-input-number v-model="catForm.sort_order" :min="0"></el-input-number></el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="catForm.statusBool" active-text="启用" inactive-text="禁用"></el-switch>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="catDialogVisible=false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveCategory" :loading="catSaving">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Template Dialog -->
|
||||||
|
<el-dialog v-model="tplDialogVisible" :title="tplForm.id?'编辑模板':'新增模板'" width="540px">
|
||||||
|
<el-form :model="tplForm" label-width="80px">
|
||||||
|
<el-form-item label="名称"><el-input v-model="tplForm.title"></el-input></el-form-item>
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<el-select v-model="tplForm.category_id" placeholder="选择分类">
|
||||||
|
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id"></el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="图片URL"><el-input v-model="tplForm.image_url" placeholder="模板图片地址"></el-input></el-form-item>
|
||||||
|
<el-form-item label="缩略图URL"><el-input v-model="tplForm.thumbnail_url" placeholder="可选,缩略图地址"></el-input></el-form-item>
|
||||||
|
<el-form-item label="宽度(px)"><el-input-number v-model="tplForm.width" :min="0"></el-input-number></el-form-item>
|
||||||
|
<el-form-item label="高度(px)"><el-input-number v-model="tplForm.height" :min="0"></el-input-number></el-form-item>
|
||||||
|
<el-form-item label="排序"><el-input-number v-model="tplForm.sort_order" :min="0"></el-input-number></el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="tplForm.statusBool" active-text="启用" inactive-text="禁用"></el-switch>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="tplForm.image_url" label="预览">
|
||||||
|
<img :src="tplForm.image_url" style="max-width:100%;max-height:200px;border-radius:4px;border:1px solid #eee">
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="tplDialogVisible=false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveTemplate" :loading="tplSaving">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Image Preview -->
|
||||||
|
<el-image-viewer v-if="previewVisible" :url-list="[previewUrl]" @close="previewVisible=false"></el-image-viewer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const { createApp, ref, reactive, onMounted } = Vue
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
setup() {
|
||||||
|
const apiBase = ref(localStorage.getItem('mkt_api_base') || location.origin)
|
||||||
|
const adminToken = ref(localStorage.getItem('mkt_admin_token') || '')
|
||||||
|
const authenticated = ref(false)
|
||||||
|
const loginLoading = ref(false)
|
||||||
|
|
||||||
|
const stats = reactive({ categoryCount: 0, templateCount: 0, totalDownloads: 0, todayDownloads: 0 })
|
||||||
|
const categories = ref([])
|
||||||
|
const templates = ref([])
|
||||||
|
const tplPage = ref(1)
|
||||||
|
const tplPageSize = ref(20)
|
||||||
|
const tplTotal = ref(0)
|
||||||
|
const tplFilterCategory = ref(null)
|
||||||
|
|
||||||
|
const catDialogVisible = ref(false)
|
||||||
|
const catForm = reactive({ id: null, name: '', icon: '', sort_order: 0, statusBool: true })
|
||||||
|
const catSaving = ref(false)
|
||||||
|
|
||||||
|
const tplDialogVisible = ref(false)
|
||||||
|
const tplForm = reactive({ id: null, title: '', category_id: null, image_url: '', thumbnail_url: '', width: 0, height: 0, sort_order: 0, statusBool: true })
|
||||||
|
const tplSaving = ref(false)
|
||||||
|
|
||||||
|
const previewVisible = ref(false)
|
||||||
|
const previewUrl = ref('')
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const url = apiBase.value.replace(/\/$/, '') + '/api/v1' + path
|
||||||
|
const opts = { method, headers: { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken.value } }
|
||||||
|
if (body) opts.body = JSON.stringify(body)
|
||||||
|
const res = await fetch(url, opts)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code && data.code !== 200) throw new Error(data.message || '请求失败')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
loginLoading.value = true
|
||||||
|
try {
|
||||||
|
await api('GET', '/admin/marketing/categories')
|
||||||
|
authenticated.value = true
|
||||||
|
localStorage.setItem('mkt_api_base', apiBase.value)
|
||||||
|
localStorage.setItem('mkt_admin_token', adminToken.value)
|
||||||
|
loadAll()
|
||||||
|
} catch (e) {
|
||||||
|
ElementPlus.ElMessage.error('登录失败: ' + e.message)
|
||||||
|
} finally {
|
||||||
|
loginLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
authenticated.value = false
|
||||||
|
localStorage.removeItem('mkt_admin_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
await Promise.all([loadCategories(), loadTemplates(), loadStats()])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCategories() {
|
||||||
|
try {
|
||||||
|
categories.value = await api('GET', '/admin/marketing/categories') || []
|
||||||
|
stats.categoryCount = categories.value.length
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
try {
|
||||||
|
let path = `/admin/marketing/templates?page=${tplPage.value}&page_size=${tplPageSize.value}`
|
||||||
|
if (tplFilterCategory.value) path += `&category_id=${tplFilterCategory.value}`
|
||||||
|
const data = await api('GET', path)
|
||||||
|
templates.value = data.templates || []
|
||||||
|
tplTotal.value = data.total || 0
|
||||||
|
stats.templateCount = data.total || 0
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const data = await api('GET', '/admin/marketing/stats')
|
||||||
|
if (data) {
|
||||||
|
stats.totalDownloads = data.TotalDownloads || 0
|
||||||
|
stats.todayDownloads = data.TodayDownloads || 0
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCategoryDialog(row) {
|
||||||
|
if (row) {
|
||||||
|
Object.assign(catForm, { id: row.id, name: row.name, icon: row.icon, sort_order: row.sort_order, statusBool: row.status === 1 })
|
||||||
|
} else {
|
||||||
|
Object.assign(catForm, { id: null, name: '', icon: '', sort_order: 0, statusBool: true })
|
||||||
|
}
|
||||||
|
catDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCategory() {
|
||||||
|
catSaving.value = true
|
||||||
|
try {
|
||||||
|
const body = { name: catForm.name, icon: catForm.icon, sort_order: catForm.sort_order, status: catForm.statusBool ? 1 : 0 }
|
||||||
|
if (catForm.id) {
|
||||||
|
await api('PUT', `/admin/marketing/categories/${catForm.id}`, body)
|
||||||
|
} else {
|
||||||
|
await api('POST', '/admin/marketing/categories', body)
|
||||||
|
}
|
||||||
|
catDialogVisible.value = false
|
||||||
|
ElementPlus.ElMessage.success('保存成功')
|
||||||
|
await loadCategories()
|
||||||
|
} catch (e) {
|
||||||
|
ElementPlus.ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
catSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCategory(id) {
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/admin/marketing/categories/${id}`)
|
||||||
|
ElementPlus.ElMessage.success('删除成功')
|
||||||
|
await loadCategories()
|
||||||
|
} catch (e) {
|
||||||
|
ElementPlus.ElMessage.error(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTemplateDialog(row) {
|
||||||
|
if (row) {
|
||||||
|
Object.assign(tplForm, {
|
||||||
|
id: row.id, title: row.title, category_id: row.category_id,
|
||||||
|
image_url: row.image_url, thumbnail_url: row.thumbnail_url,
|
||||||
|
width: row.width, height: row.height, sort_order: row.sort_order,
|
||||||
|
statusBool: row.status === 1
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Object.assign(tplForm, { id: null, title: '', category_id: null, image_url: '', thumbnail_url: '', width: 0, height: 0, sort_order: 0, statusBool: true })
|
||||||
|
}
|
||||||
|
tplDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTemplate() {
|
||||||
|
tplSaving.value = true
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
title: tplForm.title, category_id: tplForm.category_id,
|
||||||
|
image_url: tplForm.image_url, thumbnail_url: tplForm.thumbnail_url,
|
||||||
|
width: tplForm.width, height: tplForm.height,
|
||||||
|
sort_order: tplForm.sort_order, status: tplForm.statusBool ? 1 : 0
|
||||||
|
}
|
||||||
|
if (tplForm.id) {
|
||||||
|
await api('PUT', `/admin/marketing/templates/${tplForm.id}`, body)
|
||||||
|
} else {
|
||||||
|
await api('POST', '/admin/marketing/templates', body)
|
||||||
|
}
|
||||||
|
tplDialogVisible.value = false
|
||||||
|
ElementPlus.ElMessage.success('保存成功')
|
||||||
|
await loadTemplates()
|
||||||
|
} catch (e) {
|
||||||
|
ElementPlus.ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
tplSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTemplate(id) {
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/admin/marketing/templates/${id}`)
|
||||||
|
ElementPlus.ElMessage.success('删除成功')
|
||||||
|
await loadTemplates()
|
||||||
|
} catch (e) {
|
||||||
|
ElementPlus.ElMessage.error(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewImage(url) {
|
||||||
|
previewUrl.value = url
|
||||||
|
previewVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (adminToken.value) {
|
||||||
|
login()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiBase, adminToken, authenticated, loginLoading, stats,
|
||||||
|
categories, templates, tplPage, tplPageSize, tplTotal, tplFilterCategory,
|
||||||
|
catDialogVisible, catForm, catSaving,
|
||||||
|
tplDialogVisible, tplForm, tplSaving,
|
||||||
|
previewVisible, previewUrl,
|
||||||
|
login, logout, loadTemplates,
|
||||||
|
openCategoryDialog, saveCategory, deleteCategory,
|
||||||
|
openTemplateDialog, saveTemplate, deleteTemplate,
|
||||||
|
previewImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn })
|
||||||
|
app.mount('#app')
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user