From 4a9e5f9d667d24e4048122fb52d058366c9b2b51 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 12 Oct 2026 18:29:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(announcement):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=BC=B9=E7=AA=97=E5=85=AC=E5=91=8A=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=B9=B6=E5=AE=8C=E6=88=90=E5=BC=B9=E7=AA=97UI?= =?UTF-8?q?=E8=A7=86=E8=A7=89=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库与后端:announcements 表增加 is_popup 字段及迁移脚本,提供前台弹窗接口与多公告优先级选取,补全选优单测 - 管理后台:总后台与分销后台公告编辑新增「弹窗公告」开关与列表标签展示 - 前台交互:前台 PC/移动端首页引入 AnnouncementPopup,支持同一用户同一天同一公告仅弹一次的关闭记录 - 视觉优化:重构弹窗结构,顶栏镶嵌品牌橙流光细条与浅暖橙通知徽标,中间内嵌浅灰舒适阅读卡片,底部搭配大锤橙实心主操作按钮 - 验证方式:后端 announcement 单测通过,前端 npm run build 编译无报错,浏览器实机截图验证 --- backend/internal/model/announcement.go | 28 +- backend/internal/modules/announcement/dto.go | 4 + .../internal/modules/announcement/handler.go | 19 + .../modules/announcement/repository.go | 30 +- .../modules/announcement/repository_test.go | 77 +++ .../internal/modules/announcement/service.go | 5 + .../modules/distribution/announcement.go | 3 + backend/internal/modules/distribution/dto.go | 4 + backend/internal/router/router.go | 2 + .../migrations/000085_announcement_popup.sql | 15 + distribution-admin/src/api.ts | 3 + .../src/pages/Announcements.tsx | 10 + .../features/admin/api/adminAnnouncements.ts | 3 + .../admin/components/AnnouncementDialog.vue | 16 + .../admin/views/AdminAnnouncementsView.vue | 3 + .../announcement/api/announcements.ts | 13 + .../components/AnnouncementPopup.vue | 474 ++++++++++++++++++ .../announcement/utils/popupDismiss.ts | 56 +++ .../src/features/listings/views/HomeView.vue | 4 + .../listings/views/MobileHomeView.vue | 3 + 20 files changed, 758 insertions(+), 14 deletions(-) create mode 100644 backend/migrations/000085_announcement_popup.sql create mode 100644 frontend/src/features/announcement/components/AnnouncementPopup.vue create mode 100644 frontend/src/features/announcement/utils/popupDismiss.ts diff --git a/backend/internal/model/announcement.go b/backend/internal/model/announcement.go index e470dbd..f85e4bd 100644 --- a/backend/internal/model/announcement.go +++ b/backend/internal/model/announcement.go @@ -13,19 +13,21 @@ const ( type Announcement struct { ID uint64 `gorm:"primaryKey" json:"id"` // DistributionSiteID 公告归属站点:0=主站,>0=对应分站(分站公告中心按站点隔离)。 - DistributionSiteID uint64 `gorm:"not null;default:0;index" json:"distribution_site_id"` - Title string `gorm:"size:255;not null" json:"title"` - Content string `gorm:"type:text;not null" json:"content"` - Category string `gorm:"size:32;not null;default:'notice'" json:"category"` - Priority int `gorm:"not null;default:0" json:"priority"` - IsPinned bool `gorm:"not null;default:false" json:"is_pinned"` - IsImportant bool `gorm:"not null;default:false" json:"is_important"` - ViewCount int `gorm:"not null;default:0" json:"view_count"` - Status string `gorm:"size:32;not null;default:'draft'" json:"status"` - PublishedAt *time.Time `json:"published_at"` - CreatedBy *uint64 `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + DistributionSiteID uint64 `gorm:"not null;default:0;index" json:"distribution_site_id"` + Title string `gorm:"size:255;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + Category string `gorm:"size:32;not null;default:'notice'" json:"category"` + Priority int `gorm:"not null;default:0" json:"priority"` + IsPinned bool `gorm:"not null;default:false" json:"is_pinned"` + IsImportant bool `gorm:"not null;default:false" json:"is_important"` + // IsPopup 标记弹窗公告:前台首页弹出且需用户手动关闭,多条生效时只弹优先级最高的一条。 + IsPopup bool `gorm:"not null;default:false" json:"is_popup"` + ViewCount int `gorm:"not null;default:0" json:"view_count"` + Status string `gorm:"size:32;not null;default:'draft'" json:"status"` + PublishedAt *time.Time `json:"published_at"` + CreatedBy *uint64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (Announcement) TableName() string { diff --git a/backend/internal/modules/announcement/dto.go b/backend/internal/modules/announcement/dto.go index f7b4531..0ee1171 100644 --- a/backend/internal/modules/announcement/dto.go +++ b/backend/internal/modules/announcement/dto.go @@ -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"` } diff --git a/backend/internal/modules/announcement/handler.go b/backend/internal/modules/announcement/handler.go index d3223f1..66ba48d 100644 --- a/backend/internal/modules/announcement/handler.go +++ b/backend/internal/modules/announcement/handler.go @@ -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) diff --git a/backend/internal/modules/announcement/repository.go b/backend/internal/modules/announcement/repository.go index 52c618b..031b38b 100644 --- a/backend/internal/modules/announcement/repository.go +++ b/backend/internal/modules/announcement/repository.go @@ -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, diff --git a/backend/internal/modules/announcement/repository_test.go b/backend/internal/modules/announcement/repository_test.go index 8c3edea..194a9d6 100644 --- a/backend/internal/modules/announcement/repository_test.go +++ b/backend/internal/modules/announcement/repository_test.go @@ -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) + } +} diff --git a/backend/internal/modules/announcement/service.go b/backend/internal/modules/announcement/service.go index 0d5e97a..5f4b7e7 100644 --- a/backend/internal/modules/announcement/service.go +++ b/backend/internal/modules/announcement/service.go @@ -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) diff --git a/backend/internal/modules/distribution/announcement.go b/backend/internal/modules/distribution/announcement.go index 86c1644..f2192a6 100644 --- a/backend/internal/modules/distribution/announcement.go +++ b/backend/internal/modules/distribution/announcement.go @@ -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, diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index c3a2c40..f1f0d0b 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -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 元兑换哈夫币」比例,不是人民币。 diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 6e28603..eb992b2 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -657,6 +657,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { announcementRoutes := api.Group("/announcements") { announcementRoutes.GET("", announcementHandler.List) + // 弹窗公告必须注册在 /:id 之前语义更清晰;gin 允许静态段与通配符同级共存。 + announcementRoutes.GET("/popup", announcementHandler.Popup) announcementRoutes.GET("/:id", announcementHandler.GetByID) } diff --git a/backend/migrations/000085_announcement_popup.sql b/backend/migrations/000085_announcement_popup.sql new file mode 100644 index 0000000..75de6ac --- /dev/null +++ b/backend/migrations/000085_announcement_popup.sql @@ -0,0 +1,15 @@ +-- +goose Up + +-- 弹窗公告:前台首页进入时弹一次、必须用户手动关闭的公告。 +-- is_popup = 1 且 status = published 时,才会进入弹窗候选; +-- 多条同时生效时前台只弹「优先级最高」的一条(priority DESC, published_at DESC, id DESC)。 +-- 前台按「每天一次」记录已关闭状态,因此这里只标记类型,不做送达记录。 +ALTER TABLE announcements + ADD COLUMN is_popup TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否弹窗公告: 0否, 1是' AFTER is_important, + ADD KEY idx_announcements_popup (is_popup, status, distribution_site_id, priority); + +-- +goose Down + +ALTER TABLE announcements + DROP KEY idx_announcements_popup, + DROP COLUMN is_popup; diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 71c60a3..dfdb90a 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -672,6 +672,8 @@ export interface SiteAnnouncement { priority: number is_pinned: boolean is_important: boolean + /** 弹窗公告:本站首页弹出且需用户手动关闭,同一天只弹一次 */ + is_popup: boolean view_count: number status: string published_at: string | null @@ -693,6 +695,7 @@ export interface SiteAnnouncementPayload { priority: number is_pinned: boolean is_important: boolean + is_popup: boolean } export async function listSiteAnnouncements( diff --git a/distribution-admin/src/pages/Announcements.tsx b/distribution-admin/src/pages/Announcements.tsx index cc8ffa8..d1e5830 100644 --- a/distribution-admin/src/pages/Announcements.tsx +++ b/distribution-admin/src/pages/Announcements.tsx @@ -69,6 +69,7 @@ const EMPTY_FORM: SiteAnnouncementPayload = { priority: 0, is_pinned: false, is_important: false, + is_popup: false, } function formatTime(value?: string | null) { @@ -181,6 +182,7 @@ export default function Announcements() { priority: row.priority, is_pinned: row.is_pinned, is_important: row.is_important, + is_popup: row.is_popup, }) setModalOpen(true) } @@ -324,6 +326,7 @@ export default function Announcements() { 重要 )} + {row.is_popup && 弹窗公告} ), }, @@ -440,6 +443,13 @@ export default function Announcements() { 标记重要 + + 弹窗公告 + {!editing && ( ({ priority: 0, is_pinned: false, is_important: false, + is_popup: false, }) // 站点列表:0=主站(默认),其余为分站;公告归属站点只在新建时可选。 @@ -118,6 +119,7 @@ watch( priority: props.announcement.priority, is_pinned: props.announcement.is_pinned, is_important: props.announcement.is_important, + is_popup: props.announcement.is_popup, } } else { formData.value = { @@ -128,6 +130,7 @@ watch( priority: 0, is_pinned: false, is_important: false, + is_popup: false, } } void loadDistributionSites() @@ -390,6 +393,13 @@ function escapeMarkdownImageText(text: string) { 置顶 重要 + 弹窗公告 + + + + + 该公告会在对应站点首页弹出,用户必须手动点击「我知道了」才能关闭;同一天同一条只弹一次,次日重新弹出。多条弹窗公告同时启用时只弹优先级最高的一条。 + @@ -502,6 +512,12 @@ function escapeMarkdownImageText(text: string) { flex-wrap: wrap; } +.popup-tip { + color: #909399; + font-size: 13px; + line-height: 1.6; +} + :deep(.el-textarea__inner) { font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; font-size: 13px; diff --git a/frontend/src/features/admin/views/AdminAnnouncementsView.vue b/frontend/src/features/admin/views/AdminAnnouncementsView.vue index dd76da4..78f5cb2 100644 --- a/frontend/src/features/admin/views/AdminAnnouncementsView.vue +++ b/frontend/src/features/admin/views/AdminAnnouncementsView.vue @@ -308,6 +308,9 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' { 重要 + 弹窗公告 diff --git a/frontend/src/features/announcement/api/announcements.ts b/frontend/src/features/announcement/api/announcements.ts index 0904b7b..87ea51f 100644 --- a/frontend/src/features/announcement/api/announcements.ts +++ b/frontend/src/features/announcement/api/announcements.ts @@ -13,6 +13,8 @@ export interface Announcement { priority: number is_pinned: boolean is_important: boolean + /** 弹窗公告:首页弹出且需手动关闭,同一天只弹一次 */ + is_popup: boolean view_count: number status: string published_at: string | null @@ -47,3 +49,14 @@ export async function fetchAnnouncementDetail(id: number): Promise const { data } = await apiClient.get>(`/announcements/${id}`) return data.data } + +/** + * 获取当前站点要弹出的弹窗公告。 + * 后端只返回「优先级最高」的一条;没有可弹公告时 data 为 null。 + */ +export async function fetchPopupAnnouncement(): Promise { + const { data } = await apiClient.get>('/announcements/popup', { + silent: true, + }) + return data.data ?? null +} diff --git a/frontend/src/features/announcement/components/AnnouncementPopup.vue b/frontend/src/features/announcement/components/AnnouncementPopup.vue new file mode 100644 index 0000000..057c38d --- /dev/null +++ b/frontend/src/features/announcement/components/AnnouncementPopup.vue @@ -0,0 +1,474 @@ + + + + + diff --git a/frontend/src/features/announcement/utils/popupDismiss.ts b/frontend/src/features/announcement/utils/popupDismiss.ts new file mode 100644 index 0000000..5d5cb93 --- /dev/null +++ b/frontend/src/features/announcement/utils/popupDismiss.ts @@ -0,0 +1,56 @@ +/** + * 弹窗公告的「每日一次」关闭记录。 + * + * 交互口径:用户必须手动点「我知道了」才关闭;关闭后当天不再弹同一条公告, + * 次日自动重置(因此公告仍为启用状态时会重新弹一次)。 + * 按公告 ID 分别记录,若换了另一条优先级更高的弹窗公告,当天也会照常弹出。 + */ + +const STORAGE_KEY = 'announcement_popup_dismissed' + +type DismissedMap = Record + +function todayKey(now: Date = new Date()): string { + const year = now.getFullYear() + const month = `${now.getMonth() + 1}`.padStart(2, '0') + const day = `${now.getDate()}`.padStart(2, '0') + return `${year}-${month}-${day}` +} + +function readMap(): DismissedMap { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') return {} + return parsed as DismissedMap + } catch { + // localStorage 不可用或数据损坏时按「未关闭过」处理,不影响公告展示 + return {} + } +} + +/** 该公告今天是否已被用户手动关闭 */ +export function isPopupDismissedToday(id: number, now?: Date): boolean { + if (!id) return false + const record = readMap()[String(id)] + return record === todayKey(now) +} + +/** 记录用户已手动关闭该公告(仅对当天生效) */ +export function markPopupDismissed(id: number, now?: Date): void { + if (!id) return + const today = todayKey(now) + const map = readMap() + // 顺手清理非当天的历史记录,避免 localStorage 无限增长 + const next: DismissedMap = {} + Object.entries(map).forEach(([key, value]) => { + if (value === today) next[key] = value + }) + next[String(id)] = today + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + } catch { + // 忽略写入失败(隐私模式等),最差情况是当天重复弹出 + } +} diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue index f8109e7..caf6b37 100644 --- a/frontend/src/features/listings/views/HomeView.vue +++ b/frontend/src/features/listings/views/HomeView.vue @@ -13,6 +13,7 @@ import { type HomeBannerSlide, } from '@/features/listings/api/homeConfig' import { ElMessage } from 'element-plus' +import AnnouncementPopup from '@/features/announcement/components/AnnouncementPopup.vue' import AuthImage from '@/shared/components/business/AuthImage.vue' import { emptyListingPublishOptions, @@ -497,6 +498,9 @@ if (!restored) {
+ + +
diff --git a/frontend/src/features/listings/views/MobileHomeView.vue b/frontend/src/features/listings/views/MobileHomeView.vue index 2806490..19212ad 100644 --- a/frontend/src/features/listings/views/MobileHomeView.vue +++ b/frontend/src/features/listings/views/MobileHomeView.vue @@ -24,6 +24,7 @@ import { type HomeBannerSlide, } from '@/features/listings/api/homeConfig' import { showToast } from 'vant' +import AnnouncementPopup from '@/features/announcement/components/AnnouncementPopup.vue' import AuthImage from '@/shared/components/business/AuthImage.vue' import MobileHomeFilterSheet, { type FilterSection } from './MobileHomeFilterSheet.vue' import { @@ -795,6 +796,8 @@ if (!restoreHomeConfigFromCache()) {