feat(announcement): 新增首页弹窗公告功能并完成弹窗UI视觉重构
- 数据库与后端:announcements 表增加 is_popup 字段及迁移脚本,提供前台弹窗接口与多公告优先级选取,补全选优单测 - 管理后台:总后台与分销后台公告编辑新增「弹窗公告」开关与列表标签展示 - 前台交互:前台 PC/移动端首页引入 AnnouncementPopup,支持同一用户同一天同一公告仅弹一次的关闭记录 - 视觉优化:重构弹窗结构,顶栏镶嵌品牌橙流光细条与浅暖橙通知徽标,中间内嵌浅灰舒适阅读卡片,底部搭配大锤橙实心主操作按钮 - 验证方式:后端 announcement 单测通过,前端 npm run build 编译无报错,浏览器实机截图验证
This commit is contained in:
@@ -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 元兑换哈夫币」比例,不是人民币。
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user