前言
白嫖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脚本
