feat(announcement): 新增首页弹窗公告功能并完成弹窗UI视觉重构
- 数据库与后端:announcements 表增加 is_popup 字段及迁移脚本,提供前台弹窗接口与多公告优先级选取,补全选优单测 - 管理后台:总后台与分销后台公告编辑新增「弹窗公告」开关与列表标签展示 - 前台交互:前台 PC/移动端首页引入 AnnouncementPopup,支持同一用户同一天同一公告仅弹一次的关闭记录 - 视觉优化:重构弹窗结构,顶栏镶嵌品牌橙流光细条与浅暖橙通知徽标,中间内嵌浅灰舒适阅读卡片,底部搭配大锤橙实心主操作按钮 - 验证方式:后端 announcement 单测通过,前端 npm run build 编译无报错,浏览器实机截图验证
This commit is contained in:
@@ -12,6 +12,7 @@ type AnnouncementDTO struct {
|
||||
Priority int `json:"priority"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsImportant bool `json:"is_important"`
|
||||
IsPopup bool `json:"is_popup"`
|
||||
ViewCount int `json:"view_count"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
@@ -46,6 +47,8 @@ type CreateAnnouncementRequest struct {
|
||||
Priority int `json:"priority"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsImportant bool `json:"is_important"`
|
||||
// IsPopup 弹窗公告:前台首页弹出且需用户手动关闭,同一天只弹一次。
|
||||
IsPopup bool `json:"is_popup"`
|
||||
}
|
||||
|
||||
type UpdateAnnouncementRequest struct {
|
||||
@@ -55,4 +58,5 @@ type UpdateAnnouncementRequest struct {
|
||||
Priority int `json:"priority"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsImportant bool `json:"is_important"`
|
||||
IsPopup bool `json:"is_popup"`
|
||||
}
|
||||
|
||||
@@ -47,6 +47,25 @@ func (h *Handler) List(c *gin.Context) {
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
// Popup 获取前台首页要弹出的弹窗公告(按当前站点隔离;无可弹公告时 data 为 null)
|
||||
func (h *Handler) Popup(c *gin.Context) {
|
||||
siteIDs, err := h.service.ResolvePublicSiteScope(c.Request.Context(), sitecode.FromRequest(c))
|
||||
if err != nil {
|
||||
response.RecordError(c, err)
|
||||
response.InternalServerError(c, "获取弹窗公告失败")
|
||||
return
|
||||
}
|
||||
|
||||
announcement, err := h.service.PublicPopup(c.Request.Context(), siteIDs)
|
||||
if err != nil {
|
||||
response.RecordError(c, err)
|
||||
response.InternalServerError(c, "获取弹窗公告失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, announcement)
|
||||
}
|
||||
|
||||
// GetByID 获取公告详情(限当前站点可见范围)
|
||||
func (h *Handler) GetByID(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
|
||||
@@ -84,6 +84,31 @@ func (r *Repository) List(ctx context.Context, query AnnouncementListQuery) (*Pa
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublicPopup 取当前站点范围内优先级最高的弹窗公告;没有可弹公告时返回 nil, nil。
|
||||
//
|
||||
// 只取 published 且 is_popup = 1 的公告,按 priority DESC(其次发布时间/ID)排序取第一条,
|
||||
// 因此「多条同时生效只弹优先级最高的一条」由这里保证,前台无需再筛选。
|
||||
func (r *Repository) PublicPopup(ctx context.Context, allowedSiteIDs []uint64) (*AnnouncementDTO, error) {
|
||||
query := r.db.WithContext(ctx).
|
||||
Where("status = ? AND is_popup = ?", "published", true)
|
||||
if len(allowedSiteIDs) > 0 {
|
||||
query = query.Where("distribution_site_id IN ?", allowedSiteIDs)
|
||||
}
|
||||
|
||||
var announcement model.Announcement
|
||||
if err := query.
|
||||
Order("priority DESC, published_at DESC, id DESC").
|
||||
First(&announcement).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dto := toAnnouncementDTO(announcement)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
// GetByID 获取公告详情(限前台可见的站点范围,避免跨站按 ID 读取)
|
||||
func (r *Repository) GetByID(ctx context.Context, id uint64, allowedSiteIDs []uint64) (*AnnouncementDTO, error) {
|
||||
var announcement model.Announcement
|
||||
@@ -136,7 +161,7 @@ func (r *Repository) AdminList(ctx context.Context, query AnnouncementListQuery)
|
||||
// adminListBase 管理端公告列表基础查询:带站点名,支持状态/分类/站点筛选。
|
||||
func (r *Repository) adminListBase(ctx context.Context, query AnnouncementListQuery) *gorm.DB {
|
||||
base := r.db.WithContext(ctx).Table("announcements AS a").
|
||||
Select("a.id, a.distribution_site_id, COALESCE(s.site_name, '') AS distribution_site_name, a.title, a.content, a.category, a.priority, a.is_pinned, a.is_important, a.view_count, a.status, a.published_at, a.created_at, a.updated_at").
|
||||
Select("a.id, a.distribution_site_id, COALESCE(s.site_name, '') AS distribution_site_name, a.title, a.content, a.category, a.priority, a.is_pinned, a.is_important, a.is_popup, a.view_count, a.status, a.published_at, a.created_at, a.updated_at").
|
||||
Joins("LEFT JOIN distribution_sites AS s ON s.id = a.distribution_site_id")
|
||||
|
||||
if query.Status != "" {
|
||||
@@ -175,6 +200,7 @@ func (r *Repository) Create(ctx context.Context, req CreateAnnouncementRequest,
|
||||
Priority: req.Priority,
|
||||
IsPinned: req.IsPinned,
|
||||
IsImportant: req.IsImportant,
|
||||
IsPopup: req.IsPopup,
|
||||
Status: "draft",
|
||||
CreatedBy: &createdBy,
|
||||
}
|
||||
@@ -203,6 +229,7 @@ func (r *Repository) Update(ctx context.Context, id uint64, req UpdateAnnounceme
|
||||
updates["priority"] = req.Priority
|
||||
updates["is_pinned"] = req.IsPinned
|
||||
updates["is_important"] = req.IsImportant
|
||||
updates["is_popup"] = req.IsPopup
|
||||
|
||||
if err := r.db.WithContext(ctx).Model(&model.Announcement{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -255,6 +282,7 @@ func toAnnouncementDTO(a model.Announcement) AnnouncementDTO {
|
||||
Priority: a.Priority,
|
||||
IsPinned: a.IsPinned,
|
||||
IsImportant: a.IsImportant,
|
||||
IsPopup: a.IsPopup,
|
||||
ViewCount: a.ViewCount,
|
||||
Status: a.Status,
|
||||
PublishedAt: a.PublishedAt,
|
||||
|
||||
@@ -174,3 +174,80 @@ func TestAdminListFiltersBySite(t *testing.T) {
|
||||
t.Fatalf("站点信息未透出: %+v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicPopupSelection 覆盖弹窗公告的选取口径:
|
||||
// 只有 published + is_popup 才参与,站点范围内按优先级取最高的一条,无候选时返回 nil。
|
||||
func TestPublicPopupSelection(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
repo := NewRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// 主站:非弹窗公告(不应参与) + 两条弹窗公告(优先级不同)
|
||||
plain := model.Announcement{
|
||||
Title: "普通公告", Content: "内容", Category: "notice", Status: "published",
|
||||
}
|
||||
lowPriority := model.Announcement{
|
||||
Title: "低优先级弹窗", Content: "内容", Category: "notice",
|
||||
Status: "published", IsPopup: true, Priority: 1,
|
||||
}
|
||||
highPriority := model.Announcement{
|
||||
Title: "高优先级弹窗", Content: "内容", Category: "notice",
|
||||
Status: "published", IsPopup: true, Priority: 9,
|
||||
}
|
||||
// 弹窗但未发布:不应参与
|
||||
draftPopup := model.Announcement{
|
||||
Title: "草稿弹窗", Content: "内容", Category: "notice",
|
||||
Status: "draft", IsPopup: true, Priority: 99,
|
||||
}
|
||||
for _, row := range []*model.Announcement{&plain, &lowPriority, &highPriority, &draftPopup} {
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
popup, err := repo.PublicPopup(ctx, []uint64{0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if popup == nil {
|
||||
t.Fatal("主站应弹出一条弹窗公告, got nil")
|
||||
}
|
||||
if popup.ID != highPriority.ID || popup.Title != "高优先级弹窗" {
|
||||
t.Fatalf("应取优先级最高的弹窗公告, got %+v", popup)
|
||||
}
|
||||
if !popup.IsPopup {
|
||||
t.Fatalf("返回的公告应带 is_popup 标记: %+v", popup)
|
||||
}
|
||||
|
||||
// 站点隔离:独立站只看到本站公告,主站弹窗不可见
|
||||
site := model.DistributionSite{
|
||||
SiteCode: "ann_popup_site", SiteName: "弹窗站", Domain: "ann-popup.example.com",
|
||||
Status: "active", AnnouncementMode: model.AnnouncementModeOwnOnly,
|
||||
}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
none, err := repo.PublicPopup(ctx, []uint64{site.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if none != nil {
|
||||
t.Fatalf("本站没有弹窗公告时应返回 nil, got %+v", none)
|
||||
}
|
||||
|
||||
// 本站有自己的弹窗公告时应生效
|
||||
sitePopup := model.Announcement{
|
||||
DistributionSiteID: site.ID, Title: "本站弹窗", Content: "内容",
|
||||
Category: "notice", Status: "published", IsPopup: true, Priority: 5,
|
||||
}
|
||||
if err := db.Create(&sitePopup).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := repo.PublicPopup(ctx, []uint64{site.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.ID != sitePopup.ID {
|
||||
t.Fatalf("应取本站弹窗公告, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@ func (s *Service) List(ctx context.Context, query AnnouncementListQuery) (*Pagin
|
||||
return s.repo.List(ctx, query)
|
||||
}
|
||||
|
||||
// PublicPopup 获取前台首页要弹出的弹窗公告(无则返回 nil)
|
||||
func (s *Service) PublicPopup(ctx context.Context, allowedSiteIDs []uint64) (*AnnouncementDTO, error) {
|
||||
return s.repo.PublicPopup(ctx, allowedSiteIDs)
|
||||
}
|
||||
|
||||
// GetByID 获取公告详情(限前台可见的站点范围)
|
||||
func (s *Service) GetByID(ctx context.Context, id uint64, allowedSiteIDs []uint64) (*AnnouncementDTO, error) {
|
||||
dto, err := s.repo.GetByID(ctx, id, allowedSiteIDs)
|
||||
|
||||
@@ -90,6 +90,7 @@ func (r *Repository) CreateSiteAnnouncement(ctx context.Context, adminID, siteID
|
||||
Priority: req.Priority,
|
||||
IsPinned: req.IsPinned,
|
||||
IsImportant: req.IsImportant,
|
||||
IsPopup: req.IsPopup,
|
||||
Status: "draft",
|
||||
CreatedBy: &createdBy,
|
||||
}
|
||||
@@ -120,6 +121,7 @@ func (r *Repository) UpdateSiteAnnouncement(ctx context.Context, adminID, siteID
|
||||
"priority": req.Priority,
|
||||
"is_pinned": req.IsPinned,
|
||||
"is_important": req.IsImportant,
|
||||
"is_popup": req.IsPopup,
|
||||
}
|
||||
if title := strings.TrimSpace(req.Title); title != "" {
|
||||
updates["title"] = title
|
||||
@@ -217,6 +219,7 @@ func toSiteAnnouncementDTO(a model.Announcement) SiteAnnouncementDTO {
|
||||
Priority: a.Priority,
|
||||
IsPinned: a.IsPinned,
|
||||
IsImportant: a.IsImportant,
|
||||
IsPopup: a.IsPopup,
|
||||
ViewCount: a.ViewCount,
|
||||
Status: a.Status,
|
||||
PublishedAt: a.PublishedAt,
|
||||
|
||||
@@ -449,6 +449,7 @@ type SiteAnnouncementDTO struct {
|
||||
Priority int `json:"priority"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsImportant bool `json:"is_important"`
|
||||
IsPopup bool `json:"is_popup"`
|
||||
ViewCount int `json:"view_count"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
@@ -470,6 +471,8 @@ type CreateSiteAnnouncementRequest struct {
|
||||
Priority int `json:"priority"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsImportant bool `json:"is_important"`
|
||||
// IsPopup 弹窗公告:本站首页弹出且需用户手动关闭,同一天只弹一次。
|
||||
IsPopup bool `json:"is_popup"`
|
||||
}
|
||||
|
||||
type UpdateSiteAnnouncementRequest struct {
|
||||
@@ -479,6 +482,7 @@ type UpdateSiteAnnouncementRequest struct {
|
||||
Priority int `json:"priority"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsImportant bool `json:"is_important"`
|
||||
IsPopup bool `json:"is_popup"`
|
||||
}
|
||||
|
||||
// 自主加价 DTO:增加该站「1 元兑换哈夫币」比例,不是人民币。
|
||||
|
||||
Reference in New Issue
Block a user