Timepulse：现代化高颜值计时器

前往 https://ravelloh.com/posts/timepulse-modern-beautiful-timer 查看以获得最佳体验。

前言

最近没什么事做，也懒得再写博客的新内容了，于是打算做个新东西。

想来想去，似乎自己没找到什么好用的倒计时器，于是自己就写了一个。

这里也不写什么技术细节了，大致看下效果就好。

预览

TimePulse - 现代化倒计时(https://timepulse.ravelloh.top/)

Github: RavelloH/TimePulse: 致力于成为最漂亮的倒计时应用。 TimePulse 是一个具有现代化 UI 和交互的倒计时网页应用，支持多计时器管理、数据分享、数据同步、全屏展示等功能。采用玻璃态设计和流畅动画效果，提供极佳的视觉体验。

功能

核心功能

支持多个计时器的创建和管理

数据本地存储与云端同步

支持生成分享链接和二维码

支持全屏展示模式

智能识别常用节假日

支持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 (

<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)}
/>
)}

);
}