前言
上半年事比较多,好久不更新啦。一直想把小张列传单独分离一个博客,前段时间用Typecho搭了一个博客,Ty主题很多但是没有一个主题可以把我想要的所有功能放到一起,所以继承了Hexo衣钵,没有就魔改!想加一个相册功能,不需要额外管理(不想每传一张图再写一张图片地址到某个地方,主打一个懒)为了实现动态显示,大多人图床依托存储桶或者兰空,所以从这个角度出发,直接调用他们的接口动态获取图片集合。印象里莱昂纳斯和林木木做过相关瀑布流相册,所以借鉴了他们。
参考:
Ty效果:相册 - zhsher
butterfly效果

方案一
方案一仅做思路记录,密钥会暴露在前端,不建议使用
腾讯云
免部署,使用快捷,但密钥暴露在前端,切记遵循 最小权限指引原则 对永久密钥的权限范围进行限制
首先参照对象存储 快速入门-SDK 文档中准备环境,如下
登录 对象存储控制台 ,获取存储桶名称和地域名称
遵循最小权限原则,使用子用户的 SecretId 和 SecretKey。登录 控制台-新建用户 ,选择快速创建,依次添加用户名,编程访问,QcloudCOSReadOnlyAccess(只读权限)

配置 CORS 规则,AllowHeader 需配成*或自己域名,操作为 GET,其它默认

以Hexo为例,新建页面hexo new page photos,插入如下代码,填写42~64行配置
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title></title> <script src="https://npm.elemecdn.com/cos-js-sdk-v5/dist/cos-js-sdk-v5.min.js"></script> <script src="//cdnjs.cloudflare.com/ajax/libs/waterfall.js/1.0.2/waterfall.min.js"></script> <script src="https://npm.elemecdn.com/imgstatus/imgStatus.min.js"></script> <script src="https://jsd.onmicrosoft.cn/gh/Tokinx/lately/lately.min.js"></script> <script src="https://jsd.onmicrosoft.cn/gh/Tokinx/ViewImage/view-image.min.js"></script> <style> .gallery-photos a img {margin: 0; border-radius:0;bottom: 0px;} .gallery-photos{width:100%;margin-top: 10px;} .gallery-photo{min-height:5rem;width:24.97%;padding:4px;position: relative;} .gallery-photo a{border-radius:8px;display:block;overflow: hidden;} .gallery-photo img{display: block;width:100%;animation: fadeIn 1s;cursor: pointer;transition: all .4s ease-in-out !important;} .gallery-photo span.photo-title,.gallery-photo span.photo-time{max-width: calc(100% - 7px);line-height:1.8;position:absolute;left:4px;font-size:14px;background: rgba(0, 0, 0, 0.3);padding:0px 8px;color: #fff;animation: fadeIn 1s;} .gallery-photo span.photo-title{bottom:4px;border-radius: 0 8px 0 8px;} .gallery-photo span.photo-time{top:4px;border-radius: 8px 0 8px 0;} .gallery-photo:hover img{transform: scale(1.1);} @media screen and (max-width: 1100px) {.gallery-photo{width:33.3%;}} @media screen and (max-width: 768px) { .gallery-photo{width:49.9%;padding:3px} .gallery-photo span.photo-time{display:none} .gallery-photo span.photo-title{font-size:12px} .gallery-photo span.photo-title{left:3px;bottom:3px;} } @keyframes fadeIn{0% {opacity: 0;}100%{opacity: 1;}} </style> </head> <body> <div class="gallery-photos page"></div>
<script type="text/javascript"> window.onresize = () => { if (location.pathname == '/photos/') waterfall('.gallery-photos'); }; domain = 'imgl.zhsher.cn' var cos = new COS({ SecretId: 'AKIDTmpcATXaOzLRP7ankRs4VkjUzMUN1PYS', SecretKey: 'iY9VbVO4WpgRwCg4KOI4cz87nJDwWs1z', }); exclude = [] cos.getBucket({ Bucket: 'zh-13104467', Region: 'ap-nanjing', Prefix: '', }).then(data => { const photo_list = data.Contents .filter(item => /\.(jpg|png|webp)$/.test(item.Key) && !exclude.includes(item.Key)) .map(item => ({ img: `https://${domain}/` + item.Key, title: item.Key.match(/([^/]+)\.\w+$/)[1], time: item.LastModified.replace(/[TZ]/g, ' ').slice(0, -5) })); photo_list.sort(function(a, b) { var timeA = new Date(a.time); var timeB = new Date(b.time); return timeB - timeA; }); let html = ''; photo_list.forEach(item => { html += `<div class="gallery-photo"><a href="${item.img}" data-fancybox="gallery" class="fancybox" data-thumb="${item.img}"><img class="photo-img" loading='lazy' decoding="async" src="${item.img}"></a>`; item.title ? html += `<span class="photo-title">${item.title}</span>` : ''; item.time ? html += `<span class="photo-time">${item.time}</span>` : ''; html += `</div>`; }); document.querySelector('.gallery-photos.page').innerHTML = html;
imgStatus.watch('.photo-img', () => { waterfall('.gallery-photos'); });
window.Lately && Lately.init({ target: '.photo-time' });
window.ViewImage && ViewImage.init('.gallery-photo img'); }); </script> </body> </html>
|
Lsky图床
免部署,使用快捷,但密钥暴露在前端,Lsky无法权限限制,不要使用!!!
申请token,开源版需要使用命令行。企业版在前端面板中申请
curl --location --request POST 'http://域名/api/v1/tokens' --form 'email="邮箱"' --form 'password="密码"'
|
以Hexo为例,新建页面hexo new page photos,插入如下代码,填写40~54行配置
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title></title> <script src="//cdnjs.cloudflare.com/ajax/libs/waterfall.js/1.0.2/waterfall.min.js"></script> <script src="https://npm.elemecdn.com/imgstatus/imgStatus.min.js"></script> <script src="https://jsd.onmicrosoft.cn/gh/Tokinx/lately/lately.min.js"></script> <script src="https://jsd.onmicrosoft.cn/gh/Tokinx/ViewImage/view-image.min.js"></script> <style> .gallery-photos a img {margin: 0; border-radius:0;bottom: 0px;} .gallery-photos{width:100%;margin-top: 10px;} .gallery-photo{min-height:5rem;width:24.97%;padding:4px;position: relative;} .gallery-photo a{border-radius:8px;display:block;overflow: hidden;} .gallery-photo img{display: block;width:100%;animation: fadeIn 1s;cursor: pointer;transition: all .4s ease-in-out !important;} .gallery-photo span.photo-title,.gallery-photo span.photo-time{max-width: calc(100% - 7px);line-height:1.8;position:absolute;left:4px;font-size:14px;background: rgba(0, 0, 0, 0.3);padding:0px 8px;color: #fff;animation: fadeIn 1s;} .gallery-photo span.photo-title{bottom:4px;border-radius: 0 8px 0 8px;} .gallery-photo span.photo-time{top:4px;border-radius: 8px 0 8px 0;} .gallery-photo:hover img{transform: scale(1.1);} @media screen and (max-width: 1100px) {.gallery-photo{width:33.3%;}} @media screen and (max-width: 768px) { .gallery-photo{width:49.9%;padding:3px} .gallery-photo span.photo-time{display:none} .gallery-photo span.photo-title{font-size:12px} .gallery-photo span.photo-title{left:3px;bottom:3px;} } @keyframes fadeIn{0% {opacity: 0;}100%{opacity: 1;}} </style> </head> <body> <div class="gallery-photos page"></div>
<script type="text/javascript"> window.onresize = () => { if (location.pathname == '/photos/') waterfall('.gallery-photos'); };
domain = 'test.zhsher.cn' var myHeaders = new Headers(); myHeaders.append("Authorization", "Bearer 1|zsNyAN4hPx0xWONcq8Zu01UpynZWQcTgBg26R6iP"); myHeaders.append("Accept", "application/json"); exclude = ['2023/06/04/web开发07.webp'] var requestOptions = { method: 'GET', headers: myHeaders, redirect: 'follow', };
fetch(`http://${domain}/api/v1/images`, requestOptions) .then(response => response.json()) .then(result => { page_num = result.data.last_page; getPhoto(page_num) }) .catch(error => console.log('error', error));
function getPhoto(page_num) { const urls = []; for (var i = 1; i <= page_num; i++) { urls.push(`http://${domain}/api/v1/images?page=${i}`) }
const data = {}; const photo_list = []; Promise.all(urls.map(url => fetch(url, requestOptions) .then(response => response.json()) .then(result => { data[result.data.current_page] = result.data.data }) .catch(error => console.log('error', error)))).then(() => { for (var i = 1; i <= page_num; i++) { photo_list.push(...data[i].filter(item => !exclude.includes(item.pathname)).map(item => ({ img: item.links.url, title: item.name.match(/^(.*)\.(webp|png|jpg)$/)[1], time: item.date }))) }
let html = ''; photo_list.forEach(item => { html += `<div class="gallery-photo"><a href="${item.img}" data-fancybox="gallery" class="fancybox" data-thumb="${item.img}"><img class="photo-img" loading='lazy' decoding="async" src="${item.img}"></a>`; item.title ? html += `<span class="photo-title">${item.title}</span>` : ''; item.time ? html += `<span class="photo-time">${item.time}</span>` : ''; html += `</div>`; }); document.querySelector('.gallery-photos.page').innerHTML = html;
imgStatus.watch('.photo-img', () => { waterfall('.gallery-photos'); });
window.Lately && Lately.init({ target: '.photo-time' });
window.ViewImage && ViewImage.init('.gallery-photo img'); }); } </script> </body> </html>
|
方案二
为了解决方案一token暴露的痛点,想用一个后端解决,之前实时文章排参照hexo-circle-of-friends项目用了py,想尝试新东西,因为先有了方案一用了很多JS,所以采用node改写会很方便。主要参照Lenous的微博自建api仿着写的。
区别方案一做了如下修改:
- token不再暴露于前端
- 两种后端部署方式二选一,支持Vercel/服务器
- 减少cdn消耗,不再加载所有照片,对照片做了分页,每页20张图片,点击加载更多在底部追加20张(又是参考朋友圈思路嘿嘿)
- COS和Lsky在后端处理成相同Json格式,前端共用一个模板,代码更加简洁清晰
- Butterfly主题懒加载图片无法显示
获取变量
按照方案一中腾讯云的1、2步或Lsky图床的第一步获取需要的变量
腾讯云
| 变量名 | 解释 | 示例 |
|---|
| cos_domain | 存储桶自定义域名 | lsky.pro |
| cos_SecretId | 存储桶SECRETID | |
| cos_SecretKey | 存储桶SECRETKEY | |
| cos_bucket | 存储桶名称 | name-xxxx |
| cos_region | 存储桶地域 | ap-nanjing |
| cos_prefix | 文件夹路径,可留空显示全部 | blog |
| exclude | 排除某个图片的绝对路径,可留空 | 'blog/1.webp','blog/2.webp |
兰空
| 变量名 | 解释 | 示例 |
|---|
| lsky_domain | 兰空域名 | lsky.pro |
| lsky_token | 兰空Token | Bearer 1 | zsNyAN4hPx0xWONcq8Zu01UpynZWQcTgBg26R6iP |
| exclude | 排除某个图片的绝对路径,可留空 | 'blog/1.webp','blog/2.webp |
后端-Vercel
点击按钮开始部署:
,直接使用 Github 账号登录即可
配置变量,Settings->Environment Variables->依次添加变量->Save,以腾讯云为例配置如图

保存后重新部署使变量生效,Deployments->点击最上边一行的三个点->Redeploy,如上图所示
部署完毕后访问测试,腾讯云接口为https://域名/cos,Lsky域名为https://域名/lsky
绑定自定义域名,Vercel 分配的域名 DNS 被污染了, 绑定自定义域名即可直连。Setting->Domains

后端-服务器
打开宝塔面板,在wwwroot下新建一个目录,然后新建 index.js 和 package.json 并填写仓库GC-ZF/Cos-Lsky-PhotoWall内对应文件内容。其中indexl.js的8~37行诸如process.env['cos_domain']需要替换为你实际的配置,如果只用COS就只需要对COS修改

建好之后,依次点击 左侧导航栏网站->node项目->添加node项目,然后填写配置,没有node需要安装node
- 项目目录:刚才创建的目录
- 项目端口:3000,需要修改在
index.js第五行。记得在腾讯云服务器面板的防火墙放行端口 - 绑定域名:建议申请并配置一下ssl,有的网站不能使用http。不配置域名使用
服务器ip:3000 - 其他:默认即可

前端
以Hexo为例,新建页面hexo new page photos,插入如下代码,填写36~43行配置
<script src="//cdnjs.cloudflare.com/ajax/libs/waterfall.js/1.0.2/waterfall.min.js"></script>
<script src="https://npm.elemecdn.com/imgstatus/imgStatus.min.js"></script>
<script src="https://jsd.onmicrosoft.cn/gh/Tokinx/lately/lately.min.js"></script>
<script src="https://jsd.onmicrosoft.cn/gh/Tokinx/ViewImage/view-image.min.js"></script> <style>
.gallery-photos a img {margin: 0!important; border-radius:0;bottom: 0px;} .gallery-photos{width:100%;margin-top: 10px;} .gallery-photo{min-height:5rem;width:24.97%;padding:4px;position: relative;} .gallery-photo a{border-radius:8px;display:block;overflow: hidden;} .gallery-photo img{display: block;width:100%;animation: fadeIn 1s;cursor: pointer;transition: all .4s ease-in-out !important;} .gallery-photo span.photo-title,.gallery-photo span.photo-time{max-width: calc(100% - 7px);line-height:1.8;position:absolute;left:4px;font-size:14px;background: rgba(0, 0, 0, 0.3);padding:0px 8px;color: #fff;animation: fadeIn 1s;} .gallery-photo span.photo-title{bottom:4px;border-radius: 0 8px 0 8px;} .gallery-photo span.photo-time{top:4px;border-radius: 8px 0 8px 0;} .gallery-photo:hover img{transform: scale(1.1);} @media screen and (max-width: 1100px) {.gallery-photo{width:33.3%;}} @media screen and (max-width: 768px) { .gallery-photo{width:49.9%;padding:3px} .gallery-photo span.photo-time{display:none} .gallery-photo span.photo-title{font-size:12px} .gallery-photo span.photo-title{left:3px;bottom:3px;} } @keyframes fadeIn{0% {opacity: 0;}100%{opacity: 1;}} .uploadmore{width:40%;max-width:810px;height:30px;margin:auto;border-radius:12px;font-weight:700;text-align:center;display:flex;align-items:center;justify-content:center;cursor:pointer;border:1px solid #000;box-shadow:0 8px 16px -4px #2c2d300c;background:#f4f4f4}
[data-theme=dark] .uploadmore,.dark .uploadmore{background: #1D1E3C;} </style>
<div class="gallery-photos page"></div> <script type="text/javascript"> window.onresize = () => { if (location.pathname == '/photos/') waterfall('.gallery-photos'); }; api = 'https://cos-lsky-photo-wall.zhsher.cn/cos' var element = document.createElement('div'); element.classList.add('uploadmore'); element.innerHTML = `加载中...` document.querySelector('.gallery-photos.page').insertAdjacentElement('afterend', element); fetch(`${api}`) .then(response => response.json()) .then(result => { let page = []; let current_page = 0; for (var i = 0; i < result.length / 20; i++) { page.push(result.slice(i * 20, (i + 1) * 20)) } getPhoto(page[0]); updateButton(current_page, page.length - 1); current_page++; document.querySelector('.uploadmore').onclick = function() { getPhoto(page[current_page]); updateButton(current_page, page.length - 1) current_page++; } }) .catch(error => console.log('error', error)); function getPhoto(photo_list) { let html = ''; photo_list.forEach(item => { html += `<div class="gallery-photo"><a href="${item.img}" data-fancybox="gallery" class="fancybox" data-thumb="${item.img}"><img class="photo-img " loading='lazy' decoding="async" src="${item.img}"></a>`; item.title ? html += `<span class="photo-title">${item.title}</span>` : ''; item.time ? html += `<span class="photo-time">${item.time}</span>` : ''; html += `</div>`; }); document.querySelector('.gallery-photos.page').innerHTML += html; imgStatus.watch('.photo-img', () => { waterfall('.gallery-photos'); }); window.Lately && Lately.init({ target: '.photo-time' }); window.ViewImage && ViewImage.init('.gallery-photo img'); } function updateButton(current_page, page) { if (current_page == 0 && page != 0) { document.querySelector('.uploadmore').innerHTML = `加载更多` } else if (current_page == page) { document.querySelector('.uploadmore').innerHTML = `已加载全部` } } </script>
|
butterfly额外操作1
如果是butterfly主题,主题自带灯箱,修改前端模板
- // 图片灯箱 - window.ViewImage && ViewImage.init('.gallery-photo img');
|
butterfly额外操作2
如果是butterfly主题且开启了lazyload,图片可能会显示空白。参照butterfly控制特定图片懒加载 | 安知鱼中方法二,修改前端模板
- <img class="photo-img" loading='lazy' decoding="async" src="${item.img}">; + <img class="photo-img no-lazyload" loading='lazy' decoding="async" src="${item.img}">;
|
修改themes/butterfly/scripts/filters/post_lazyload.js的第13行
- return htmlContent.replace(/(<img.*? src=)/ig, `$1 "${bg}" data-lazy-src=`); + return htmlContent.replace(/(<img(?!.*?class[\t]*=[\t]*['"].*?no-lazyload.*?['"]).*? src=)/gi, `$1 "${bg}" data-lazy-src=`);
|