基于 Vant UI + Redisson BitSet 实现签到日历
前言
大家好,我是雪荷,在我的厚米匹配项目(厚米匹配系统)中利用 Vant 自带的日历组件和 Redisson BitSet 实现了一个签到日历,其效果图如下。
思路
实现其实很简单,我们先思考后端该如何实现。记录用户每天是否签到可以通过 Redis 的 BitMap(位图)来实现,其是一个位数组,每个元素占一个比特大小,值只能为 0 或者 1。假如用户今天签到了,那么对应一年的天数即位图对应的索引为 1,未签到的话就为 0。到此大致方案已经基本成型,剩下还有几个小问题。
-
如何设计 BitMap 的 key
-
如何计算今日至每年 1 月 1 号的天数
-
如何查询用户哪些天签到/未签到
对于以上难点该如何解决呢?其实不难,主要难的是第一点和第三点。
我是用的是 Redisson 的 BitSet 实现的,其与 BitMap 都是基于 Redis String 类型实现的,但是 BitSet 类似 Java 自带的 BitSet,API 更多并且还支持位运算操作,并且 BitSet 除了存储 0/1 还能存储 boolean 类型(true/false)。
如何设计 BitMap 的 key
对于第一点,我们该如何设计 BitMap 的 key 呢?往深一步说就是,该如何设计 key 来区分每个用户某一年的签到记录呢?我的方案是这样的,系统名 + 业务名 + 用户 ID + 年份 ID。仔细想想是不是解决了呢,想要查询某个用户某一年的签到记录直接拼接用户 ID 和年份不就好了吗。
如何查询用户哪些天签到/未签到
BitSet 支持遍历操作,直接判断值是否为 1 即可,为 1 的话算出对应的日期。我们存储的索引位置是按照签到日与 1.1 日距离的天数来存的,反过来根据天数来算日期岂不是易如反掌。将已签到的日期存到一个 List 中返回给前端,前端根据 List 进行渲染,不在 List 的日期就代表未签到。
Calendar 组件介绍
Calendar 是 Vant 的一个日历组件,其支持很多 API 和属性,这里我就不一一介绍了,我们只说重要的。Calendar 日历组件显示的日期、属性啥的都是提前渲染好的,那我们该如何渲染和标记已签到和未签到的日期呢?幸好官方给我们提供了一个异步渲染示例——通过计算属性实现。
另外再提一嘴,日历组件的日期数据结构为 Day,可以根据 formatter 属性来自定义 Day 对象属性,如显示“已签到”,“未签到”啥的,具体属性如下:
完整代码
前端代码
<template> <div v-if="datesLoaded"> <van-calendar switch-mode="month" title="每日签到" type="multiple" :poppable="false" :min-date="minDate" :max-date="maxDate" :default-date="selectedDates" :style="{ height: '500px' }" readonly="true" :show-confirm="false" :formatter="formatter"> </van-calendar> <div style="margin: auto; display: flex; flex-direction: column; align-items: center;" v-if="isSignedIn !== undefined"> <van-button type="primary" v-if="!isSignedIn" style="width: 320px; margin-bottom: 10px;" @click="signIn">签到 </van-button> <van-button color="grey" v-if="isSignedIn" disabled style="width: 320px;">已签到</van-button> </div> </div> </template> <script setup lang="ts"> import {computed, ref, watchEffect} from 'vue'; import myAxios from "../../plugins/myAxios"; import {showFailToast, showSuccessToast} from "vant"; const minDate = ref(new Date(2024, 6, 1)); const maxDate = ref(new Date(2025, 6, 1)); const isSignedIn = ref(false); const signedInDayNum = ref(0); const selectedDates = ref<Date[]>([]); const datesLoaded = ref(false); const loadData = async () => { const res: any = await myAxios.get('/user/sign/in/info/get'); if (res?.code === 0) { isSignedIn.value = res.data.isSignedIn; signedInDayNum.value = res.data.signedInDayNum; selectedDates.value = res.data.signedInDates.map((dateStr: string) => new Date(dateStr)); if (selectedDates.value.length === 0) { selectedDates.value.push(new Date()); } datesLoaded.value = true; // 标记数据已加载 } else { showFailToast('加载失败!'); } }; watchEffect(async () => { loadData(); }); const signIn = async () => { const res: any = await myAxios.post('/user/sign/in'); if (res?.code === 0) { showSuccessToast('签到成功!'); loadData(); } else { showFailToast('签到失败!' + (res.description ? `,${res.description}` : '')); } }; const formatter = computed(() => { if (!datesLoaded.value) { return (day) => day; } return (day) => { const currentDate = new Date(day.date); currentDate.setHours(0, 0, 0, 0); // 只保留日期部分 const today = new Date(); today.setHours(0, 0, 0, 0); // 只保留日期部分 const isInSelectedDates = selectedDates.value.some(date => { console.log(date) const selectedDate = new Date(date); selectedDate.setHours(0, 0, 0, 0); return selectedDate.getTime() === currentDate.getTime(); }); if (currentDate.getTime() < today.getTime()) { day.topInfo = isInSelectedDates ? '已签到' : '未签到'; day.bottomInfo = isInSelectedDates ? '+10 积分' : ''; } else if (currentDate.getTime() === today.getTime() && isSignedIn.value) { day.topInfo = isInSelectedDates ? '已签到' : '未签到'; day.bottomInfo = '+10 积分'; } else { day.topInfo = ''; } return day; }; }); /** * 解析 yyyy-MM-dd 格式的日期字符串,并返回 Date 对象 * @param dateStr - 日期字符串,格式为 yyyy-MM-dd * @returns {Date} - Date 对象 */ const parseDate = (dateStr: string): Date => { const [year, month, day] = dateStr.split('-').map(Number); return new Date(year, month - 1, day); // 月份从 0 开始计数 }; </script> <style scoped> </style>
后端代码
@Component public class SignInManager { @Resource private RedissonClient redissonClient; // 判断是否签到 public boolean isSignIn(String key) { int days = DateUtils.getGapDayFromFirstDayOfYear(); RBitSet bitSet = redissonClient.getBitSet(key); return bitSet.get(days); } // 签到 public void signIn(String key) { RBitSet bitSet = redissonClient.getBitSet(key); int days = DateUtils.getGapDayFromFirstDayOfYear(); bitSet.set(days, true); } // 获取签到信息 public SignInInfoVO getSignInInfo(String key) { RBitSet bitSet = redissonClient.getBitSet(key); SignInInfoVO signInInfoVO = new SignInInfoVO(); signInInfoVO.setIsSignedIn(this.isSignIn(key)); signInInfoVO.setSignedInDayNum((int) bitSet.cardinality()); List<Integer> signedInDateIndexList = new ArrayList<>(); List<java.util.Date> signedInDateList = new ArrayList<>(); LocalDate today = LocalDate.now(); if (redissonClient.getKeys().countExists(key) > 0 && bitSet.length() > 0) { for (int i = 0; i < bitSet.length(); i++) { if (bitSet.get(i)) { signedInDateIndexList.add(i); } } signedInDateList = signedInDateIndexList.stream().map(signedInDateIndex -> { LocalDate signedInLocalDate = today.minusDays(DateUtils.getGapDayFromFirstDayOfYear() - signedInDateIndex); return Date.from(signedInLocalDate.atStartOfDay(ZoneId.systemDefault()).toInstant()); }).collect(Collectors.toList()); } signInInfoVO.setSignedInDates(signedInDateList); return signInInfoVO; } }
如有任何项目问题,欢迎私信询问和探讨,如果能有人 PR 就更好了。
项目地址
网址:厚米匹配系统
前端地址:GitHub - dnwwdwd/homieMatching-fronted: homie 匹配系统前端,基于 vuehomie 匹配系统是一个移动端网页的在线云交友平台。实现了按标签匹配、查找用户,基于 Redis GEO 实现搜索附近用户,同时个人还可以建队、组队以打造个人学习队伍。除了添加好友、搜索好友外,还基于 Websocket 实现好友间私聊,方便用户寻找志同道合的学习搭子。
后端地址:GitHub - dnwwdwd/homieMatching: homie 匹配系统是一个移动端网页的在线云交友平台。实现了按标签匹配、查找用户,基于 Redis GEO 实现搜索附近用户,同时个人还可以建队、组队以打造个人学习队伍。除了添加好友、搜索好友外,还基于 Websocket 实现好友间私聊,方便用户寻找志同道合的学习搭子。
欢迎👏大家体验网站也帮忙给我点点🌟哈,真的十分感谢各位,如果任何讲的不对的地方请及时指正。另外,最近在重构 BI 项目(GitHub - dnwwdwd/Lingxi-BI: 灵犀BI-专业的智能生成商业报表的项目),感兴趣的可以点点🌟,谢谢大家。