
一、项目概述
本项目旨在创建一个高度可定制的恋爱时间倒计时网页,支持纪念日日期设置、背景主题切换、个性化文案定制等功能,并通过localStorage保存用户配置。技术栈将采用HTML5、Tailwind CSS v3和原生JavaScript,结合Canvas粒子动画实现视觉吸引力。
二、核心功能设计
-
双模式计时系统
- 正计时:记录恋爱天数(支持精确到秒级更新)
- 倒计时:重要纪念日提醒(如100天、周年纪念)
-
个性化定制面板
- 日期选择器:支持公历/农历切换
- 背景设置:纯色渐变、粒子动画、自定义图片上传
- 主题系统:预设3套浪漫主题(粉紫渐变/星空蓝/蜜桃粉)
- 文案定制:主标题、副标题自定义
- 字体选择:3种字体风格(手写体/衬线体/无衬线体)
-
视觉动效设计
- 爱心粒子背景:鼠标交互时粒子聚合为爱心形状
- 时间数字动画:数字变化时的平滑过渡效果
- 纪念日里程碑:特殊天数(如520天)的烟花特效
三、技术实现方案
- 倒计时核心逻辑
javascript
// 高精度计时实现(避免setInterval延迟问题)
function startCountdown(targetDate) {
const updateTimer = () => {
const now = new Date().getTime();
const diff = targetDate - now;
// 时间计算逻辑
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
// ...小时/分钟/秒计算
// DOM更新
updateDOM(days, hours, minutes, seconds);
if (diff <= 0) {
// 倒计时结束逻辑
return;
}
// 动态调整下一次执行时间(修正定时器误差)
const nextUpdate = Math.max(1000, diff % 1000);
setTimeout(updateTimer, nextUpdate);
};
updateTimer();
}
- 粒子背景实现(基于Canvas API)
javascript
class ParticleBackground {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.particles = [];
this.resizeCanvas();
this.initParticles(120); // 创建120个粒子
this.animate();
}
initParticles(count) {
for (let i = 0; i < count; i++) {
this.particles.push({
x: Math.random() * this.canvas.width,
y: Math.random() * this.canvas.height,
size: Math.random() * 3 + 1,
speedX: (Math.random() - 0.5) * 0.5,
speedY: (Math.random() - 0.5) * 0.5,
color: this.getRandomColor()
});
}
}
// 爱心形状鼠标交互
handleMouseMove(e) {
// 粒子向鼠标位置聚集形成爱心路径
}
animate() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 更新粒子位置和连线
requestAnimationFrame(() => this.animate());
}
}
- 用户配置持久化
javascript
// 配置数据结构
const DEFAULT_CONFIG = {
anniversaryDate: '2023-01-01',
title: '我们的恋爱时光',
subtitle: '记录每一刻心动',
theme: 'pink-love',
backgroundType: 'particle',
customBackground: '',
font: 'handwriting'
};
// 保存配置到localStorage
function saveConfig(config) {
try {
localStorage.setItem('loveCounterConfig', JSON.stringify(config));
} catch (e) {
console.error('配置保存失败:', e);
}
}
// 加载配置
function loadConfig() {
const saved = localStorage.getItem('loveCounterConfig');
return saved ? JSON.parse(saved) : DEFAULT_CONFIG;
}
四、UI/UX设计规范
-
色彩系统
- 主色调:#FF6B8B(浪漫粉)
- 辅助色:#8A2BE2(梦幻紫)、#FFD700(香槟金)
- 中性色:#F9F9F9(背景)、#333333(文字)
-
响应式布局
- 移动端:单列布局,配置项折叠为底部抽屉
- 平板:双列布局,左侧配置+右侧预览
- 桌面端:三栏布局,增加快捷操作区
-
交互反馈
- 配置变更时实时预览
- 操作成功的微动画提示
- 错误状态的友好提示
五、项目结构
bash
love-counter/
├── index.html # 主页面
├── src/
│ ├── css/
│ │ └── styles.css # Tailwind自定义样式
│ ├── js/
│ │ ├── countdown.js # 计时逻辑
│ │ ├── particle.js # 粒子背景
│ │ ├── config.js # 配置管理
│ │ └── ui.js # UI交互
│ └── assets/
│ └── fonts/ # 自定义字体
├── tailwind.config.js # 主题配置
└── README.md # 项目文档
六、优化与兼容性
-
性能优化
- Canvas动画节流(requestAnimationFrame)
- 图片懒加载与压缩
- 配置项变更防抖处理
-
浏览器兼容
- 支持Chrome/Edge/Safari最新版
- 降级处理IE浏览器(简化动画效果)
-
可访问性
- 语义化HTML结构
- 键盘导航支持
- 颜色对比度符合WCAG标准
源代码
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>恋爱时间倒计时</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
love: {
pink: '#FF6B8B',
purple: '#8A2BE2',
gold: '#FFD700',
light: '#FFF0F3',
dark: '#333333'
}
},
fontFamily: {
handwriting: ['Segoe Script', 'Brush Script MT', 'cursive'],
serif: ['Georgia', 'Cambria', 'serif'],
sans: ['Inter', 'system-ui', 'sans-serif']
},
animation: {
'heartbeat': 'heartbeat 1.5s ease-in-out infinite',
'fade-in': 'fadeIn 0.5s ease-out forwards',
'slide-up': 'slideUp 0.5s ease-out forwards'
},
keyframes: {
heartbeat: {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.2)' }
},
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' }
},
slideUp: {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' }
}
}
}
}
}
</script>
<style type="text/tailwindcss">
@layer utilities {
.content-auto {
content-visibility: auto;
}
.text-shadow {
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.particle-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.config-panel {
transform: translateX(0);
transition: transform 0.3s ease-in-out;
}
.config-panel.hidden {
transform: translateX(100%);
}
@media (max-width: 768px) {
.config-panel {
transform: translateY(100%);
height: 80vh;
border-radius: 1rem 1rem 0 0;
}
.config-panel.hidden {
transform: translateY(100%);
}
.config-panel.active {
transform: translateY(0);
}
}
}
</style>
</head>
<body class="font-sans bg-love-light text-love-dark overflow-x-hidden">
<!-- 粒子背景画布 -->
<canvas id="particleCanvas" class="particle-bg"></canvas>
<!-- 主容器 -->
<div class="relative min-h-screen flex flex-col items-center justify-center p-4 z-10">
<!-- 头部标题区 -->
<header class="text-center mb-8 animate-fade-in">
<h1 id="mainTitle" class="text-[clamp(2rem,5vw,3.5rem)] font-handwriting font-bold text-love-pink text-shadow">
我们的恋爱时光
</h1>
<p id="subTitle" class="text-[clamp(1rem,2vw,1.5rem)] text-gray-600 mt-2">
记录每一刻心动
</p>
</header>
<!-- 倒计时显示区 -->
<div class="w-full max-w-3xl mx-auto bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl p-6 md:p-10 animate-slide-up">
<div class="flex flex-col md:flex-row justify-around items-center gap-6">
<!-- 正计时显示 -->
<div class="text-center">
<div id="loveDays" class="text-[clamp(2.5rem,8vw,5rem)] font-bold text-love-purple">0</div>
<div class="text-gray-500">恋爱天数</div>
</div>
<!-- 分隔符 -->
<div class="hidden md:block h-20 w-px bg-gray-300"></div>
<!-- 倒计时显示 -->
<div class="grid grid-cols-4 gap-2 md:gap-4 w-full md:w-auto">
<div class="text-center">
<div id="countdownDays" class="text-[clamp(1.5rem,5vw,2.5rem)] font-bold text-love-pink">00</div>
<div class="text-xs md:text-sm text-gray-500">天</div>
</div>
<div class="text-center">
<div id="countdownHours" class="text-[clamp(1.5rem,5vw,2.5rem)] font-bold text-love-pink">00</div>
<div class="text-xs md:text-sm text-gray-500">时</div>
</div>
<div class="text-center">
<div id="countdownMinutes" class="text-[clamp(1.5rem,5vw,2.5rem)] font-bold text-love-pink">00</div>
<div class="text-xs md:text-sm text-gray-500">分</div>
</div>
<div class="text-center">
<div id="countdownSeconds" class="text-[clamp(1.5rem,5vw,2.5rem)] font-bold text-love-pink">00</div>
<div class="text-xs md:text-sm text-gray-500">秒</div>
</div>
</div>
</div>
<div class="mt-6 text-center text-gray-500 text-sm">
<span id="nextAnniversary">距离下一个纪念日还有:</span>
<span id="anniversaryName" class="font-medium text-love-purple">恋爱100天</span>
</div>
</div>
<!-- 配置按钮 -->
<button id="configBtn" class="fixed bottom-6 right-6 bg-love-pink text-white rounded-full p-3 shadow-lg z-20 hover:bg-love-purple transition-colors">
<i class="fa fa-cog text-xl"></i>
</button>
</div>
<!-- 配置面板 -->
<div id="configPanel" class="config-panel fixed top-0 right-0 w-full md:w-80 h-full bg-white shadow-2xl z-30 p-6 overflow-y-auto">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold text-love-pink">个性化设置</h2>
<button id="closeConfigBtn" class="text-gray-500 hover:text-gray-700">
<i class="fa fa-times text-xl"></i>
</button>
</div>
<form id="configForm" class="space-y-6">
<!-- 日期设置 -->
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">恋爱开始日期</label>
<input type="date" id="startDate" class="w-full p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-love-pink focus:border-love-pink">
</div>
<!-- 纪念日设置 -->
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">下一个纪念日</label>
<div class="flex gap-2">
<input type="date" id="anniversaryDate" class="flex-1 p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-love-pink focus:border-love-pink">
</div>
<input type="text" id="anniversaryNameInput" placeholder="纪念日名称(如:恋爱100天)" class="w-full p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-love-pink focus:border-love-pink">
</div>
<!-- 标题设置 -->
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">主标题</label>
<input type="text" id="titleInput" placeholder="输入主标题" class="w-full p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-love-pink focus:border-love-pink">
</div>
<!-- 背景设置 -->
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">背景类型</label>
<div class="grid grid-cols-3 gap-2">
<button type="button" data-bg-type="gradient" class="bg-gradient-to-br from-love-pink to-love-purple h-10 rounded-lg border-2 border-transparent focus:border-love-pink"></button>
<button type="button" data-bg-type="particle" class="bg-gray-100 h-10 rounded-lg border-2 border-transparent focus:border-love-pink"></button>
<label class="relative h-10 rounded-lg bg-gray-100 flex items-center justify-center cursor-pointer border-2 border-transparent focus-within:border-love-pink">
<i class="fa fa-upload text-gray-400"></i>
<input type="file" id="bgImageUpload" accept="image/*" class="absolute inset-0 opacity-0 cursor-pointer">
</label>
</div>
</div>
<!-- 主题设置 -->
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">主题颜色</label>
<div class="flex gap-2">
<button type="button" data-theme="pink" class="w-full h-10 bg-love-pink rounded-lg border-2 border-transparent focus:border-black"></button>
<button type="button" data-theme="purple" class="w-full h-10 bg-love-purple rounded-lg border-2 border-transparent focus:border-black"></button>
<button type="button" data-theme="gold" class="w-full h-10 bg-love-gold rounded-lg border-2 border-transparent focus:border-black"></button>
</div>
</div>
<!-- 字体设置 -->
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">字体选择</label>
<select id="fontSelect" class="w-full p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-love-pink focus:border-love-pink">
<option value="sans">无衬线体</option>
<option value="serif">衬线体</option>
<option value="handwriting">手写体</option>
</select>
</div>
<!-- 保存按钮 -->
<button type="submit" class="w-full bg-love-pink hover:bg-love-pink/90 text-white font-medium py-2 px-4 rounded-lg transition-colors">
保存设置
</button>
</form>
</div>
<!-- 移动端配置按钮 -->
<button id="mobileConfigBtn" class="fixed bottom-6 right-6 md:hidden bg-love-pink text-white rounded-full p-3 shadow-lg z-20">
<i class="fa fa-cog text-xl"></i>
</button>
<script src="https://cdn.jsdelivr.net/particles.js/2.0.0/particles.min.js"></script>
<script>
// 全局变量
let config = {
startDate: '',
anniversaryDate: '',
anniversaryName: '恋爱100天',
title: '我们的恋爱时光',
subtitle: '记录每一刻心动',
bgType: 'gradient',
bgImage: '',
theme: 'pink',
font: 'sans'
};
let particleSystem = null;
let countdownInterval = null;
// DOM元素
const elements = {
loveDays: document.getElementById('loveDays'),
countdownDays: document.getElementById('countdownDays'),
countdownHours: document.getElementById('countdownHours'),
countdownMinutes: document.getElementById('countdownMinutes'),
countdownSeconds: document.getElementById('countdownSeconds'),
mainTitle: document.getElementById('mainTitle'),
subTitle: document.getElementById('subTitle'),
anniversaryName: document.getElementById('anniversaryName'),
startDate: document.getElementById('startDate'),
anniversaryDate: document.getElementById('anniversaryDate'),
anniversaryNameInput: document.getElementById('anniversaryNameInput'),
titleInput: document.getElementById('titleInput'),
fontSelect: document.getElementById('fontSelect'),
configPanel: document.getElementById('configPanel'),
configBtn: document.getElementById('configBtn'),
closeConfigBtn: document.getElementById('closeConfigBtn'),
mobileConfigBtn: document.getElementById('mobileConfigBtn'),
configForm: document.getElementById('configForm'),
bgImageUpload: document.getElementById('bgImageUpload'),
particleCanvas: document.getElementById('particleCanvas')
};
// 初始化
function init() {
// 加载配置
loadConfig();
// 设置表单值
updateFormValues();
// 初始化粒子背景
initParticleSystem();
// 更新UI
updateUI();
// 启动倒计时
startCountdown();
// 添加事件监听
addEventListeners();
}
// 加载配置
function loadConfig() {
const savedConfig = localStorage.getItem('loveCounterConfig');
if (savedConfig) {
config = JSON.parse(savedConfig);
} else {
// 默认日期设置为今天
const today = new Date();
const defaultDate = new Date(today.setDate(today.getDate() - 1)).toISOString().split('T')[0];
config.startDate = defaultDate;
// 默认纪念日设置为30天后
const anniversary = new Date(today.setDate(today.getDate() + 30)).toISOString().split('T')[0];
config.anniversaryDate = anniversary;
saveConfig();
}
}
// 保存配置
function saveConfig() {
localStorage.setItem('loveCounterConfig', JSON.stringify(config));
}
// 更新表单值
function updateFormValues() {
elements.startDate.value = config.startDate;
elements.anniversaryDate.value = config.anniversaryDate;
elements.anniversaryNameInput.value = config.anniversaryName;
elements.titleInput.value = config.title;
elements.subTitle.textContent = config.subtitle;
elements.fontSelect.value = config.font;
// 设置选中的主题
document.querySelector(`[data-theme="${config.theme}"]`).classList.add('border-black');
// 设置选中的背景类型
document.querySelector(`[data-bg-type="${config.bgType}"]`).classList.add('border-love-pink');
}
// 初始化粒子系统
function initParticleSystem() {
if (window.particlesJS && config.bgType === 'particle') {
particlesJS('particleCanvas', {
"particles": {
"number": { "value": 80, "density": { "enable": true, "value_area": 800 } },
"color": { "value": "#FF6B8B" },
"shape": { "type": "circle" },
"opacity": { "value": 0.5, "random": true },
"size": { "value": 3, "random": true },
"line_linked": {
"enable": true,
"distance": 150,
"color": "#FF6B8B",
"opacity": 0.2,
"width": 1
},
"move": {
"enable": true,
"speed": 1,
"direction": "none",
"random": true,
"straight": false,
"out_mode": "out",
"bounce": false
}
},
"interactivity": {
"detect_on": "canvas",
"events": {
"onhover": { "enable": true, "mode": "grab" },
"onclick": { "enable": true, "mode": "push" },
"resize": true
},
"modes": {
"grab": { "distance": 140, "line_linked": { "opacity": 0.8 } },
"push": { "particles_nb": 3 }
}
},
"retina_detect": true
});
}
}
// 更新UI
function updateUI() {
// 更新标题
elements.mainTitle.textContent = config.title;
elements.subTitle.textContent = config.subtitle;
elements.anniversaryName.textContent = config.anniversaryName;
// 更新字体
document.body.className = `font-${config.font}`;
// 更新主题颜色
document.documentElement.style.setProperty('--theme-color',
config.theme === 'pink' ? '#FF6B8B' :
config.theme === 'purple' ? '#8A2BE2' : '#FFD700');
// 更新背景
updateBackground();
// 计算并更新恋爱天数
updateLoveDays();
// 计算并更新倒计时
updateCountdown();
}
// 更新背景
function updateBackground() {
const body = document.body;
// 清除之前的背景设置
body.style.backgroundImage = '';
body.className = body.className.replace(/bg-\S+/g, '');
if (config.bgType === 'gradient') {
body.classList.add('bg-gradient-to-br',
config.theme === 'pink' ? 'from-love-pink/20 to-love-purple/20' :
config.theme === 'purple' ? 'from-love-purple/20 to-indigo-500/20' :
'from-love-gold/20 to-yellow-300/20');
} else if (config.bgType === 'particle') {
body.classList.add('bg-gray-100');
if (!particleSystem) {
initParticleSystem();
}
} else if (config.bgType === 'image' && config.bgImage) {
body.style.backgroundImage = `url(${config.bgImage})`;
body.classList.add('bg-cover', 'bg-center');
}
}
// 更新恋爱天数
function updateLoveDays() {
const start = new Date(config.startDate);
const now = new Date();
const diffTime = Math.abs(now - start);
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
elements.loveDays.textContent = diffDays;
}
// 更新倒计时
function updateCountdown() {
const now = new Date();
const anniversary = new Date(config.anniversaryDate);
// 如果纪念日已过,设置为明年
if (anniversary < now) {
anniversary.setFullYear(anniversary.getFullYear() + 1);
}
const diffTime = anniversary - now;
// 计算天、时、分、秒
const days = Math.floor(diffTime / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diffTime % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffTime % (1000 * 60)) / 1000);
// 更新DOM
elements.countdownDays.textContent = days.toString().padStart(2, '0');
elements.countdownHours.textContent = hours.toString().padStart(2, '0');
elements.countdownMinutes.textContent = minutes.toString().padStart(2, '0');
elements.countdownSeconds.textContent = seconds.toString().padStart(2, '0');
}
// 启动倒计时
function startCountdown() {
// 立即更新一次
updateLoveDays();
updateCountdown();
// 清除之前的定时器
if (countdownInterval) {
clearInterval(countdownInterval);
}
// 设置定时器
countdownInterval = setInterval(() => {
updateLoveDays();
updateCountdown();
}, 1000);
}
// 添加事件监听
function addEventListeners() {
// 配置面板切换
elements.configBtn.addEventListener('click', () => {
elements.configPanel.classList.remove('hidden');
elements.configPanel.classList.add('active');
});
elements.closeConfigBtn.addEventListener('click', () => {
elements.configPanel.classList.add('hidden');
elements.configPanel.classList.remove('active');
});
elements.mobileConfigBtn.addEventListener('click', () => {
elements.configPanel.classList.toggle('active');
});
// 背景类型选择
document.querySelectorAll('[data-bg-type]').forEach(btn => {
btn.addEventListener('click', () => {
// 移除所有选中状态
document.querySelectorAll('[data-bg-type]').forEach(b =>
b.classList.remove('border-love-pink'));
// 设置当前选中状态
btn.classList.add('border-love-pink');
config.bgType = btn.dataset.bgType;
updateBackground();
});
});
// 主题选择
document.querySelectorAll('[data-theme]').forEach(btn => {
btn.addEventListener('click', () => {
// 移除所有选中状态
document.querySelectorAll('[data-theme]').forEach(b =>
b.classList.remove('border-black'));
// 设置当前选中状态
btn.classList.add('border-black');
config.theme = btn.dataset.theme;
updateUI();
});
});
// 图片上传
elements.bgImageUpload.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
config.bgImage = event.target.result;
config.bgType = 'image';
// 更新背景类型选中状态
document.querySelectorAll('[data-bg-type]').forEach(b =>
b.classList.remove('border-love-pink'));
document.querySelector(`[data-bg-type="image"]`).classList.add('border-love-pink');
updateBackground();
};
reader.readAsDataURL(file);
}
});
// 表单提交
elements.configForm.addEventListener('submit', (e) => {
e.preventDefault();
// 更新配置
config.startDate = elements.startDate.value;
config.anniversaryDate = elements.anniversaryDate.value;
config.anniversaryName = elements.anniversaryNameInput.value;
config.title = elements.titleInput.value;
config.font = elements.fontSelect.value;
// 保存配置
saveConfig();
// 更新UI
updateUI();
// 关闭配置面板
elements.configPanel.classList.add('hidden');
elements.configPanel.classList.remove('active');
// 显示保存成功提示
alert('设置已保存!');
});
}
// 页面加载完成后初始化
window.addEventListener('DOMContentLoaded', init);
// 窗口大小变化时调整粒子系统
window.addEventListener('resize', () => {
if (particleSystem) {
particleSystem.resize();
}
});
</script>
</body>
</html>