前言
白嫖cloudflare的R2存储图片,然后用workers实现一个简单的相册功能
步骤
- 注册Cloudflare账号,创建R2存储桶(bucket)和Workers KV命名空间
- 在R2存储桶中上传一些图片(支持jpg、jpeg、png、gif、webp格式)
- 创建一个新的Cloudflare Workers脚本,复制以下代码并粘贴到脚本编辑器中
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// 相册主页
if (url.pathname === '/') {
return await generateAlbumPage(env, ctx);
}
// 搜索接口
if (url.pathname === '/search') {
const query = url.searchParams.get('q') || '';
return await searchImages(env, query, ctx);
}
// 图片代理(解决跨域问题)
if (url.pathname.startsWith('/image/')) {
const key = decodeURIComponent(url.pathname.slice(7));
return await serveImage(env, key);
}
return new Response('Not Found', { status: 404 });
}
};
// 提取文件名的基本部分(不含路径和扩展名)
function getBaseName(key) {
// 去除路径
const filename = key.substring(key.lastIndexOf('/') + 1);
// 去除扩展名
return filename.substring(0, filename.lastIndexOf('.'));
}
// 计算相对时间
function timeAgo(timestamp) {
const seconds = Math.floor((new Date() - new Date(timestamp)) / 1000);
// 定义时间间隔
const intervals = {
year: 31536000,
month: 2592000,
week: 604800,
day: 86400,
hour: 3600,
minute: 60
};
// 如果小于30秒
if (seconds < 30) return '刚刚';
// 计算时间差
let counter;
for (const i in intervals) {
counter = Math.floor(seconds / intervals[i]);
if (counter > 0) {
if (counter === 1) {
return `${counter} ${i}前`; // 1天前
} else {
return `${counter} ${i}前`; // 2天前
}
}
}
return '刚刚';
}
// 生成相册页面
async function generateAlbumPage(env, ctx) {
const images = await listAllImages(env, ctx);
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>浪子的相册 - 且听风吟</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.search {
margin-bottom: 20px;
display: flex;
gap: 10px;
}
.search input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
.search button {
padding: 10px 20px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.search button:hover {
background-color: #45a049;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.card {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
cursor: pointer;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.card img {
width: 100%;
height: 200px;
object-fit: cover;
}
.card-info {
padding: 10px;
}
.card-name {
font-weight: bold;
margin-bottom: 5px;
word-break: break-word;
}
.card-meta {
font-size: 0.9em;
color: #666;
}
.time-ago {
color: #4CAF50;
font-weight: 500;
}
/* 灯箱样式 */
.lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
z-index: 1000;
text-align: center;
padding: 20px;
box-sizing: border-box;
}
.lightbox-content {
position: relative;
max-width: 90%;
max-height: 90%;
margin: 0 auto;
display: inline-block;
}
.lightbox img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border: 2px solid white;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
}
.lightbox-caption {
color: white;
margin-top: 15px;
font-size: 18px;
}
.lightbox-close {
position: absolute;
top: 20px;
right: 20px;
color: white;
font-size: 30px;
cursor: pointer;
z-index: 1001;
}
.lightbox-nav {
position: absolute;
top: 50%;
width: 100%;
transform: translateY(-50%);
display: flex;
justify-content: space-between;
padding: 0 20px;
box-sizing: border-box;
}
.lightbox-nav button {
background-color: rgba(255, 255, 255, 0.2);
color: white;
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s;
}
.lightbox-nav button:hover {
background-color: rgba(255, 255, 255, 0.4);
}
</style>
</head>
<body>
<h1>且听风吟</h1>
<div class="search">
<input type="text" id="searchInput" placeholder="搜索" />
<button onclick="searchImages()">Search</button>
</div>
<div id="imageGrid" class="grid">
${images.map((img, index) => `
<div class="card" data-index="${index}" onclick="openLightbox(${index})">
<img src="/image/${encodeURIComponent(img.key)}" alt="${img.key}" />
<div class="card-info">
<div class="card-name">${getBaseName(img.key)}</div>
<div class="card-meta">
<!-- Size: ${formatBytes(img.size)} | -->
<span class="time-ago" data-timestamp="${img.uploaded}">${timeAgo(img.uploaded)}</span>
</div>
</div>
</div>
`).join('')}
</div>
<!-- 灯箱 -->
<div id="lightbox" class="lightbox">
<span class="lightbox-close" onclick="closeLightbox()">×</span>
<div class="lightbox-content">
<img id="lightbox-img" src="" alt="" />
<div id="lightbox-caption" class="lightbox-caption"></div>
</div>
<div class="lightbox-nav">
<button id="prev-btn" onclick="changeImage(-1)">❮</button>
<button id="next-btn" onclick="changeImage(1)">❯</button>
</div>
</div>
<script>
// 提取文件名的基本部分(不含路径和扩展名)
function getBaseName(key) {
// 去除路径
const filename = key.substring(key.lastIndexOf('/') + 1);
// 去除扩展名
return filename.substring(0, filename.lastIndexOf('.'));
}
// 计算相对时间
function timeAgo(timestamp) {
const seconds = Math.floor((new Date() - new Date(timestamp)) / 1000);
// 定义时间间隔
const intervals = {
year: 31536000,
month: 2592000,
week: 604800,
day: 86400,
hour: 3600,
minute: 60
};
// 如果小于30秒
if (seconds < 30) return '刚刚';
// 计算时间差
let counter;
for (const i in intervals) {
counter = Math.floor(seconds / intervals[i]);
if (counter > 0) {
if (counter === 1) {
return \`\${counter} \${i} Ago\`; // 1天前
} else {
return \`\${counter} \${i} Ago\`; // 2天前
}
}
}
return '刚刚';
}
// 更新所有时间显示
function updateTimeAgoElements() {
const timeElements = document.querySelectorAll('.time-ago');
timeElements.forEach(element => {
const timestamp = element.getAttribute('data-timestamp');
element.textContent = timeAgo(timestamp);
});
}
// 存储所有图片信息
const allImages = ${JSON.stringify(images)};
let currentImageIndex = 0;
// 打开灯箱
function openLightbox(index) {
currentImageIndex = index;
showImage(index);
document.getElementById('lightbox').style.display = 'block';
document.body.style.overflow = 'hidden'; // 防止背景滚动
}
// 关闭灯箱
function closeLightbox() {
document.getElementById('lightbox').style.display = 'none';
document.body.style.overflow = 'auto'; // 恢复背景滚动
}
// 显示指定索引的图片
function showImage(index) {
if (index < 0) index = allImages.length - 1;
if (index >= allImages.length) index = 0;
currentImageIndex = index;
const img = allImages[index];
document.getElementById('lightbox-img').src = '/image/' + encodeURIComponent(img.key);
document.getElementById('lightbox-img').alt = img.key;
document.getElementById('lightbox-caption').innerHTML =
'<strong>' + getBaseName(img.key) + '</strong><br>' +
'Size: ' + formatBytes(img.size) + ' | ' +
'<span class="time-ago">' + timeAgo(img.uploaded) + '</span>';
}
// 切换图片
function changeImage(direction) {
showImage(currentImageIndex + direction);
}
// 搜索图片
async function searchImages() {
const query = document.getElementById('searchInput').value;
const response = await fetch('/search?q=' + encodeURIComponent(query));
const images = await response.json();
const grid = document.getElementById('imageGrid');
grid.innerHTML = images.map((img, index) => \`
<div class="card" data-index="\${index}" onclick="openLightbox(\${index})">
<img src="/image/\${encodeURIComponent(img.key)}" alt="\${img.key}" />
<div class="card-info">
<div class="card-name">\${getBaseName(img.key)}</div>
<div class="card-meta">
Size: \${formatBytes(img.size)} |
<span class="time-ago" data-timestamp="\${img.uploaded}">\${timeAgo(img.uploaded)}</span>
</div>
</div>
</div>
\`).join('');
// 更新全局图片列表
allImages.length = 0;
allImages.push(...images);
}
// 格式化字节大小
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 键盘事件监听
document.addEventListener('keydown', function(e) {
if (document.getElementById('lightbox').style.display === 'block') {
if (e.key === 'Escape') {
closeLightbox();
} else if (e.key === 'ArrowLeft') {
changeImage(-1);
} else if (e.key === 'ArrowRight') {
changeImage(1);
}
}
});
// 点击灯箱背景关闭
document.getElementById('lightbox').addEventListener('click', function(e) {
if (e.target === this) {
closeLightbox();
}
});
// 初始更新时间显示
updateTimeAgoElements();
// 每分钟更新一次时间显示
setInterval(updateTimeAgoElements, 60000);
</script>
</body>
</html>
`;
return new Response(html, { headers: { 'Content-Type': 'text/html' } });
}
// 搜索图片
async function searchImages(env, query, ctx) {
const images = await listAllImages(env, ctx);
const filtered = images.filter(img =>
img.key.toLowerCase().includes(query.toLowerCase())
);
return new Response(JSON.stringify(filtered), {
headers: { 'Content-Type': 'application/json' }
});
}
// 获取所有图片列表
async function listAllImages(env, ctx) {
// 尝试从KV缓存获取
const cacheKey = 'image-index';
const cached = await env.ALBUM_KV.get(cacheKey);
if (cached) return JSON.parse(cached);
// 从R2获取最新列表
const objects = [];
let cursor = undefined;
do {
const options = { limit: 1000 };
if (cursor) options.cursor = cursor;
const list = await env.MY_R2_BUCKET.list(options);
objects.push(...list.objects.filter(obj =>
obj.key.match(/\.(jpg|jpeg|png|gif|webp)$/i)
));
cursor = list.truncated ? list.cursor : undefined;
} while (cursor);
// 转换为需要的格式
const images = objects.map(obj => ({
key: obj.key,
size: obj.size,
uploaded: obj.uploaded
}));
// 缓存结果(TTL 1小时)
ctx.waitUntil(env.ALBUM_KV.put(cacheKey, JSON.stringify(images), { expirationTtl: 3600 }));
return images;
}
// 代理图片服务
async function serveImage(env, key) {
const object = await env.MY_R2_BUCKET.get(key);
if (!object) return new Response('Not Found', { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('Cache-Control', 'public, max-age=31536000');
return new Response(object.body, { headers });
}
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
在脚本设置中,绑定R2存储桶和KV命名空间:
- 绑定R2存储桶为
MY_R2_BUCKET - 绑定KV命名空间为
ALBUM_KV
- 绑定R2存储桶为
保存并部署Workers脚本
