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

相关推荐
知兀8 分钟前
【MybatisPlus】后端用枚举类,数据库用tinyint,存在枚举类型转换
java
StockTV11 分钟前
印度股票实时数据 NSE和BSE的实时行情、K 线及指数数据
java·开发语言·spring boot·python
User_芊芊君子13 分钟前
【OpenAI 把 AI 玩明白了】:自主推理 + 动态知识图谱,这 4 个技术突破要颠覆行业
java·人工智能·知识图谱
c++之路1 小时前
C++20概述
java·开发语言·c++20
Championship.23.241 小时前
Linux Top 命令族深度解析与实战指南
java·linux·服务器·top·linux调试
橘子海全栈攻城狮1 小时前
【最新源码】养老院系统管理A013
java·spring boot·后端·web安全·微信小程序
逻辑驱动的ken1 小时前
Java高频面试考点18
java·开发语言·数据库·算法·面试·职场和发展·哈希算法
冷雨夜中漫步2 小时前
Claude Code源码分析——Claude Code Agent Loop 详细设计文档
java·开发语言·人工智能·ai
直奔標竿2 小时前
Java开发者AI转型第二十六课!Spring AI 个人知识库实战(五)——联网搜索增强实战
java·开发语言·人工智能·spring boot·后端·spring
快乐非自愿2 小时前
Redis--SDS字符串与集合的底层实现原理
数据库·redis·缓存