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()) {
+
+