前言
大家好,我是雪荷,在我的厚米匹配项目(厚米匹配系统)中利用 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 就更好了。
项目地址
网址:厚米匹配系统
欢迎👏大家体验网站也帮忙给我点点🌟哈,真的十分感谢各位,如果任何讲的不对的地方请及时指正。另外,最近在重构 BI 项目(GitHub - dnwwdwd/Lingxi-BI: 灵犀BI-专业的智能生成商业报表的项目),感兴趣的可以点点🌟,谢谢大家。