每日推荐弹窗功能实现
最近给博客加了个功能:用户进入网站首页时,弹出每日推荐弹窗,每天随机推荐一篇文章。同一天内不会重复显示,提升用户体验。
一、功能需求
- 用户进入网站首页时,弹出每日推荐弹窗
- 每天随机推荐一篇文章(同一天返回同一篇)
- 展示文章封面、标题、日期和阅读按钮
- 同一天内只显示一次(localStorage 记录)
二、技术选型
- 后端:Spring Boot + Redis + MyBatis-Plus
- 前端:Vue 3 + TypeScript + Axios + localStorage
三、后端实现
1. Redis 常量
在 RedisConstant.java 中添加:
java
package com.ican.constant;
/**
* Redis常量
*
* @author ican
*/
public class RedisConstant {
// ... 其他常量 ...
/**
* 每日推荐文章
*/
public static final String DAILY_ARTICLE = "daily_article:";
}
2. 控制器添加接口
在 ArticleController.java 中添加:
java
package com.ican.controller;
import com.ican.model.vo.Result;
import com.ican.model.vo.response.ArticleRecommendResp;
import com.ican.service.ArticleService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* 文章控制器
*
* @author ican
*/
@Api(tags = "文章模块")
@RestController
public class ArticleController {
@Autowired
private ArticleService articleService;
/**
* 每日推荐文章
*
* @return {@link Result<ArticleRecommendResp>} 每日推荐文章
*/
@ApiOperation(value = "每日推荐文章")
@GetMapping("/article/daily")
public Result<ArticleRecommendResp> getDailyArticle() {
return Result.success(articleService.getDailyArticle());
}
}
接口放在 /article/ 路径下,不需要登录(前台功能)。
3. 服务类添加方法
在 ArticleService.java 中添加:
java
package com.ican.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ican.constant.CommonConstant;
import com.ican.constant.RedisConstant;
import com.ican.entity.Article;
import com.ican.enums.ArticleStatusEnum;
import com.ican.mapper.ArticleMapper;
import com.ican.model.vo.response.ArticleRecommendResp;
import com.ican.service.RedisService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Random;
@Service
public class ArticleService {
@Autowired
private ArticleMapper articleMapper;
@Autowired
private RedisService redisService;
/**
* 每日推荐文章(同一天返回同一篇,基于 Redis 缓存)
*
* @return 推荐文章信息
*/
public ArticleRecommendResp getDailyArticle() {
String todayKey = RedisConstant.DAILY_ARTICLE + LocalDate.now();
// 尝试从缓存获取
Object cachedId = redisService.getObject(todayKey);
if (cachedId != null) {
Integer articleId = Integer.parseInt(cachedId.toString());
Article article = articleMapper.selectOne(new LambdaQueryWrapper<Article>()
.select(Article::getId, Article::getArticleTitle, Article::getArticleCover, Article::getCreateTime)
.eq(Article::getId, articleId)
.eq(Article::getIsDelete, CommonConstant.FALSE)
.eq(Article::getStatus, ArticleStatusEnum.PUBLIC.getStatus()));
if (article != null) {
// 返回缓存的文章
ArticleRecommendResp resp = new ArticleRecommendResp();
resp.setId(article.getId());
resp.setArticleTitle(article.getArticleTitle());
resp.setArticleCover(article.getArticleCover());
resp.setCreateTime(article.getCreateTime());
return resp;
}
}
// 随机选一篇公开文章
Long count = articleMapper.selectCount(new LambdaQueryWrapper<Article>()
.eq(Article::getIsDelete, CommonConstant.FALSE)
.eq(Article::getStatus, ArticleStatusEnum.PUBLIC.getStatus()));
if (count == 0) {
return null;
}
// 随机偏移
int offset = new Random().nextInt(count.intValue());
List<Article> articles = articleMapper.selectList(new LambdaQueryWrapper<Article>()
.select(Article::getId, Article::getArticleTitle, Article::getArticleCover, Article::getCreateTime)
.eq(Article::getIsDelete, CommonConstant.FALSE)
.eq(Article::getStatus, ArticleStatusEnum.PUBLIC.getStatus())
.last("LIMIT 1 OFFSET " + offset));
if (articles.isEmpty()) {
return null;
}
Article article = articles.get(0);
// 缓存到 Redis,过期时间到当天结束
long secondsUntilMidnight = Duration.between(
LocalDateTime.now(),
LocalDate.now().plusDays(1).atStartOfDay()
).getSeconds();
redisService.setObject(todayKey, article.getId(), secondsUntilMidnight, TimeUnit.SECONDS);
ArticleRecommendResp resp = new ArticleRecommendResp();
resp.setId(article.getId());
resp.setArticleTitle(article.getArticleTitle());
resp.setArticleCover(article.getArticleCover());
resp.setCreateTime(article.getCreateTime());
return resp;
}
}
关键点说明:
- 用
LocalDate.now()作为 Redis key 的一部分,确保同一天返回同一篇文章 - 先尝试从缓存获取,有则直接返回
- 没有缓存则随机选一篇公开文章
- 随机方法:先查总数,再用
OFFSET随机偏移(性能比ORDER BY RAND()好) - 缓存过期时间:计算到第二天 0 点的秒数,确保当天结束前有效
4. 响应实体类
确保有 ArticleRecommendResp.java:
java
package com.ican.model.vo.response;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 推荐文章Response
*
* @author ican
*/
@Data
@ApiModel(description = "推荐文章Response")
public class ArticleRecommendResp {
@ApiModelProperty(value = "文章id")
private Integer id;
@ApiModelProperty(value = "文章标题")
private String articleTitle;
@ApiModelProperty(value = "文章缩略图")
private String articleCover;
@ApiModelProperty(value = "发布时间")
private LocalDateTime createTime;
}
四、前端实现
1. API 函数
在 api/article/index.ts 中添加:
typescript
import { Result } from "@/model";
import request from "@/utils/request";
import { AxiosPromise } from "axios";
import { ArticleRecommend } from "./types";
/**
* 每日推荐文章
* @returns 推荐文章
*/
export function getDailyArticle(): AxiosPromise<Result<ArticleRecommend>> {
return request({
url: "/article/daily",
method: "get",
});
}
2. 创建每日推荐组件
创建 components/DailyRecommend/index.vue:
vue
<template>
<Teleport to="body">
<Transition name="daily-fade">
<div class="daily-overlay" v-if="visible" @click.self="close">
<div class="daily-card">
<button class="daily-close" @click="close">
<svg-icon icon-class="close" size="1rem"></svg-icon>
</button>
<div class="daily-badge">每日推荐</div>
<div class="daily-cover" v-if="article">
<router-link :to="`/article/${article.id}`" @click="close">
<img :src="article.articleCover" alt="article cover" />
</router-link>
</div>
<div class="daily-info" v-if="article">
<router-link
:to="`/article/${article.id}`"
class="daily-title"
@click="close"
>
{{ article.articleTitle }}
</router-link>
<div class="daily-date">
<svg-icon
icon-class="calendar"
size="0.8rem"
style="margin-right: 0.3rem"
></svg-icon>
{{ formatDate(article.createTime) }}
</div>
</div>
<div class="daily-loading" v-else>
加载中...
</div>
<div class="daily-footer">
<router-link
v-if="article"
:to="`/article/${article.id}`"
class="daily-read-btn"
@click="close"
>
阅读文章
</router-link>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { getDailyArticle } from "@/api/article";
import { ArticleRecommend } from "@/api/article/types";
import { formatDate } from "@/utils/date";
const visible = ref(false);
const article = ref<ArticleRecommend | null>(null);
onMounted(() => {
// 检查今天是否已经展示过
const today = new Date().toISOString().slice(0, 10);
const lastShown = localStorage.getItem("daily_recommend_date");
if (lastShown === today) return;
getDailyArticle()
.then(({ data }) => {
if (data.flag && data.data) {
article.value = data.data;
visible.value = true;
localStorage.setItem("daily_recommend_date", today);
}
})
.catch(() => {
// 静默失败,不影响页面正常显示
});
});
const close = () => {
visible.value = false;
};
</script>
<style lang="scss" scoped>
.daily-fade-enter-active,
.daily-fade-leave-active {
transition: opacity 0.3s ease;
}
.daily-fade-enter-from,
.daily-fade-leave-to {
opacity: 0;
}
.daily-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
}
.daily-card {
position: relative;
width: 380px;
max-width: 90vw;
background: var(--grey-0);
border-radius: 1rem;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
animation: slideUp 0.4s ease;
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.daily-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 10;
background: rgba(0, 0, 0, 0.3);
border: none;
border-radius: 50%;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #fff;
transition: background 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.5);
}
}
.daily-badge {
position: absolute;
top: 0.75rem;
left: 0.75rem;
z-index: 10;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.05rem;
}
.daily-cover {
width: 100%;
height: 200px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
&:hover {
transform: scale(1.05);
}
}
}
.daily-info {
padding: 1rem 1.25rem 0.5rem;
}
.daily-title {
display: block;
font-size: 1.1rem;
font-weight: 600;
color: var(--grey-8);
line-height: 1.5;
margin-bottom: 0.5rem;
text-decoration: none;
transition: color 0.2s;
&:hover {
color: var(--primary-color);
}
}
.daily-date {
display: flex;
align-items: center;
font-size: 0.8rem;
color: var(--grey-5);
}
.daily-loading {
padding: 2rem;
text-align: center;
color: var(--grey-5);
}
.daily-footer {
padding: 0.75rem 1.25rem 1.25rem;
display: flex;
justify-content: center;
}
.daily-read-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1.5rem;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
border-radius: 2rem;
font-size: 0.9rem;
text-decoration: none;
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
}
@media (max-width: 767px) {
.daily-card {
width: 320px;
}
.daily-cover {
height: 160px;
}
}
</style>
3. 在首页引入
在 views/Home/index.vue 中:
vue
<template>
<!-- ... 其他内容 ... -->
<!-- 每日推荐 -->
<DailyRecommend></DailyRecommend>
</template>
<script setup lang="ts">
import DailyRecommend from "@/components/DailyRecommend/index.vue";
// ... 其他导入 ...
</script>
五、使用效果
- 用户首次进入网站首页
- 页面加载完成后,自动调用每日推荐接口
- 如果今天还没显示过,弹出每日推荐弹窗
- 弹窗展示随机文章的封面、标题、日期
- 用户可以点击「阅读文章」跳转,或点击关闭按钮
- 同一天内不会重复显示(localStorage 记录)
六、遇到的问题
1. Redis 过期时间计算
问题:需要确保缓存到当天结束,但计算到第二天 0 点的秒数可能不准确。
解决 :用 Duration.between() 计算当前时间到第二天 0 点的秒数,确保精确。
2. 随机选择文章
问题 :MySQL 的 ORDER BY RAND() 性能不好,文章多时会很慢。
解决 :先查总数,再用 OFFSET 随机偏移:LIMIT 1 OFFSET {randomOffset}。性能更好。
3. 前端重复显示
问题:用户刷新页面时,可能重复显示弹窗。
解决 :用 localStorage 记录今天是否已显示过,key 是 daily_recommend_date,value 是日期字符串(如 2026-02-08)。
4. 文章不存在
问题:缓存的文章 ID 对应的文章可能已被删除或改为私密。
解决:从缓存获取文章后,再查一次数据库,确保文章存在且是公开状态。如果不存在,重新随机选择。
七、总结
这个功能实现起来不算复杂:
- 后端:用 Redis 缓存当天随机选中的文章,确保同一天返回同一篇
- 前端:首页加载时调用接口,检查今天是否已显示过,未显示则弹出弹窗
关键是:
- Redis key 用日期,确保同一天返回同一篇文章
- 缓存过期时间精确到当天结束
- 随机选择用
OFFSET而不是ORDER BY RAND(),性能更好 - 前端用
localStorage记录今天是否已显示过 - 处理文章不存在的情况,重新随机选择
现在用户进入网站时,每天都能看到一篇推荐文章,提升用户体验。如果用户不想看,也可以关闭弹窗,不影响正常浏览。
八、完整代码文件清单
后端:
RedisConstant.java- 添加DAILY_ARTICLE常量ArticleController.java- 添加/article/daily接口ArticleService.java- 添加getDailyArticle方法ArticleRecommendResp.java- 响应实体类(如果还没有)
前端:
api/article/index.ts- 添加getDailyArticle函数components/DailyRecommend/index.vue- 新建每日推荐组件views/Home/index.vue- 引入每日推荐组件
按照上面的步骤,就可以在自己的项目中实现这个功能了。