最近在研究如何使用Cloudflare Workers和R2来实现一个简单的相册功能。通过结合这两项技术,我们可以创建一个高效且易于维护的在线相册系统。以下是我实现这一功能的步骤和代码示例。
准备工作
- 注册Cloudflare账号:如果还没有Cloudflare账号,需要先注册一个。
- 创建R2存储桶:在Cloudflare的仪表盘中创建一个R2存储桶,用于存储相册图片。
- 设置Workers:在Cloudflare的Workers部分创建一个新的Worker,用于处理图片展示。
- 设置KV命名空间:如果需要存储图片的元数据,可以创建一个KV命名空间。
代码实现
以下是一个简单的Cloudflare Worker代码示例,用于从R2存储桶中获取图片并展示在网页上。
export default { async fetch(request, env, ctx) {return new Response(html, { headers: { 'Content-Type': 'text/html' } })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 }) }}
// 生成相册页面 async function generateAlbumPage(env, ctx) {const images = await listAllImages(env, ctx)
const html =<!DOCTYPE html> <html> <head> <title>My Photo Album</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;<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">${img.key}</div> <div class="card-meta">Size: ${formatBytes(img.size)} | Uploaded: ${new Date(img.uploaded).toLocaleString()}</div> </div> </div>}
/ 灯箱样式 / .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>My Photo Album</h1> <div class="search"> <input type="text" id="searchInput" placeholder="Search images..." /> <button onclick="searchImages()">Search</button> </div> <div id="imageGrid" class="grid"> ${images.map((img, index) =>).join('')}<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">\${img.key}</div> <div class="card-meta">Size: \${formatBytes(img.size)} | Uploaded: \${new Date(img.uploaded).toLocaleString()}</div> </div> </div></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> // 存储所有图片信息 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>' + img.key + '</strong><br>' + 'Size: ' + formatBytes(img.size) + ' | Uploaded: ' + new Date(img.uploaded).toLocaleString();}
// 切换图片 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) => \\
// 更新全局图片列表 allImages.length = 0; allImages.push(...images);).join('');}
// 格式化字节大小 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(); } }); </script> </body> </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
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${Number.parseFloat((bytes / k i).toFixed(2))} ${sizes[i]}}
可自行修改css样式以适应个人喜好。
部署与测试
- 部署Worker:将上述代码复制到Cloudflare Workers的编辑器中,保存并部署。
- 配置R2和KV:确保Worker绑定了你创建的R2存储桶(变量使用
MY_R2_BUCKET)和KV命名空间(变量使用ALBUM_KV)。 - 上传图片:通过Cloudflare的R2界面上传一些图片到你的存储桶中。
- 访问相册**:打开浏览器,访问你的Worker URL,应该可以看到相册页面,并且可以搜索和查看图片。
演示
你可以访问以下链接查看演示效果:
https://gallery.zxd.im/