在 hexo 博客中添加节假日提醒、祝福弹窗 — butterfly 主题
最近为博客添加了节假日弹窗提醒的功能,简单做个原理总结和搭建教程。
一、部署原理
此功能使用“本地计算 + 静态拉取”的方法,借助开源的天文历法推算库 lunar-javascript 来精确计算每年动态的节气、农历节日和法定节假日,并编码为json文件,这样既方便维护和自定义,又保持功能轻量化和极快的相应速度。整体原理如下面两个流程图所示:
graph TD
subgraph s1["阶段一:构建时 (Build Time)"]
A["本地环境 / CI 环境"] -->|"执行 node 脚本"| B("generate-holidays.js")
B -->|调用| C["lunar-javascript 历法库"]
C -->|"计算公历/农历/节气/法定节假日"| D{"数据清洗与规范化"}
D -->|"去重 & 节日过滤 & 注入文案"| E[("生成 holidays.json")]
E -.->|"部署至服务器"| F["博客静态资源目录"]
end
classDef s1 fill:#f9f0ff,stroke:#d8b4e2,stroke-width:2px;
class A,B,C,D,E s1;
graph TD
subgraph s2["阶段二:运行时 (Runtime)"]
G["用户访问博客"] --> H["前端 holiday-popup.js 运行"]
H --> I{"检查 LocalStorage<br>今日是否已弹窗?"}
I -->|是| J["终止执行,无感浏览"]
I -->|否| K["发起 Fetch 请求拉取 holidays.json"]
K --> L{"匹配今日日期 MM-DD"}
L -->|"无节日"| J
L -->|"有节日"| M["解析节日名称、随机祝福语、Emoji"]
M --> N["动态创建 DOM 注入页面"]
N --> O["挂载隔离的 CSS 样式"]
O --> P["用户点击关闭按钮"]
P --> Q["销毁 DOM & 样式"]
Q --> R["写入 LocalStorage 标记已读"]
end
classDef s2 fill:#f0f9ff,stroke:#b4d8e2,stroke-width:2px;
class G,H,I,J,K,L,M,N,O,P,Q,R s2;
- 数据生成
计算每一天对应的公历节日、农历节日、二十四节气及法定节假日,并过滤特定节日,对重复节日(如“清明”节气与“清明节”法定假日)进行去重,导出holidays.json文件。 - 前端异步解耦
弹窗逻辑使用 IIFE 并在<body>底部异步加载,脚本运行时直接异步拉取holidays.json,在内存中进行哈希匹配。这种方式的网络开销极小,比 API 更快。 - 状态持久化与防打扰
当用户关闭弹窗时,将当前日期作为 Key 写入 LocalStorage。脚本在初始化时首先校验该 Key 是否存在,实现“节日期间每天仅首次访问时弹出一次”的逻辑,避免影响用户体验。 - 样式隔离
弹窗样式由 JS 动态创建<style>标签挂载,并在弹窗关闭时连同 DOM 一并销毁。
二、部署步骤
安装 lunar-javascript 插件:1
npm install lunar-javascript
在博客根目录创建数据生成脚本 generate-holidays.js:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120const fs = require('fs');
const path = require('path');
const { Solar, HolidayUtil } = require('lunar-javascript');
const holidayMessages = {
// 自定义纪念日
'04-24': { name: '站长生日', msgs: ['祝站长 LuoTian 生日快乐!', '岁岁常欢愉,年年皆胜意。站长破壳日快乐!'] },
'01-12': { name: '博客生日', msgs: ['本博客又长大了一岁!', '代码与时光同在,见证每一次提交与成长。'] },
// 法定/传统/国际节日
'春节': { msgs: ['新春大吉,万事如意!', '辞旧迎新,愿新年胜旧年。'] },
'元旦': { msgs: ['一元复始,万象更新。', '新年好,愿新的一年闪闪发光。'] },
'清明节': { msgs: ['慎终追远,缅怀先辈。', '燕子来时新社,梨花落后清明。'] },
'劳动节': { msgs: ['致敬每一位努力奋斗的劳动者。', '劳动最光荣!'] },
'端午节': { msgs: ['端午安康,百病不侵。', '粽叶飘香,又是一年端阳。'] },
'中秋节': { msgs: ['海上生明月,天涯共此时。', '中秋佳节,阖家团圆。'] },
'国庆节': { msgs: ['祝福祖国母亲生日快乐!', '繁荣昌盛,国泰民安。'] },
'七夕节': { msgs: ['星河璀璨,七夕佳节。', '愿得一人心,白首不相离。'] },
'感恩节': { msgs: ['心怀感恩,皆是美好。', '感谢生命中所有的相遇与陪伴。'] },
'妇女节': { msgs: ['祝所有女性力量熠熠生辉。', '节日快乐,做最闪亮的自己。'] },
'青年节': { msgs: ['青春逢盛世,奋斗正当时。', '五四精神,薪火相传。'] },
'儿童节': { msgs: ['永葆童心,快乐无界。', '愿你历尽千帆,归来仍是少年。'] },
'建党节': { msgs: ['不忘初心,牢记使命。', '星火燎原,辉煌历程。'] },
'建军节': { msgs: ['致敬最可爱的人!', '岁月静好,因有人负重前行。'] },
'教师节': { msgs: ['桃李满天下,春晖遍四方。', '师恩难忘,节日快乐。'] },
// 二十四节气
'立春': { msgs: ['今日立春,万物生机萌发。', '东风解冻,春日初临。'] },
'雨水': { msgs: ['今日雨水,润物细无声。', '好雨知时节,当春乃发生。'] },
'惊蛰': { msgs: ['今日惊蛰,春雷乍动,万物生机盎然。'] },
'春分': { msgs: ['今日春分,昼夜均而寒暑平。', '草长莺飞,春意正浓。'] },
'谷雨': { msgs: ['今日谷雨,雨生百谷,春将尽。', '落花游丝白日静,鸣鸠春半雨建瓴。'] },
'立夏': { msgs: ['今日立夏,万物至此皆长大。', '南风草木香,初夏悠悠长。'] },
'小满': { msgs: ['今日小满,物至于此小得盈满。', '人生最好是小满。'] },
'芒种': { msgs: ['今日芒种,连收带种,辛勤耕耘。'] },
'夏至': { msgs: ['今日夏至,白昼最长,骄阳似火。'] },
'小暑': { msgs: ['今日小暑,倏忽温风至,因循小暑来。'] },
'大暑': { msgs: ['今日大暑,万物荣华,烈日炎炎。'] },
'立秋': { msgs: ['今日立秋,云天收夏色,木叶动秋声。'] },
'处暑': { msgs: ['今日处暑,暑气至此而止矣。'] },
'白露': { msgs: ['今日白露,露从今夜白,月是故乡明。'] },
'秋分': { msgs: ['今日秋分,秋意浓,昼夜平。'] },
'寒露': { msgs: ['今日寒露,袅袅凉风动,凄凄寒露零。'] },
'霜降': { msgs: ['今日霜降,气肃而凝,露结为霜矣。'] },
'立冬': { msgs: ['今日立冬,秋去冬来,万物收藏。'] },
'小雪': { msgs: ['今日小雪,晚来天欲雪,能饮一杯无?'] },
'大雪': { msgs: ['今日大雪,至此而雪盛也。'] },
'冬至': { msgs: ['今日冬至,阴极之至,阳气始生。'] },
'小寒': { msgs: ['今日小寒,冷气积久而寒。'] },
'大寒': { msgs: ['今日大寒,岁末清寒,春之将至。'] }
};
const blackList = [
'消费者权益日', '龙头节', '全国中小学生安全教育日', '愚人节',
'全国助残日', '全民国防教育日', '世界住房日',
'情人节', '万圣节', '圣诞节', '平安夜', '复活节'
];
const normalizeName = (name) => {
if (name === '元旦节') return '元旦';
if (name === '清明') return '清明节'; // 将节气清明与节日合并
return name;
};
const year = new Date().getFullYear();
const holidaysData = {};
for (let month = 1; month <= 12; month++) {
const daysInMonth = new Date(year, month, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const solar = Solar.fromYmd(year, month, day);
const lunar = solar.getLunar();
const dateStrMMDD = `${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
let dailyEvents = [];
let festivalsSet = new Set();
let baseFestivals = [
...solar.getFestivals(),
...lunar.getFestivals(),
lunar.getJieQi()
].filter(Boolean);
baseFestivals.forEach(f => festivalsSet.add(normalizeName(f)));
const statutory = HolidayUtil.getHoliday(year, month, day);
if (statutory && !statutory.isWork()) {
festivalsSet.add(normalizeName(statutory.getName()));
}
// 过滤黑名单
let filteredFestivals = Array.from(festivalsSet).filter(f => !blackList.some(b => f.includes(b)));
// 组装最终 JSON 数据
filteredFestivals.forEach(festName => {
dailyEvents.push({
name: festName,
msgs: holidayMessages[festName]?.msgs || [`今日${festName},愿你度过美好的一天。`]
});
});
// 插入自定义纪念日
if (holidayMessages[dateStrMMDD]) {
const customExists = dailyEvents.some(e => e.name === holidayMessages[dateStrMMDD].name);
if (!customExists) {
dailyEvents.push({
name: holidayMessages[dateStrMMDD].name,
msgs: holidayMessages[dateStrMMDD].msgs
});
}
}
if (dailyEvents.length > 0) {
holidaysData[dateStrMMDD] = dailyEvents;
}
}
}
const outputPath = path.join(__dirname, 'source', 'holidays.json');
fs.writeFileSync(outputPath, JSON.stringify(holidaysData, null, 2), 'utf-8');
console.log(`成功生成 ${year} 年度节日数据`);
在根目录执行:1
node generate-holidays.js
该命令将自动创建source/holidays.json数据文件。可以在holidayMessages变量中进一步自定义弹窗提醒的节日及提示语。也可以在blackList变量中添加自定义节日屏蔽规则。
在 source/js/ 目录创建前端脚本 holiday-popup.js:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140(function () {
const initHolidayPopup = async () => {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const day = now.getDate();
const todayStr = `${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const todayFullStr = `${year}-${todayStr}`;
const displayDate = `今天是 ${year}年${month}月${day}日`;
const storageKey = `holiday_popup_seen_${todayFullStr}`;
if (localStorage.getItem(storageKey)) return;
try {
const response = await fetch('/holidays.json');
if (!response.ok) return;
const holidaysData = await response.json();
const todaysHolidays = holidaysData[todayStr];
if (!todaysHolidays || todaysHolidays.length === 0) return;
renderPopup(todaysHolidays, displayDate, storageKey);
} catch (error) {
console.error('节假日数据加载失败:', error);
}
};
const getEmoji = (name) => {
const emojiMap = {
'春节': '🏮', '元旦': '🎉', '清明节': '🌿', '劳动节': '🛠️',
'端午节': '🐉', '中秋节': '🥮', '国庆节': '🇨🇳',
'站长生日': '🎂', '博客生日': '💻',
'七夕节': '💖', '感恩节': '🦃', '妇女节': '🌹',
'青年节': '🔥', '儿童节': '🎈', '建党节': '🚩',
'建军节': '🎖️', '教师节': '💐',
'立春': '🌱', '雨水': '💧', '惊蛰': '⚡', '春分': '🌸',
'谷雨': '🌧️', '立夏': '🍉', '小满': '🌾', '芒种': '🌻',
'夏至': '☀️', '小暑': '🍧', '大暑': '🔥', '立秋': '🍂',
'处暑': '🍁', '白露': '🍵', '秋分': '🌾', '寒露': '🍁',
'霜降': '❄️', '立冬': '⛄', '小雪': '🌨️', '大雪': '❄️',
'冬至': '🥟', '小寒': '🧣', '大寒': '🧤'
};
return emojiMap[name] || '✨';
};
const renderPopup = (holidays, displayDate, storageKey) => {
if (document.getElementById('holiday-popup-wrapper')) return;
const style = document.createElement('style');
style.id = 'holiday-popup-style';
style.textContent = `
#holiday-popup-wrapper {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 999999;
display: flex; justify-content: center; align-items: center;
}
#holiday-popup-box {
position: relative; width: 85%; max-width: 380px;
padding: 30px 25px; border-radius: 10px; text-align: center;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
border: 1px solid rgba(255, 255, 255, 0.85);
box-shadow: 0 8px 16px -4px rgba(138, 138, 138, 0.15);
display: flex; flex-direction: column; align-items: center;
transition: background 0.3s ease, border 0.3s ease, box-shadow 0.3s ease;
}
html[data-theme="dark"] #holiday-popup-box {
background: rgba(30, 30, 30, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 8px 16px -4px rgba(0, 0, 0, 0.2);
}
#holiday-popup-date {
font-size: 14px; margin-bottom: 22px; font-weight: 500;
color: var(--font-color); opacity: 0.85;
}
.holiday-item { margin-bottom: 22px; }
.holiday-item:last-of-type { margin-bottom: 10px; }
.holiday-title {
font-size: 20px; font-weight: bold; margin: 0 0 12px 0;
color: #49b1f5; display: flex; align-items: center; justify-content: center; gap: 8px;
}
.holiday-msg {
font-size: 15px; line-height: 1.6; margin: 0;
color: var(--font-color);
}
#holiday-popup-close-btn {
margin-top: 20px; padding: 8px 32px;
font-size: 14px; cursor: pointer; border-radius: 5px;
border: 1px solid #49b1f5; background: transparent; color: #49b1f5;
transition: all 0.3s;
}
#holiday-popup-close-btn:hover { background: #49b1f5; color: #fff; }
`;
document.head.appendChild(style);
const itemsHtml = holidays.map(holiday => {
const randomMsg = holiday.msgs[Math.floor(Math.random() * holiday.msgs.length)];
const emoji = getEmoji(holiday.name);
return `
<div class="holiday-item">
<h3 class="holiday-title"><span>${emoji}</span> ${holiday.name}</h3>
<p class="holiday-msg">${randomMsg}</p>
</div>
`;
}).join('');
const isBirthday = holidays.some(holiday =>
holiday.name === '站长生日' || holiday.name === '博客生日'
);
const closeBtnText = isBirthday ? '生日快乐!' : '关闭';
const wrapper = document.createElement('div');
wrapper.id = 'holiday-popup-wrapper';
wrapper.innerHTML = `
<div id="holiday-popup-box">
<div id="holiday-popup-date">${displayDate}</div>
${itemsHtml}
<button id="holiday-popup-close-btn">${closeBtnText}</button>
</div>
`;
document.body.appendChild(wrapper);
document.getElementById('holiday-popup-close-btn').addEventListener('click', () => {
wrapper.remove();
style.remove();
localStorage.setItem(storageKey, 'true');
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initHolidayPopup);
} else {
initHolidayPopup();
}
document.addEventListener('pjax:complete', initHolidayPopup);
})();
可以在emojiMap变量中自定义节日 emoji 或文字装扮。
在主题配置文件中进行注入:1
2
3inject:
bottom:
- <script async src="/js/holiday-popup.js" data-pjax></script>
重新构建并刷新浏览器缓存,博客便会在节日当日第一次访问时出现节日弹窗。
为了方便调试或演示弹窗效果,可以使用浏览器的无痕浏览模式,因为无痕浏览不会存储本地数据,因此每次进入博客均会出现节日弹窗。
三、注意事项
数据的时效性维护
此方案需在年初手动刷新节日数据npm update lunar-javascript,然后重新执行node generate-holidays.js,以生成最新一年的假期数据。JSON 文件的路径问题
Node 脚本默认将 JSON 生成在source/holidays.json。Hexo 在部署时会将其直接复制到public/根目录。前端 fetch 路径为fetch('/holidays.json')。如果博客不是部署在域名根目录,则需将 fetch 路径修改为相对路径或补全二级目录。PJAX 兼容性
此脚本兼容 PJAX,不会因刷新或跳转导致脚本二次触发。





