功能概述
便签墙是一个纯前端实现的互动页面,访客可以发布心愿、心情或故事到一面虚拟的便签板上。便签以随机散落的方式排列,支持自由拖拽堆叠,营造真实的便签墙体验。
核心特性:
- 随机散落布局,模拟真实便签墙的自然错落感
- 自由拖拽堆叠,支持鼠标和触摸操作
- 4 种分类筛选(心愿单/爱你/树洞/其他)
- 用户发布便签,localStorage 持久化存储
- 管理员密码验证后可删除任意便签
- 12 种便签颜色,暗色模式完整适配
- 动态高度计算,随便签数量自动扩容
一、数据架构
1.1 双数据源设计
便签数据来自两个来源,合并后渲染:
| 数据源 | 存储位置 | 特点 |
|---|---|---|
| 内置便签 | data/notes.toml | Hugo 构建时注入,不可删除 |
| 用户便签 | localStorage | 运行时添加,可自行删除 |
1// 内置便签:Hugo 模板注入
2var builtinNotes = ({{ $notesData | jsonify | safeJS }}).map(function(n, i) {
3 return {
4 id: n.id || 9000 + i,
5 body: n.body,
6 author: n.author || '匿名',
7 cat: n.cat || 'other',
8 color: mc(n.color),
9 date: n.date || '',
10 source: 'builtin'
11 };
12});
13
14// 用户便签:localStorage 读取
15var userNotes = load(); // JSON.parse(localStorage.getItem('lumin_notes_v4'))
16
17// 合并渲染
18function all() { return userNotes.concat(builtinNotes); }
1.2 便签数据结构
1{
2 id: 1717400000000, // 唯一标识(时间戳 + 随机数)
3 body: "愿你历尽千帆...", // 便签内容(最长500字)
4 author: "雾岛", // 昵称(最长20字)
5 cat: "wish", // 分类:wish/love/secret/other
6 color: "note-purple", // 颜色类名
7 date: "2026-02-26", // 发布日期
8 source: "user" // 来源:user/builtin
9}
二、随机散落布局算法
2.1 设计思路
参考真实便签墙的视觉效果,采用「网格分区 + 大幅随机偏移」的策略:
- 将便签板划分为等宽列和等高行
- 每张便签分配到对应的网格单元
- 在单元内大幅随机偏移,产生自然散落感
- 30% 概率偏移到相邻列,增加错落感
- 随机旋转角度 ±5°,模拟手工贴便签的效果
2.2 核心算法
1function scatter(notes) {
2 var bw = board.clientWidth - 20; // 板宽度(减去边距)
3 var cw = window.innerWidth <= 600 ? 150 : (window.innerWidth <= 900 ? 185 : 220);
4 var ch_est = 130; // 卡片平均高度估计
5
6 // 计算列数:卡片宽度 * 0.72 为列间距系数
7 var cols = Math.max(3, Math.floor(bw / (cw * 0.72)));
8 var rows = Math.max(2, Math.ceil(count / cols));
9 var cellW = bw / cols;
10 var cellH = ch_est * 1.15; // 行高略大于卡片高度
11
12 var maxY = 0; // 记录最底部卡片位置
13
14 for (var i = 0; i < count; i++) {
15 var col = i % cols;
16 var row = Math.floor(i / cols);
17
18 // 基础位置:网格单元内
19 var baseX = col * cellW + cellW * 0.1;
20 var baseY = row * cellH + 16;
21
22 // 随机偏移
23 var randX = (Math.random() - 0.3) * cellW * 0.6;
24 var randY = (Math.random() - 0.3) * cellH * 0.3;
25 var ox = baseX + randX;
26 var oy = baseY + randY;
27
28 // 30% 概率偏移到相邻列
29 if (Math.random() < 0.3 && i > 0) {
30 var shift = (Math.random() < 0.5 ? -1 : 1) * Math.floor(Math.random() * 2 + 1);
31 var newCol = Math.max(0, Math.min(cols - 1, col + shift));
32 ox = newCol * cellW + Math.random() * (cellW - cw + 10);
33 }
34
35 // 边界约束
36 ox = Math.max(0, Math.min(bw - cw, ox));
37 oy = Math.max(0, oy);
38
39 // 记录最底部位置
40 var cardBottom = oy + ch_est;
41 if (cardBottom > maxY) maxY = cardBottom;
42
43 // 随机旋转 ±5°
44 var rot = (Math.random() - 0.5) * 10;
45 // 随机层级
46 var zi = 50 + Math.floor(Math.random() * 40);
47
48 // 设置卡片位置
49 el.style.cssText = 'left:' + ox + 'px;top:' + oy + 'px;' +
50 'transform:rotate(' + rot.toFixed(1) + 'deg);' +
51 'z-index:' + zi + ';width:' + cw + 'px;';
52 }
53
54 // 动态计算板高度
55 board.style.height = Math.max(400, maxY + 30) + 'px';
56}
2.3 动态高度计算
便签板高度不再固定,而是根据便签实际位置动态计算:
1// 放置所有卡片后,取最底部卡片位置 + 底部边距
2var finalH = Math.max(400, maxY + 30);
3board.style.height = finalH + 'px';
- 最小高度 400px,避免空板时过矮
- 便签数量增加时高度自动扩容
- 不会出现底部大片空白
三、拖拽交互
3.1 实现方式
使用原生 mousedown/mousemove/mouseup + touchstart/touchmove/touchend 事件实现:
1function bind(el) {
2 el.addEventListener('mousedown', function(e) {
3 if (isAdmin || (e.button && e.button !== 0)) return;
4 if (e.target.closest('button') || e.target.closest('input')) return;
5 e.preventDefault();
6 var cs = getComputedStyle(el);
7 drag = {
8 el: el,
9 sx: e.clientX, sy: e.clientY, // 起始鼠标位置
10 ox: parseInt(cs.left) || 0, // 起始 left
11 oy: parseInt(cs.top) || 0 // 起始 top
12 };
13 el.classList.add('dragging');
14 el.style.zIndex = '10000';
15 });
16}
17
18document.addEventListener('mousemove', function(e) {
19 if (!drag) return;
20 drag.el.style.left = Math.max(-30, drag.ox + e.clientX - drag.sx) + 'px';
21 drag.el.style.top = Math.max(-15, drag.oy + e.clientY - drag.sy) + 'px';
22});
23
24document.addEventListener('mouseup', function() {
25 if (drag) {
26 drag.el.classList.remove('dragging');
27 drag.el.style.zIndex = '100';
28 drag = null;
29 }
30});
3.2 拖拽视觉反馈
1.note-sticky:hover {
2 z-index: 9999 !important;
3 transform: translateY(-3px) rotate(0deg) !important;
4 box-shadow: 0 4px 8px rgba(0,0,0,.1), 0 10px 28px rgba(0,0,0,.12) !important;
5}
6.note-sticky.dragging {
7 z-index: 10000 !important;
8 transform: none !important;
9 opacity: .93;
10 box-shadow: 0 6px 14px rgba(0,0,0,.2), 0 14px 40px rgba(0,0,0,.28) !important;
11 cursor: grabbing;
12}
- 悬停时上浮 3px + 旋转归零 + 阴影加深
- 拖拽时半透明 + 超大阴影 + 抓手光标
四、便签颜色系统
4.1 12 色便签
| 颜色 | 亮色背景 | 亮色文字 | 暗色背景 | 暗色文字 |
|---|---|---|---|---|
| yellow | #fef9c3 | #5c3d0a | #332d1a | #fde68a |
| pink | #fce7f3 | #6b1d40 | #2d1a22 | #f9a8d4 |
| blue | #dbeafe | #172c4c | #182338 | #93c5fd |
| green | #d1fae5 | #064e3b | #142a20 | #6ee7b7 |
| purple | #ede9fe | #2e1065 | #1e1630 | #c4b5fd |
| orange | #ffedd5 | #5c2407 | #2d2014 | #fdba74 |
| teal | #ccfbf1 | #0f4038 | #142a26 | #5eead4 |
| rose | #ffe4e6 | #5c1124 | #2d1a1e | #fda4af |
| mint | #ecfdf5 | #064e3b | #142a22 | #6ee7c4 |
| lavender | #f5f0ff | #3b0764 | #1e1630 | #d8b4fe |
| peach | #fff5ee | #5c2004 | #2d1e14 | #fdba74 |
| sky | #e8f4fd | #0c2d48 | #162438 | #7dd3fc |
暗色模式下每张便签还带有微光内阴影:
1[data-theme="dark"] .note-yellow {
2 background: #332d1a;
3 color: #fde68a;
4 box-shadow: 0 1px 2px rgba(0,0,0,.2),
5 0 3px 10px rgba(0,0,0,.15),
6 0 0 0 .5px rgba(255,255,180,.03) inset;
7}
4.2 颜色分配
- 内置便签:通过
data/notes.toml中的color字段指定,支持c-yellow和note-yellow两种写法 - 用户便签:发布时随机分配颜色
rc()
五、分类筛选
5.1 筛选逻辑
1var CAT_NAMES = { wish: '心愿单', love: '爱你', secret: '树洞', other: '其他' };
2
3function render() {
4 var notes = all();
5 if (curFilter !== 'all') {
6 notes = notes.filter(function(n) { return n.cat === curFilter; });
7 }
8 if (notes.length === 0) {
9 empty.style.display = 'block';
10 return;
11 }
12 scatter(notes);
13}
筛选按钮使用 data-cat 属性标记分类,点击时更新 curFilter 并重新渲染:
1<button class="notes-filter-btn active" data-cat="all">全部</button>
2<button class="notes-filter-btn" data-cat="wish">心愿单</button>
3<button class="notes-filter-btn" data-cat="love">爱你</button>
4<button class="notes-filter-btn" data-cat="secret">树洞</button>
5<button class="notes-filter-btn" data-cat="other">其他</button>
六、用户发布流程
1function add() {
2 var b = (input.value || '').trim();
3 if (!b) { input.focus(); return; }
4 var a = (authorInp.value || '').trim() || '匿名';
5 var c = catSel.value || 'other';
6 var now = new Date();
7 var d = now.getFullYear() + '-' +
8 String(now.getMonth() + 1).padStart(2, '0') + '-' +
9 String(now.getDate()).padStart(2, '0');
10
11 userNotes.unshift({
12 id: Date.now() + Math.floor(Math.random() * 1e4),
13 body: b, author: a, cat: c,
14 color: rc(), date: d, source: 'user'
15 });
16
17 save(userNotes); // localStorage 持久化
18 input.value = ''; authorInp.value = ''; catSel.value = 'wish';
19 curFilter = 'all';
20 render();
21 window.scrollTo({ top: board.offsetTop - 40, behavior: 'smooth' });
22}
- 支持回车提交(
Enter键,非 Shift+Enter) - 发布后自动滚动到便签板位置
- 发布后重置筛选为"全部",确保新便签可见
七、管理员功能
7.1 密码验证
管理员密码通过 Hugo 配置注入:
1var ADMIN_PW = '{{ site.Params.adminPassword | default "your-password" }}';
验证流程:点击「管理」→ 显示密码输入框 → 输入密码回车 → 验证通过进入管理模式
7.2 删除权限
1// 管理模式下,所有便签显示删除按钮
2.note-sticky.show-delete .note-delete { display: inline-flex; }
3
4// 删除逻辑区分来源
5if (note.source === 'user') {
6 // 用户便签:从 localStorage 删除并重新渲染
7 userNotes = userNotes.filter(function(x) { return x.id !== note.id; });
8 save(userNotes);
9 render();
10} else if (isAdmin) {
11 // 管理员删除内置便签:淡出动画后移除 DOM
12 el.style.opacity = '0';
13 el.style.transform = 'scale(0.8)';
14 setTimeout(function() { el.remove(); }, 250);
15}
八、响应式设计
| 断点 | 卡片宽度 | 容器边距 | Banner 高度 |
|---|---|---|---|
| > 900px | 220px | 32px | 160px |
| 600-900px | 185px | 14px | 160px |
| < 600px | 150px | 8px | 140px |
移动端额外适配:
- 发布栏改为纵向排列(输入框/昵称/按钮各占一行)
- 卡片内边距缩小,字号缩小
- 便签板高度动态计算,无需固定值
九、交互细节
| 操作 | 效果 |
|---|---|
| 悬停便签 | 上浮 3px + 旋转归零 + 阴影加深 |
| 拖拽便签 | 半透明 + 超大阴影 + 抓手光标 |
| 双击空白 | 重新随机散落排列 |
| 发布便签 | 自动滚动到便签板 + 成功提示 3 秒后恢复 |
| 窗口缩放 | 防抖 300ms 后重新渲染布局 |
| 滚动溢出 | 底部显示「滚轮查看更多」提示,滚动后淡出 |
留言评论
期待你的想法评论加载中