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