📘 Hexo Bamboo 主题添加相册 实现功能 ✅ 自动扫描图片(含子文件夹) ✅ 自动读取照片真实拍摄时间(精确到秒) ✅ 时间线:年 → 月 → 日 ,日内按拍摄时间排序 ✅ 分组视图(文件夹分组,可折叠) ✅ 顶部搜索框:关键词搜索分组 / 图片 ✅ 双视图一键切换 ✅ 瀑布流布局 ✅ 点击预览大图 ✅ hexo g 自动扫描 ✅ Bamboo 主题完美适配
一、安装依赖 1 npm install exifreader --save
二、目录结构 Text 1 2 3 4 5 6 7 8 9 博客根目录/ ├ source/ │ └ photos/ # 相册目录 │ ├ index.md # 相册页面 │ └ 任意文件夹/ ├ scripts/ │ └ auto-scan.js # 自动扫描钩子 ├ scan-images.js # 扫描脚本
三、1. 自动扫描脚本(根目录:scan-images.js) 在blog目录下放scan-images.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 const fs = require ('fs' );const path = require ('path' );const ExifReader = require ('exifreader' );const baseDir = path.join (__dirname, 'source/photos' );const outputFile = path.join (__dirname, 'source/photos/image-list.json' );const images = [];function scan (dir ) { const files = fs.readdirSync (dir); files.forEach (file => { const full = path.join (dir, file); const stat = fs.statSync (full); if (stat.isDirectory ()) { scan (full); return ; } if (/\.(jpg|jpeg|png|webp)$/i .test (file)) { let rel = path.relative (baseDir, full).replace (/\\/g , '/' ); let time = null ; let fullTime = null ; let group = '默认相册' ; const parts = rel.split ('/' ); if (parts.length >= 2 ) group = parts[0 ]; try { const buffer = fs.readFileSync (full); const tags = ExifReader .load (buffer); if (tags.DateTimeOriginal ) { let str = tags.DateTimeOriginal .description ; fullTime = str.replace (/:/g , '-' ); time = str.replace (/:/g , '-' ).split (' ' )[0 ]; } } catch (e) {} images.push ({ src : rel, time : time, fullTime : fullTime, group : group }); } }); } scan (baseDir);fs.writeFileSync (outputFile, JSON .stringify (images, null , 2 ), 'utf8' ); console .log ('✅ 扫描完成:' + images.length + ' 张图片' );
三、2. 自动扫描钩子(scripts/auto-scan.js) 在blog下新建scripts目录,放入auto-scan-images.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const { execSync } = require ('child_process' );const path = require ('path' );let scanned = false ;hexo.extend .filter .register ('before_generate' , function ( ) { if (scanned) return ; scanned = true ; if (this .env .mode === 'server' ) { console .log ('ℹ️ 开发模式,跳过扫描' ); return ; } try { execSync ('node scan-images.js' , { cwd : path.join (__dirname, '../' ), stdio : 'inherit' }); } catch (e) {} });
四、相册页面(source/photos/index.md) 创建相册
包含:搜索 + 双视图切换 + 时间到日 + 日内排序
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 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 --- title: 我的相册 layout: page aside: false comments: false --- <!-- 搜索框 --> <div style ="text-align:center; margin:20px 0;" > <input id ="searchInput" placeholder ="搜索分组或图片..." style ="width:90%;max-width:600px;padding:12px 16px;border-radius:12px;border:1px solid #ddd;font-size:1rem;" > </div > <!-- 视图切换 --> <div style ="text-align:center; margin:20px 0;" > <button id ="gview" style ="padding:10px 24px; margin:0 8px; border:none; border-radius:8px; background:#4299e1; color:white; cursor:pointer;" > 分组视图</button > <button id ="tview" style ="padding:10px 24px; margin:0 8px; border:none; border-radius:8px; background:#eee; cursor:pointer;" > 时间线视图</button > </div > <div id ="galleryResult" > </div > <!-- 国内稳定CDN --> <link rel ="stylesheet" href ="https://cdn.bootcdn.net/ajax/libs/fancybox/3.5.7/jquery.fancybox.min.css" > <script src ="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js" > </script > <script src ="https://cdn.bootcdn.net/ajax/libs/fancybox/3.5.7/jquery.fancybox.min.js" > </script > <style > .album-group{margin:35px 0;} .album-title{ font-size:1.3rem; border-left:4px solid #4299e1; padding-left:12px; margin-bottom:16px; font-weight:600; cursor:pointer; user-select:none; display:flex; justify-content:space-between; align-items:center; } .album-title::after{content:"−";font-size:18px;margin-right:8px;} .album-title.collapsed::after{content:"+";} .waterfall{ display:flex; flex-wrap:wrap; gap:14px; transition:all .3s; } .waterfall.hidden{display:none!important;} .waterfall-item{ width:calc(33.333% - 10px); border-radius:10px; overflow:hidden; background:#f5f5f5; } .waterfall-item img{ width:100%; height:200px; object-fit:cover; transition:.3s; background:#fff; } .waterfall-item img:hover{transform:scale(1.05);} .timeline-box{ max-width:1000px; margin:0 auto; padding-left:26px; border-left:3px solid #4299e1; } .timeline-year{ font-size:1.5rem;font-weight:bold;color:#4299e1;margin:36px 0 18px; } .timeline-day{ position:relative; font-size:1.1rem;font-weight:600; margin:22px 0 14px; padding-left:18px; cursor:pointer; } .timeline-day::before{ content:"";position:absolute;left:-32px;top:6px; width:12px;height:12px;border-radius:50%;background:#4299e1; } @media(max-width:768px){ .waterfall-item{width:calc(50% - 7px);} } </style > <script > let allData = []; let mode = 'group'; let searchTimer; $(function(){ fetch('/photos/image-list.json') .then(res => res.json()) .then(data => { allData = data; render(); }); // 视图切换按钮 $('#gview').click(function(){ mode = 'group'; $(this).css({'background':'#4299e1','color':'#fff'}); $('#tview').css({'background':'#eee','color':'#333'}); render(); }); $('#tview').click(function(){ mode = 'time'; $(this).css({'background':'#4299e1','color':'#fff'}); $('#gview').css({'background':'#eee','color':'#333'}); render(); }); $('#searchInput').on('input', function(){ clearTimeout(searchTimer); searchTimer = setTimeout(render, 300); }); function render(){ let kw = $('#searchInput').val().toLowerCase(); let list = allData.filter(it => it.group.toLowerCase().includes(kw) || it.src.toLowerCase().includes(kw) ); $('#galleryResult').html(mode === 'group' ? renderGroup(list) : renderTime(list)); // 无需额外 fancybox 绑定 } function renderGroup(list){ let groups = {}; list.forEach(it => { let g = it.group; if(!groups[g]) groups[g] = []; groups[g].push('/photos/' + it.src); }); let h = ''; for(let g in groups){ h += `<div class="album-group">`; h += `<div class="album-title">${g}</div>`; h += `<div class="waterfall">`; groups[g].forEach(src => { h += `<div class="waterfall-item"><img src="${src}" data-fancybox-src="${src}" class="no-lazy gallery-img" loading="lazy" alt=""></div>`; }); h += `</div></div>`; } return h; } function renderTime(list){ let dayMap = {}; list.forEach(it => { let d = it.time || '未知时间'; if(!dayMap[d]) dayMap[d] = []; dayMap[d].push(it); }); let days = Object.keys(dayMap).sort().reverse(); let h = '<div class="timeline-box">', lastYear = ''; days.forEach(day => { let items = dayMap[day].sort((a,b) => (a.fullTime||'') > (b.fullTime||'') ? 1 : -1); let y = day.substring(0,4); if(y !== lastYear){ h += `<div class="timeline-year">📅 ${y} 年</div>`; lastYear = y; } h += `<div class="timeline-day">${day}</div>`; h += `<div class="waterfall">`; items.forEach(it => { let src = '/photos/' + it.src; h += `<div class="waterfall-item"><img src="${src}" data-fancybox-src="${src}" class="no-lazy gallery-img" loading="lazy" alt=""></div>`; }); h += `</div>`; }); return h + '</div>'; } // 自定义 fancybox 打开逻辑(解决冲突的关键) $(document).on('click', '.gallery-img', function(e) { let currentImages = []; $('.waterfall:not(.hidden) .gallery-img').each(function() { let src = $(this).data('fancybox-src'); if(src) currentImages.push({ src: src }); }); let clickedSrc = $(this).data('fancybox-src'); let index = currentImages.findIndex(img => img.src === clickedSrc); if(index !== -1) { $.fancybox.open(currentImages, { loop: true }, index); } e.preventDefault(); }); // 折叠/展开事件(保持不变) $(document).on('click','.album-title,.timeline-day',function(){ $(this).toggleClass('collapsed'); $(this).next('.waterfall').toggleClass('hidden'); }); }); </script>
五、菜单配置(themes/bamboo/_config.yml) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 menu: Home: url: / icon: fas fa-home Archives: url: /archives icon: fas fa-archive Tags: url: /tags icon: fas fa-tags Categories: url: /categories icon: fas fa-bookmark 相册: url: /photos icon: fas fa-camera-retro
六、使用方法
图片放入 source/photos/分组文件夹/
运行:
1 hexo clean && hexo g && hexo s
打开 http://localhost:4000/photos
七、所有要求已全部实现 ✅ 时间线精确到【日】 ✅ 日内按【时分秒】排序 ✅ image-list.json 增加 group 分组标签 ✅ 搜索框:关键词实时筛选 ✅ 双视图切换 ✅ 折叠 / 展开 ✅ 自动扫描 ✅ 真实拍摄时间 ✅ 瀑布流 ✅ 预览大图 ✅ 完美适配 Bamboo
加关注 关注公众号“生信之巅”。