Files
hfb_sys/backend/internal/modules/distribution/announcement.go
T
yml2213 4a9e5f9d66 feat(announcement): 新增首页弹窗公告功能并完成弹窗UI视觉重构
- 数据库与后端:announcements 表增加 is_popup 字段及迁移脚本,提供前台弹窗接口与多公告优先级选取,补全选优单测
- 管理后台:总后台与分销后台公告编辑新增「弹窗公告」开关与列表标签展示
- 前台交互:前台 PC/移动端首页引入 AnnouncementPopup,支持同一用户同一天同一公告仅弹一次的关闭记录
- 视觉优化:重构弹窗结构,顶栏镶嵌品牌橙流光细条与浅暖橙通知徽标,中间内嵌浅灰舒适阅读卡片,底部搭配大锤橙实心主操作按钮
- 验证方式:后端 announcement 单测通过,前端 npm run build 编译无报错,浏览器实机截图验证
2026-10-12 18:33:44 +08:00

239 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package distribution
import (
"context"
"errors"
"strings"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
// 分站公告中心:站长在分销后台自助维护本站公告。
//
// 站点隔离口径:所有写操作与列表查询都强制 distribution_site_id = siteID,
// 且先通过 ensureSiteAccess 校验当前管理员对该站点的访问权,避免跨站读写。
// 前台展示范围由站点 announcement_mode 决定(见 announcement 模块 ResolvePublicSiteScope)。
// normalizeAnnouncementMode 校验公告中心来源模式,空值按默认 inherit 处理。
func normalizeAnnouncementMode(value string) (string, error) {
switch strings.TrimSpace(value) {
case "", model.AnnouncementModeInherit:
return model.AnnouncementModeInherit, nil
case model.AnnouncementModeOwnOnly:
return model.AnnouncementModeOwnOnly, nil
default:
return "", ErrInvalidAnnouncementMode
}
}
// ListSiteAnnouncements 分页查询本站公告(含草稿/归档,供站长管理)。
func (r *Repository) ListSiteAnnouncements(ctx context.Context, adminID, siteID uint64, status string, page, pageSize int) (*SiteAnnouncementPage, error) {
if siteID == 0 {
return nil, ErrSiteNotFound
}
if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil {
return nil, err
}
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
base := r.db.WithContext(ctx).Model(&model.Announcement{}).Where("distribution_site_id = ?", siteID)
if s := strings.TrimSpace(status); s != "" {
base = base.Where("status = ?", s)
}
var total int64
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return nil, err
}
var rows []model.Announcement
if err := base.Session(&gorm.Session{}).
Order("is_pinned DESC, priority DESC, id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&rows).Error; err != nil {
return nil, err
}
items := make([]SiteAnnouncementDTO, 0, len(rows))
for _, row := range rows {
items = append(items, toSiteAnnouncementDTO(row))
}
return &SiteAnnouncementPage{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// CreateSiteAnnouncement 在指定站点下新建公告(草稿)。
func (r *Repository) CreateSiteAnnouncement(ctx context.Context, adminID, siteID uint64, req CreateSiteAnnouncementRequest) (*SiteAnnouncementDTO, error) {
if siteID == 0 {
return nil, ErrSiteNotFound
}
if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil {
return nil, err
}
createdBy := adminID
row := model.Announcement{
DistributionSiteID: siteID,
Title: strings.TrimSpace(req.Title),
Content: req.Content,
Category: strings.TrimSpace(req.Category),
Priority: req.Priority,
IsPinned: req.IsPinned,
IsImportant: req.IsImportant,
IsPopup: req.IsPopup,
Status: "draft",
CreatedBy: &createdBy,
}
if row.Category == "" {
row.Category = "notice"
}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
dto := toSiteAnnouncementDTO(row)
return &dto, nil
}
// UpdateSiteAnnouncement 更新本站公告内容。
func (r *Repository) UpdateSiteAnnouncement(ctx context.Context, adminID, siteID, id uint64, req UpdateSiteAnnouncementRequest) (*SiteAnnouncementDTO, error) {
if siteID == 0 {
return nil, ErrSiteNotFound
}
if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil {
return nil, err
}
row, err := r.findSiteAnnouncement(ctx, siteID, id)
if err != nil {
return nil, err
}
updates := map[string]any{
"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
}
if req.Content != "" {
updates["content"] = req.Content
}
if category := strings.TrimSpace(req.Category); category != "" {
updates["category"] = category
}
if err := r.db.WithContext(ctx).Model(&model.Announcement{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil {
return nil, err
}
updated, err := r.findSiteAnnouncement(ctx, siteID, id)
if err != nil {
return nil, err
}
dto := toSiteAnnouncementDTO(*updated)
return &dto, nil
}
// UpdateSiteAnnouncementStatus 站点公告上下架:publish=发布,archive=归档,unarchive=取消归档。
func (r *Repository) UpdateSiteAnnouncementStatus(ctx context.Context, adminID, siteID, id uint64, action string) (*SiteAnnouncementDTO, error) {
if siteID == 0 {
return nil, ErrSiteNotFound
}
if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil {
return nil, err
}
row, err := r.findSiteAnnouncement(ctx, siteID, id)
if err != nil {
return nil, err
}
now := time.Now()
updates := map[string]any{}
switch strings.TrimSpace(action) {
case "publish":
updates["status"] = "published"
updates["published_at"] = now
case "archive":
updates["status"] = "archived"
case "unarchive":
updates["status"] = "published"
updates["published_at"] = gorm.Expr("COALESCE(published_at, ?)", now)
default:
return nil, ErrInvalidListingStatus
}
if err := r.db.WithContext(ctx).Model(&model.Announcement{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil {
return nil, err
}
updated, err := r.findSiteAnnouncement(ctx, siteID, id)
if err != nil {
return nil, err
}
dto := toSiteAnnouncementDTO(*updated)
return &dto, nil
}
// DeleteSiteAnnouncement 删除本站公告。
func (r *Repository) DeleteSiteAnnouncement(ctx context.Context, adminID, siteID, id uint64) error {
if siteID == 0 {
return ErrSiteNotFound
}
if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil {
return err
}
row, err := r.findSiteAnnouncement(ctx, siteID, id)
if err != nil {
return err
}
return r.db.WithContext(ctx).Where("id = ?", row.ID).Delete(&model.Announcement{}).Error
}
// findSiteAnnouncement 按站点归属读取公告,跨站 ID 一律视为不存在。
func (r *Repository) findSiteAnnouncement(ctx context.Context, siteID, id uint64) (*model.Announcement, error) {
var row model.Announcement
err := r.db.WithContext(ctx).Where("id = ? AND distribution_site_id = ?", id, siteID).First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAnnouncementNotFound
}
return nil, err
}
return &row, nil
}
func toSiteAnnouncementDTO(a model.Announcement) SiteAnnouncementDTO {
return SiteAnnouncementDTO{
ID: a.ID,
DistributionSiteID: a.DistributionSiteID,
Title: a.Title,
Content: a.Content,
Category: a.Category,
Priority: a.Priority,
IsPinned: a.IsPinned,
IsImportant: a.IsImportant,
IsPopup: a.IsPopup,
ViewCount: a.ViewCount,
Status: a.Status,
PublishedAt: a.PublishedAt,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
}
}
// normalizeAnnouncementModeOrInherit 读侧容错:非法/历史空值一律按默认继承口径返回。
func normalizeAnnouncementModeOrInherit(value string) string {
mode, err := normalizeAnnouncementMode(value)
if err != nil {
return model.AnnouncementModeInherit
}
return mode
}