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

相关推荐
咩咩啃树皮4 小时前
第40篇:Vue3组件化开发精讲——组件拆分、复用、父子通信、工程化架构
java·前端·架构
鱟鲥鳚4 小时前
Spring Boot 集成 LangChain4j:从模型调用到 Tool Calling(Demo版)
java·spring boot
大模型码小白6 小时前
【Python零基础教程】继承、多态与魔法函数:面向对象编程三大核心特性详解
java·大数据·开发语言·人工智能·python·ai编程
腾渊信息科技公司6 小时前
Spring Boot对接MES实战:视觉检测数据自动同步方案
java·人工智能·spring boot·后端·计算机视觉·ai·软件需求
爱笑的源码基地7 小时前
高并发 Redis 缓存门诊HIS系统源码,含财务统计药房进销存
java·程序·门诊系统·诊所系统·云诊所源码
wuqingshun3141597 小时前
TCP超时重传机制是为了解决什么问题?
java
二宝哥8 小时前
CentOS 7.9 系统下 Redis 5.0.4 单机安装与源码编译安装指南
redis·centos
莫逸风10 小时前
【AgentScope 2.0】 0. 学习指南
java·llm·agent·agentscope
z1234567898610 小时前
2026最新两款AI编程工具深度对比实测
java·数据库·ai编程
yaoxin52112311 小时前
470. Java 反射 - Member 接口与 AccessFlag
java·开发语言·python