基于 Vant UI + Redisson BitSet 实现签到日历

前言

大家好,我是雪荷,在我的厚米匹配项目(厚米匹配系统)中利用 Vant 自带的日历组件和 Redisson BitSet 实现了一个签到日历,其效果图如下。

思路

实现其实很简单,我们先思考后端该如何实现。记录用户每天是否签到可以通过 Redis 的 BitMap(位图)来实现,其是一个位数组,每个元素占一个比特大小,值只能为 0 或者 1。假如用户今天签到了,那么对应一年的天数即位图对应的索引为 1,未签到的话就为 0。到此大致方案已经基本成型,剩下还有几个小问题。

  1. 如何设计 BitMap 的 key

  2. 如何计算今日至每年 1 月 1 号的天数

  3. 如何查询用户哪些天签到/未签到

对于以上难点该如何解决呢?其实不难,主要难的是第一点和第三点。

我是用的是 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-专业的智能生成商业报表的项目),感兴趣的可以点点🌟,谢谢大家。

相关推荐
南鸢1.02 分钟前
11张思维导图带你快速学习java
java·开发语言
墨鸦_Cormorant2 分钟前
JDK 8 升级 17 及 springboot 2.x 升级 3.x 指南
java·spring boot
ACGkaka_2 分钟前
IDEA 编译报错 “java: 常量字符串过长” 的解决办法
java·ide·intellij-idea
鸽鸽程序猿4 分钟前
【JavaSE】【多线程】线程池
java·java-ee
IT管理圈7 分钟前
50个JAVA常见代码大全:学完这篇从Java小白到架构师
java·windows·python
P7进阶路2 小时前
72.是否可以把所有Bean都通过Spring容器来管理?(Spring的applicationContext.xml中配置全局扫 描)
xml·java·spring
找了一圈尾巴2 小时前
Wend看源码-Java-Map学习
java·学习·map
罗政2 小时前
PDF书籍《手写调用链监控APM系统-Java版》第4章 SPI服务模块化系统
java·pdf·linq
北欧人写代码2 小时前
javaWeb开发
java
犬余2 小时前
设计模式之享元模式:看19路棋盘如何做到一子千面
java·设计模式·享元模式