feat(announcement): 新增首页弹窗公告功能并完成弹窗UI视觉重构

- 数据库与后端:announcements 表增加 is_popup 字段及迁移脚本,提供前台弹窗接口与多公告优先级选取,补全选优单测
- 管理后台:总后台与分销后台公告编辑新增「弹窗公告」开关与列表标签展示
- 前台交互:前台 PC/移动端首页引入 AnnouncementPopup,支持同一用户同一天同一公告仅弹一次的关闭记录
- 视觉优化:重构弹窗结构,顶栏镶嵌品牌橙流光细条与浅暖橙通知徽标,中间内嵌浅灰舒适阅读卡片,底部搭配大锤橙实心主操作按钮
- 验证方式:后端 announcement 单测通过,前端 npm run build 编译无报错,浏览器实机截图验证
This commit is contained in:
yml2213
2026-10-12 18:33:44 +08:00
parent 41d7ffd073
commit 4a9e5f9d66
20 changed files with 758 additions and 14 deletions
+15 -13
View File
@@ -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 {
@@ -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 元兑换哈夫币」比例,不是人民币。
+2
View File
@@ -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)
}
@@ -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;
+3
View File
@@ -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(
@@ -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() {
重要
</Tag>
)}
{row.is_popup && <Tag color="blue">弹窗公告</Tag>}
</Space>
),
},
@@ -440,6 +443,13 @@ export default function Announcements() {
<Form.Item name="is_important" valuePropName="checked">
<Checkbox>标记重要</Checkbox>
</Form.Item>
<Form.Item
name="is_popup"
valuePropName="checked"
tooltip="本站首页弹出,用户必须手动关闭;同一天同一条只弹一次"
>
<Checkbox>弹窗公告</Checkbox>
</Form.Item>
</Space>
{!editing && (
<Alert
@@ -11,6 +11,8 @@ export interface CreateAnnouncementRequest {
priority?: number
is_pinned?: boolean
is_important?: boolean
/** 弹窗公告:前台首页弹出且需用户手动关闭,同一天只弹一次 */
is_popup?: boolean
}
export interface UpdateAnnouncementRequest {
@@ -20,6 +22,7 @@ export interface UpdateAnnouncementRequest {
priority?: number
is_pinned?: boolean
is_important?: boolean
is_popup?: boolean
}
export async function fetchAdminAnnouncements(params: {
@@ -55,6 +55,7 @@ const formData = ref<CreateAnnouncementRequest>({
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) {
<el-form-item label="标记">
<el-checkbox v-model="formData.is_pinned">置顶</el-checkbox>
<el-checkbox v-model="formData.is_important" style="margin-left: 20px">重要</el-checkbox>
<el-checkbox v-model="formData.is_popup" style="margin-left: 20px">弹窗公告</el-checkbox>
</el-form-item>
<el-form-item v-if="formData.is_popup" label=" ">
<span class="popup-tip">
该公告会在对应站点首页弹出,用户必须手动点击「我知道了」才能关闭;同一天同一条只弹一次,次日重新弹出。多条弹窗公告同时启用时只弹优先级最高的一条。
</span>
</el-form-item>
</el-form>
@@ -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;
@@ -308,6 +308,9 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
<el-tag v-if="row.is_important" type="danger" size="small" effect="plain"
>重要</el-tag
>
<el-tag v-if="row.is_popup" type="primary" size="small" effect="plain"
>弹窗公告</el-tag
>
</div>
</div>
</template>
@@ -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<Announcement>
const { data } = await apiClient.get<ApiResponse<Announcement>>(`/announcements/${id}`)
return data.data
}
/**
* 获取当前站点要弹出的弹窗公告。
* 后端只返回「优先级最高」的一条;没有可弹公告时 data 为 null。
*/
export async function fetchPopupAnnouncement(): Promise<Announcement | null> {
const { data } = await apiClient.get<ApiResponse<Announcement | null>>('/announcements/popup', {
silent: true,
})
return data.data ?? null
}
@@ -0,0 +1,474 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { fetchPopupAnnouncement, type Announcement } from '@/features/announcement'
import { renderAnnouncementMarkdown } from '@/features/announcement/utils/markdown'
import {
isPopupDismissedToday,
markPopupDismissed,
} from '@/features/announcement/utils/popupDismiss'
import { formatDateTime } from '@/shared/utils/time'
/**
* 弹窗公告:前台首页(PC / 移动端共用)进入时弹出,用户必须手动点击「我知道了」关闭。
*
* 视觉设计优化:
* 1. 风格定位:轻奢通透、干净雅致,避免大面积深色或浓烈纯色块带来的视觉压迫与抢眼感;
* 2. 品牌呈现:卡片顶部镶嵌精致橙色微光品牌饰条,左侧橙色立体徽标 + 橙色 Badge + 底部温润品牌主按钮首尾呼应;
* 3. 排版细节:纯白/微暖白优雅顶栏、浅灰清爽内容卡片,字距行高舒展呼吸感强;
* 4. 交互:轻柔毛玻璃遮罩 + 细腻微动效。
*/
const announcement = ref<Announcement | null>(null)
const visible = ref(false)
const contentHtml = computed(() =>
announcement.value ? renderAnnouncementMarkdown(announcement.value.content || '') : ''
)
const publishedAtText = computed(() =>
announcement.value?.published_at ? formatDateTime(announcement.value.published_at) : ''
)
onMounted(async () => {
try {
const data = await fetchPopupAnnouncement()
if (!data || !data.id) return
if (isPopupDismissedToday(data.id)) return
announcement.value = data
visible.value = true
} catch {
// 弹窗公告属于附加信息,拉取失败时静默忽略,不打扰用户浏览首页
}
})
// 弹窗期间锁定页面滚动,避免用户滑动穿透到底层首页
watch(visible, value => {
if (typeof document === 'undefined') return
document.body.style.overflow = value ? 'hidden' : ''
})
onBeforeUnmount(() => {
if (typeof document !== 'undefined') document.body.style.overflow = ''
})
function handleClose() {
if (announcement.value) markPopupDismissed(announcement.value.id)
visible.value = false
}
</script>
<template>
<Teleport to="body">
<Transition name="popup-scale">
<div
v-if="visible && announcement"
class="announcement-popup-mask"
role="dialog"
aria-modal="true"
>
<div class="announcement-popup">
<!-- 顶部品牌强调条 -->
<div class="popup-brand-bar" />
<!-- 头部:雅致浅色顶栏 + 品牌橙徽标 + 标题与标签 -->
<header class="popup-header">
<div class="popup-icon-wrapper" aria-hidden="true">
<svg viewBox="0 0 24 24" class="popup-icon-svg">
<path
fill="currentColor"
d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"
/>
</svg>
</div>
<div class="popup-heading">
<h3 class="popup-title">{{ announcement.title }}</h3>
<p class="popup-subtitle">请仔细阅读以下内容</p>
</div>
<div class="popup-badge">
{{ announcement.is_important ? '重要公告' : '平台公告' }}
</div>
</header>
<!-- 正文区域:独立圆角浅色阅读卡片 -->
<div class="popup-body">
<div class="popup-content-box">
<div class="popup-content" v-html="contentHtml" />
</div>
<div v-if="publishedAtText" class="popup-meta">
<svg viewBox="0 0 24 24" class="meta-clock-icon" aria-hidden="true">
<path
fill="currentColor"
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"
/>
</svg>
<span>发布于 {{ publishedAtText }}</span>
</div>
</div>
<!-- 底部操作:温润纯正的大锤橙主操作按钮 -->
<footer class="popup-footer">
<button type="button" class="popup-action-btn" @click="handleClose">我知道了</button>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* ===== 遮罩层:轻量毛玻璃 + 适度柔和压暗 ===== */
.announcement-popup-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
touch-action: none;
}
/* ===== 弹窗主体卡片 ===== */
.announcement-popup {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
max-width: 580px;
max-height: calc(100vh - 48px);
border-radius: 20px;
background: #ffffff;
box-shadow:
0 20px 50px -12px rgba(15, 23, 42, 0.18),
0 4px 16px -4px rgba(234, 88, 12, 0.08),
0 0 0 1px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
/* 顶部品牌橙流光细条 */
.popup-brand-bar {
width: 100%;
height: 4px;
flex-shrink: 0;
background: linear-gradient(90deg, #ff7a1a 0%, #ea580c 50%, #f97316 100%);
}
/* ===== 头部:雅致浅色顶栏 + 品牌橙徽标 + 标题与标签 ===== */
.popup-header {
position: relative;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
padding: 22px 28px 18px;
background: linear-gradient(180deg, #fffbf8 0%, #ffffff 100%);
border-bottom: 1px solid #f1f5f9;
}
/* 品牌圆形图标徽章:浅暖橙底 + 品牌橙图标,温润不刺眼 */
.popup-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 46px;
height: 46px;
border-radius: 50%;
background: #fff7ed;
border: 1px solid #fed7aa;
box-shadow: 0 2px 8px rgba(234, 88, 12, 0.1);
color: #ea580c;
}
.popup-icon-svg {
width: 23px;
height: 23px;
}
.popup-heading {
flex: 1;
min-width: 0;
}
.popup-title {
margin: 0;
color: #0f172a;
font-size: 18px;
font-weight: 700;
line-height: 1.4;
letter-spacing: 0.2px;
word-break: break-word;
}
.popup-subtitle {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
line-height: 1.4;
}
/* 右侧标签(精致温和的浅暖橙胶囊) */
.popup-badge {
flex-shrink: 0;
padding: 4px 11px;
border: 1px solid #fed7aa;
border-radius: 999px;
background: #fff7ed;
color: #ea580c;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.3px;
}
/* ===== 正文阅读区 ===== */
.popup-body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: auto;
padding: 20px 28px 14px;
}
/* 内部浅色阅读卡片容器 */
.popup-content-box {
padding: 20px 22px;
border: 1px solid #eef2f6;
border-radius: 14px;
background: #f8fafc;
}
.popup-content {
color: #334155;
font-size: 15px;
line-height: 1.85;
word-break: break-word;
}
.popup-content :deep(img) {
display: block;
max-width: 100%;
height: auto;
margin: 14px 0;
border-radius: 10px;
}
.popup-content :deep(a) {
color: #ea580c;
font-weight: 600;
text-decoration: none;
}
.popup-content :deep(a:hover) {
text-decoration: underline;
}
.popup-content :deep(strong) {
color: #0f172a;
font-weight: 700;
}
.popup-content :deep(h1),
.popup-content :deep(h2),
.popup-content :deep(h3) {
margin: 18px 0 10px;
color: #0f172a;
font-size: 16px;
font-weight: 700;
line-height: 1.5;
}
.popup-content :deep(h1:first-child),
.popup-content :deep(h2:first-child),
.popup-content :deep(h3:first-child) {
margin-top: 0;
}
.popup-content :deep(p) {
margin: 0 0 12px;
}
.popup-content :deep(ul),
.popup-content :deep(ol) {
margin: 0 0 12px;
padding-left: 20px;
}
.popup-content :deep(li) {
margin: 5px 0;
}
.popup-content :deep(li::marker) {
color: #ea580c;
}
.popup-content :deep(blockquote) {
margin: 0 0 12px;
padding: 10px 16px;
border-left: 3px solid #f97316;
border-radius: 0 8px 8px 0;
background: #fff7ed;
color: #9a3412;
}
.popup-content :deep(code) {
padding: 2px 6px;
border: 1px solid #fed7aa;
border-radius: 5px;
background: #fff7ed;
color: #ea580c;
font-size: 13.5px;
}
.popup-content :deep(hr) {
margin: 16px 0;
border: 0;
border-top: 1px solid #e2e8f0;
}
.popup-content :deep(> :last-child) {
margin-bottom: 0;
}
/* 时间元信息 */
.popup-meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin-top: 12px;
color: #94a3b8;
font-size: 12px;
}
.meta-clock-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* ===== 底部操作区 ===== */
.popup-footer {
flex-shrink: 0;
padding: 4px 28px 24px;
}
/* 饱满温润的品牌橙主操作按钮 */
.popup-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 48px;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, #ff7a1a 0%, #ea580c 100%);
color: #ffffff;
font-size: 15.5px;
font-weight: 700;
letter-spacing: 1.5px;
cursor: pointer;
box-shadow: 0 4px 14px rgba(234, 88, 12, 0.25);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.popup-action-btn:hover {
background: linear-gradient(135deg, #ff872e 0%, #f06115 100%);
box-shadow: 0 6px 18px rgba(234, 88, 12, 0.35);
transform: translateY(-1px);
}
.popup-action-btn:active {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(234, 88, 12, 0.2);
}
/* ===== 弹窗出入场微动效 ===== */
.popup-scale-enter-active,
.popup-scale-leave-active {
transition: opacity 0.25s ease;
}
.popup-scale-enter-active .announcement-popup,
.popup-scale-leave-active .announcement-popup {
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.popup-scale-enter-from,
.popup-scale-leave-to {
opacity: 0;
}
.popup-scale-enter-from .announcement-popup,
.popup-scale-leave-to .announcement-popup {
transform: scale(0.95) translateY(6px);
}
/* ===== 移动端响应式 ===== */
@media (max-width: 640px) {
.announcement-popup-mask {
padding: 16px;
}
.announcement-popup {
max-height: calc(100dvh - 32px);
border-radius: 18px;
}
.popup-header {
gap: 12px;
padding: 18px 20px 14px;
}
.popup-icon-wrapper {
width: 40px;
height: 40px;
}
.popup-icon-svg {
width: 20px;
height: 20px;
}
.popup-title {
font-size: 16px;
}
.popup-subtitle {
font-size: 12px;
}
.popup-badge {
padding: 3px 9px;
font-size: 11px;
}
.popup-body {
padding: 16px 20px 10px;
}
.popup-content-box {
padding: 15px 16px;
}
.popup-content {
font-size: 14px;
line-height: 1.8;
}
.popup-footer {
padding: 4px 20px 20px;
}
.popup-action-btn {
min-height: 44px;
font-size: 14.5px;
}
}
</style>
@@ -0,0 +1,56 @@
/**
* 弹窗公告的「每日一次」关闭记录。
*
* 交互口径:用户必须手动点「我知道了」才关闭;关闭后当天不再弹同一条公告,
* 次日自动重置(因此公告仍为启用状态时会重新弹一次)。
* 按公告 ID 分别记录,若换了另一条优先级更高的弹窗公告,当天也会照常弹出。
*/
const STORAGE_KEY = 'announcement_popup_dismissed'
type DismissedMap = Record<string, string>
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 {
// 忽略写入失败(隐私模式等),最差情况是当天重复弹出
}
}
@@ -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) {
<section class="pc-home-redesign">
<HomeAnnouncement :announcements="displayAnnouncements" />
<!-- 弹窗公告:进入首页弹出,必须手动关闭,同一天同一条只弹一次 -->
<AnnouncementPopup />
<main class="home-content">
<!-- 商城专属标识 Banner -->
<div v-if="siteStore.isDistribution" class="dist-portal-banner">
@@ -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()) {
<template>
<main class="mobile-shell">
<!-- 弹窗公告:进入首页弹出,必须手动关闭,同一天同一条只弹一次 -->
<AnnouncementPopup />
<div ref="scrollEl" class="mobile-scroll" @scroll.passive="handleListScroll">
<!-- ========== Hero 区域:顶部搜索与公告 ========== -->
<section class="mobile-hero">