前言
最近没什么事做,也懒得再写博客的新内容了,于是打算做个新东西。
想来想去,似乎自己没找到什么好用的倒计时器,于是自己就写了一个。
这里也不写什么技术细节了,大致看下效果就好。
预览
TimePulse - 现代化倒计时(https://timepulse.ravelloh.top/)








功能
核心功能
- 支持多个计时器的创建和管理
- 数据本地存储与云端同步
- 支持生成分享链接和二维码
- 支持全屏展示模式
- 智能识别常用节假日
- 支持PWA,可安装到主屏幕并离线使用
视觉和交互
- 精美的玻璃态设计和流畅动效
- 自适应模糊渐变背景,随主题色变化
- 暗色/亮色主题自动切换
- 完全响应式设计,适配各种设备
新增特性
- 自定义滚动条与滚动进度指示器,与主题色联动
- PWA功能支持,可作为独立应用安装到设备
- 离线访问支持,无需网络也能使用核心功能
- 优化的渐变背景效果,解决色彩断层问题
- 自定义下拉菜单组件,完美适配明暗两种模式
- 支持键盘导航的无障碍交互体验
- 滚动触发区优化,改善移动设备触控体验
技术
技术方面我觉得有价值的不算多,主要也就是个时间处理和背景设计让我花了点时间。
目前考虑到的节日放在context/TimerContext
中,具体来说,只有以下节日:
// 生成固定日期的节日列表,考虑时区调整
const generateFixedHolidays = (year) => {
// 获取用户时区偏移量(分钟)
const userTimezoneOffsetMinutes = new Date().getTimezoneOffset();
// UTC+0 时区和用户时区的差异小时数(注意符号相反)
const userTimezoneOffsetHours = -userTimezoneOffsetMinutes / 60;
// 创建时区补偿函数
const createDateWithOffset = (monthIndex, day) => {
// 创建UTC时间
const date = new Date(Date.UTC(year, monthIndex, day));
// 格式化为ISO字符串,保留T00:00:00Z的UTC标志
return date.toISOString();
};
return [
// 国际节日 - 日期固定
{ name: `${year}年元旦`, date: createDateWithOffset(0, 1), color: '#1890FF' },
{ name: `${year}年情人节`, date: createDateWithOffset(1, 14), color: '#EB2F96' },
{ name: `${year}年妇女节`, date: createDateWithOffset(2, 8), color: '#C71585' },
{ name: `${year}年植树节`, date: createDateWithOffset(2, 12), color: '#52C41A' },
{ name: `${year}年愚人节`, date: createDateWithOffset(3, 1), color: '#722ED1' },
{ name: `${year}年青年节`, date: createDateWithOffset(4, 4), color: '#722ED1' },
{ name: `${year}年劳动节`, date: createDateWithOffset(4, 1), color: '#FA8C16' },
{ name: `${year}年清明节`, date: getQingmingDate(year).toISOString(), color: '#228B22' },
{ name: `${year}年儿童节`, date: createDateWithOffset(5, 1), color: '#13C2C2' },
{ name: `${year}年建党节`, date: createDateWithOffset(6, 1), color: '#FF0000' },
{ name: `${year}年建军节`, date: createDateWithOffset(7, 1), color: '#CF1322' },
{ name: `${year}年教师节`, date: createDateWithOffset(8, 10), color: '#096DD9' },
{ name: `${year}年国庆节`, date: createDateWithOffset(9, 1), color: '#FF4D4F' },
{ name: `${year}年万圣节`, date: createDateWithOffset(9, 31), color: '#FF7A45' },
{ name: `${year}年平安夜`, date: createDateWithOffset(11, 24), color: '#36CFC9' },
{ name: `${year}年圣诞节`, date: createDateWithOffset(11, 25), color: '#F759AB' },
];
};
// 计算动态节日日期,也考虑时区
const calculateDynamicHolidays = (year) => {
const holidays = [];
// 获取用户时区的偏移量
const userTimezoneOffsetMinutes = new Date().getTimezoneOffset();
const userTimezoneOffsetHours = -userTimezoneOffsetMinutes / 60;
// 创建ISO格式的UTC日期字符串
const createISODate = (date) => {
return date.toISOString();
};
// 母亲节 - 5月第二个星期日
const firstDayOfMay = new Date(Date.UTC(year, 4, 1));
const motherDayDay = firstDayOfMay.getUTCDay(); // 获取星期几
const daysUntilSecondSunday = (7 - motherDayDay) % 7 + 7; // 到第二个星期日的天数
const motherDayDate = new Date(Date.UTC(year, 4, 1 + daysUntilSecondSunday));
holidays.push({
name: `${year}年母亲节`,
date: createISODate(motherDayDate),
color: '#F759AB'
});
// 父亲节 - 6月第三个星期日
const firstDayOfJune = new Date(Date.UTC(year, 5, 1));
const fatherDayDay = firstDayOfJune.getUTCDay(); // 获取星期几
const daysUntilThirdSunday = (7 - fatherDayDay) % 7 + 14; // 到第三个星期日的天数
const fatherDayDate = new Date(Date.UTC(year, 5, 1 + daysUntilThirdSunday));
holidays.push({
name: `${year}年父亲节`,
date: createISODate(fatherDayDate),
color: '#1890FF'
});
// 感恩节 - 11月第四个星期四
const firstDayOfNovember = new Date(Date.UTC(year, 10, 1));
const thanksgivingDayDay = firstDayOfNovember.getUTCDay(); // 获取星期几
const daysToThursday = (4 + 7 - thanksgivingDayDay) % 7; // 到第一个星期四的天数
const thanksgivingDayDate = new Date(Date.UTC(year, 10, 1 + daysToThursday + 21)); // 加21天到第四个星期四
holidays.push({
name: `${year}年感恩节`,
date: createISODate(thanksgivingDayDate),
color: '#FAAD14'
});
return holidays;
};
// 添加农历节日转换函数
const getChineseFestivals = (year) => {
const lunarHolidays = [];
const holidaysMapping = [
{ name: `${year}年春节`, lunarMonth: 1, lunarDay: 1, color: '#FF0000' },
{ name: `${year}年元宵节`, lunarMonth: 1, lunarDay: 15, color: '#FF6347' },
{ name: `${year}年端午节`, lunarMonth: 5, lunarDay: 5, color: '#32CD32' },
{ name: `${year}年七夕节`, lunarMonth: 7, lunarDay: 7, color: '#FF1493' },
{ name: `${year}年中元节`, lunarMonth: 7, lunarDay: 15, color: '#708090' },
{ name: `${year}年中秋节`, lunarMonth: 8, lunarDay: 15, color: '#FFA500' },
{ name: `${year}年重阳节`, lunarMonth: 9, lunarDay: 9, color: '#800080' },
{ name: `${year}年腊八节`, lunarMonth: 12, lunarDay: 8, color: '#8B4513' },
];
holidaysMapping.forEach(holiday => {
// 使用 solarlunar 将农历日期转换为公历日期
const solarDate = solarlunar.lunar2solar(year, holiday.lunarMonth, holiday.lunarDay, false);
// 构造 ISO 格式日期字符串,注意月-1
const date = new Date(Date.UTC(solarDate.cYear, solarDate.cMonth - 1, solarDate.cDay)).toISOString();
lunarHolidays.push({
name: holiday.name,
date: date,
color: holiday.color
});
});
return lunarHolidays;
};
一般来说这些已经够了,你也可以直接拿这部分成品节日日期生成器自己用。
另外比较有意思的是Background,文件在components/Background/GradientBackground.js
。
这里其实做到了一种动态化的背景效果,实现方式也比较取巧,是直接在高斯模糊后面加上几个半透明圆,赋予其不同的位置、初速度、颜色,让他们自由组合,效果就已经不错了。

import { useRef, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useTimers } from '../../context/TimerContext';
import { useTheme } from '../../context/ThemeContext';
export default function GradientBackground() {
const { getActiveTimer, activeTimerId } = useTimers();
const { theme } = useTheme();
const [circles, setCircles] = useState([]);
const [prevTimerId, setPrevTimerId] = useState(null);
const containerRef = useRef(null);
const activeTimer = getActiveTimer();
const [isSafari, setIsSafari] = useState(false);
// 检测Safari浏览器
useEffect(() => {
// 检测Safari浏览器
const isSafariBrowser =
navigator.userAgent.indexOf('Safari') !== -1 &&
navigator.userAgent.indexOf('Chrome') === -1;
// 检测iOS设备
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
setIsSafari(isSafariBrowser || isIOS);
}, []);
// 生成渐变圆圈 - 针对Safari减少圆圈数量和动画复杂度
useEffect(() => {
// 检查计时器是否变化
const isNewTimer = activeTimerId !== prevTimerId;
if (isNewTimer) {
setPrevTimerId(activeTimerId);
}
// 基于活动计时器的颜色生成颜色
const baseColor = activeTimer?.color || '#0ea5e9';
const colors = generateColors(baseColor, theme === 'dark');
// 针对Safari减少圆圈数量
const circleCount = 5
// 生成或更新圆圈配置
if (circles.length === 0 || isNewTimer) {
// 如果是新计时器或初始化,创建新圆圈
const newCircles = Array.from({ length: circleCount }, (_, i) => ({
id: i,
x: Math.random() * 100 - 30 + Math.random() * 40,
y: Math.random() * 100 - 30 + Math.random() * 40,
size: 30 + Math.random() * 40,
speedX: (Math.random() - 0.5) * (isSafari ? 0.01 : 0.03), // 降低Safari中的速度
speedY: (Math.random() - 0.5) * (isSafari ? 0.01 : 0.03), // 降低Safari中的速度
color: colors[i % colors.length],
blur: isSafari ? 40 : (60 + Math.random() * 40), // 减少Safari中的模糊强度
opacity: 0.3 + Math.random() * (isSafari ? 0.2 : 0.3) // 降低Safari中的透明度变化
}));
setCircles(newCircles);
} else if (activeTimer) {
// 如果计时器颜色变化,平滑过渡圆圈颜色
setCircles(prev => prev.map((circle, i) => ({
...circle,
// 使用过渡这里,而不是闪烁效果
color: colors[i % colors.length]
})));
}
}, [activeTimerId, theme, activeTimer, isSafari]);
// 根据基础颜色生成一组和谐的颜色
const generateColors = (baseColor, isDark) => {
// 解析颜色
let r, g, b;
if (baseColor.startsWith('#')) {
const hex = baseColor.slice(1);
r = parseInt(hex.slice(0, 2), 16);
g = parseInt(hex.slice(2, 4), 16);
b = parseInt(hex.slice(4, 6), 16);
} else {
// 默认颜色
r = 14;
g = 165;
b = 233;
}
// 生成颜色变体
return [
`rgba(${r}, ${g}, ${b}, 0.5)`, // 原色
`rgba(${r * 0.8}, ${g * 1.1}, ${b * 1.2}, 0.5)`, // 变体1
`rgba(${r * 1.2}, ${g * 0.8}, ${b * 0.9}, 0.5)`, // 变体2
`rgba(${r * 0.9}, ${g * 0.9}, ${b * 1.3}, 0.5)`, // 变体3
`rgba(${r * 1.1}, ${g * 1.2}, ${b * 0.8}, 0.5)`, // 变体4
`rgba(${r * 1.3}, ${g * 0.9}, ${b * 0.9}, 0.5)` // 变体5
];
};
return (
<div className="fixed inset-0 overflow-hidden z-0 pointer-events-none">
<AnimatePresence>
{circles.map(circle => (
<motion.div
key={`circle-${circle.id}-${activeTimerId || 'default'}`}
className="moving-circle absolute"
initial={{
left: `${circle.x}vw`,
top: `${circle.y}vh`,
width: `${circle.size}vw`,
height: `${circle.size}vw`,
opacity: 0
}}
animate={{
left: [`${circle.x}vw`, `${circle.x + circle.speedX * 100}vw`],
top: [`${circle.y}vh`, `${circle.y + circle.speedY * 100}vh`],
backgroundColor: circle.color,
filter: `blur(${circle.blur}px)`,
opacity: circle.opacity
}}
transition={{
left: { duration: isSafari ? 30 : 20, ease: "linear", repeat: Infinity, repeatType: "reverse" },
top: { duration: isSafari ? 30 : 20, ease: "linear", repeat: Infinity, repeatType: "reverse" },
// 增加Safari中的过渡持续时间,减少更新频率
backgroundColor: { duration: isSafari ? 3.5 : 2.5, ease: "easeOut" },
opacity: { duration: isSafari ? 1.2 : 0.8 }
}}
style={{
// 添加硬件加速属性
WebkitTransform: "translateZ(0)",
transform: "translateZ(0)",
willChange: "transform"
}}
/>
))}
</AnimatePresence>
{/* 移除闪烁动画,替换为以下更平滑的过渡效果 */}
{activeTimerId !== prevTimerId && prevTimerId !== null && (
<motion.div
className="absolute inset-0 z-0 pointer-events-none"
// 背景使用径向渐变,从中心向外扩散,效果更自然
style={{
background: `radial-gradient(circle at center, ${activeTimer?.color || '#0ea5e9'}05 0%, transparent 70%)`
}}
initial={{ opacity: 0 }}
animate={{ opacity: 0.5 }}
exit={{ opacity: 0 }}
transition={{ duration: 2.5, ease: "easeOut" }}
onAnimationComplete={() => setPrevTimerId(activeTimerId)}
/>
)}
</div>
);
}