Cloudflare Worker 导航页与 KV 存储开发笔记

1. 项目概述与需求背景

  • 目标:实现一个基于 Cloudflare Worker 的极简、高速导航页,支持后台可视化管理(带密码保护),并将数据持久化存储在 Cloudflare KV 中。
  • 布局调整:前台展示从初始的一列细长布局优化为两列网格自适应布局,提升大屏浏览体验,同时支持移动端窄屏自动折叠为单列。

2. 核心技术点与问题修复记录

2.1 KV 数据初始化与空状态兜底

  • 问题:当 KV 中初始没有数据或返回空对象 {} 时,前端 navData 解析异常,导致后台没有任何分类、点击新增无反应。
  • 解决方案:在 Worker 读取 KV 时加入健壮性校验。如果 KV 数据为空或格式不正确,则自动初始化默认的分类结构:

    let rawData = await env.NAV_KV.get("nav_data", { type: "json" });
    let data = (rawData && typeof rawData === 'object' && Object.keys(rawData).length > 0) ? rawData : {
    "常用导航": [
      { "name": "Google", "url": "https://google.com" },
      { "name": "百度", "url": "https://www.baidu.com" }
    ]
    };
    

2.2 后台保存逻辑修复

  • 问题:原代码 saveData 函数中包含复杂且有潜在风险的 eval 处理。
  • 解决方案:改写为标准的异步 fetch POST 请求,将前端组装的完整 JSON 数据直接提交并写入 KV 中:

    async function saveData() {
      const urlParams = new URLSearchParams(location.search);
      const token = urlParams.get('token');
      try {
          const res = await fetch('/zadmin?token=' + encodeURIComponent(token), {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify(navData)
          });
          const result = await res.json();
          // 处理响应提示
      } catch (e) {
          alert('保存出错: ' + e.message);
      }
    }
    

2.3 前台双列网格布局设计 (CSS Grid)

  • 实现方案:通过 CSS Grid 布局将导航卡片划分为两列,并设置自适应断点:

    body {
      max-width: 900px;
      margin: 40px auto;
      padding: 0 20px;
    }
    .nav-container {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 20px 40px;
    }
    @media (max-width: 600px) {
      .nav-container { grid-template-columns: 1fr; }
    }
    .footer {
      grid-column: 1 / -1;
    }
    

3. Cloudflare KV 存储限制说明

  • 单个 Value 最大限制:25 MiB(约 2,600 万个字节/字符)。
  • 容量评估:导航页纯 JSON 数据占用空间极小(单个条目仅几十字节)。即便存储数千甚至上万个网址,总体积通常也仅在几十 KB 到几百 KB 之间,完全无需担心容量上限问题。

4. 最终完整代码

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    
    let rawData = await env.NAV_KV.get("nav_data", { type: "json" });
    let data = (rawData && typeof rawData === 'object' && Object.keys(rawData).length > 0) ? rawData : {
      "常用导航": [
        { "name": "Google", "url": "https://google.com" },
        { "name": "百度", "url": "https://www.baidu.com" }
      ]
    };

    if (url.pathname === "/zadmin") {
      const authHeader = request.headers.get("Authorization");
      const tokenParam = url.searchParams.get("token");
      
      if (authHeader !== env.ADMIN_PASSWORD && tokenParam !== env.ADMIN_PASSWORD) {
        return new Response(adminLoginPage(), {
          headers: { "Content-Type": "text/html;charset=UTF-8" }
        });
      }

      if (request.method === "POST") {
        try {
          const body = await request.json();
          await env.NAV_KV.put("nav_data", JSON.stringify(body));
          return new Response(JSON.stringify({ success: true, message: "保存成功" }), {
            headers: { "Content-Type": "application/json;charset=UTF-8" }
          });
        } catch (e) {
          return new Response(JSON.stringify({ success: false, message: "保存失败: " + e.message }), {
            status: 400,
            headers: { "Content-Type": "application/json;charset=UTF-8" }
          });
        }
      }

      return new Response(adminPage(data), {
        headers: { "Content-Type": "text/html;charset=UTF-8" }
      });
    }

    const html = `<!DOCTYPE html>
    <html lang="zh-CN">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Index</title>
        <style>
            body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 20px; line-height: 1.6; color: #333; background: #fff; }
            h1 { font-size: 1.5em; margin-bottom: 20px; }
            .nav-container {
                display: grid;
                grid-template-columns: repeat(2, minmax(0, 1fr));
                gap: 20px 40px;
            }
            @media (max-width: 600px) {
                .nav-container { grid-template-columns: 1fr; }
            }
            .category-group {
                break-inside: avoid;
            }
            h2 { border-bottom: 1px solid #eee; padding-bottom: 6px; margin-top: 10px; font-size: 1.1em; color: #666; }
            ul { padding-left: 0; list-style: none; margin: 8px 0; }
            li { margin: 6px 0; }
            a { color: #0366d6; text-decoration: none; font-size: 1.05em; }
            a:hover { text-decoration: underline; }
            .footer { margin-top: 50px; font-size: 0.8em; color: #999; border-top: 1px solid #f0f0f0; padding-top: 10px; grid-column: 1 / -1; }
        </style>
    </head>
    <body>
        <h1>Index</h1>
        <div class="nav-container">
            ${Object.entries(data).map(([category, links]) => `
                <div class="category-group">
                    <h2>${category}</h2>
                    <ul>
                        ${Array.isArray(links) ? links.map(link => `<li><a href="${link.url}" target="_blank">${link.name}</a></li>`).join('') : ''}
                    </ul>
                </div>
            `).join('')}
            <div class="footer"><a href="/zadmin" style="color: #999;" target="_blank">[Admin Login]</a></div>
        </div>
    </body>
    </html>`;

    return new Response(html, {
      headers: { "Content-Type": "text/html;charset=UTF-8" }
    });
  }
};

function adminLoginPage() {
  return `<!DOCTYPE html>
  <html lang="zh-CN">
  <head><meta charset="UTF-8"><title>Admin Login</title></head>
  <body style="display:flex;justify-content:center;align-items:center;height:100vh;font-family:sans-serif;">
    <form onsubmit="event.preventDefault(); location.href='/zadmin?token=' + encodeURIComponent(document.getElementById('p').value);">
      <h3>请输入管理密码</h3>
      <input type="password" id="p" style="padding:8px;font-size:16px;" autofocus>
      <button type="submit" style="padding:8px 16px;font-size:16px;">登录</button>
    </form>
  </body>
  </html>`;
}

function adminPage(currentData) {
  return `<!DOCTYPE html>
  <html lang="zh-CN">
  <head>
      <meta charset="UTF-8"><title>Nav Admin</title>
      <style>
          body { font-family: sans-serif; max-width: 800px; margin: 30px auto; padding: 0 20px; color: #333; }
          .category-box { background: #f9f9f9; border: 1px solid #ddd; padding: 15px; margin-bottom: 20px; border-radius: 6px; }
          .category-header { display: flex; align-items: center; margin-bottom: 10px; gap: 10px; }
          .category-header input { font-size: 16px; font-weight: bold; padding: 5px; flex: 1; }
          .link-row { display: flex; gap: 10px; margin-bottom: 8px; align-items: center; }
          .link-row input { padding: 5px; font-size: 14px; }
          .link-row input.name { width: 30%; }
          .link-row input.url { width: 55%; }
          button { padding: 6px 12px; cursor: pointer; border-radius: 4px; border: 1px solid #ccc; background: #fff; }
          button.danger { background: #ffebee; color: #c62828; border-color: #ef9a9a; }
          button.primary { background: #2ea44f; color: white; border: none; font-size: 16px; padding: 10px 20px; }
          .top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
      </style>
  </head>
  <body>
      <div class="top-bar">
          <h2>导航可视化后台管理</h2>
          <div>
              <button onclick="addCategory()">+ 新增分类</button>
              <button class="primary" onclick="saveData()">保存全部修改</button>
          </div>
      </div>
      
      <div id="editor-container"></div>
      
      <br>
      <button class="primary" onclick="saveData()">保存全部修改</button>
      <span id="msg" style="margin-left: 10px; color: green; font-weight: bold;"></span>
      <p><a href="/" target="_blank">👉 查看前台效果</a></p>

      <script>
          let navData = ${JSON.stringify(currentData)};

          function render() {
              const container = document.getElementById('editor-container');
              container.innerHTML = '';
              
              const categories = Object.keys(navData);
              if (categories.length === 0) {
                  container.innerHTML = '<p>暂无分类,请点击上方“新增分类”</p>';
                  return;
              }

              categories.forEach((cat, catIdx) => {
                  const box = document.createElement('div');
                  box.className = 'category-box';
                  
                  let headerHtml = \`
                      <div class="category-header">
                          <input type="text" value="\${cat}" placeholder="分类名称" onchange="updateCategoryName(\${catIdx}, this.value)">
                          <button class="danger" onclick="deleteCategory(\\\`\${cat}\\\`)">删除整个分类</button>
                          <button onclick="addLink(\\\`\${cat}\\\`)">+ 添加网址</button>
                      </div>
                  \`;
                  
                  let linksHtml = '';
                  const links = navData[cat] || [];
                  links.forEach((link, linkIdx) => {
                      linksHtml += \`
                          <div class="link-row">
                              <input type="text" class="name" value="\${link.name || ''}" placeholder="网站名称" onchange="updateLink(\\\`\${cat}\\\`, \${linkIdx}, 'name', this.value)">
                              <input type="text" class="url" value="\${link.url || ''}" placeholder="网址链接 (https://...)" onchange="updateLink(\\\`\${cat}\\\`, \${linkIdx}, 'url', this.value)">
                              <button class="danger" onclick="deleteLink(\\\`\${cat}\\\`, \${linkIdx})">删除</button>
                          </div>
                      \`;
                  });

                  box.innerHTML = headerHtml + linksHtml;
                  container.appendChild(box);
              });
          }

          function updateCategoryName(idx, newName) {
              const keys = Object.keys(navData);
              const oldName = keys[idx];
              if (!newName || oldName === newName) return;
              if (navData[newName]) {
                  alert('分类名已存在!');
                  render();
                  return;
              }
              let newData = {};
              keys.forEach((k, i) => {
                  if (i === idx) {
                      newData[newName] = navData[oldName];
                  } else {
                      newData[k] = navData[k];
                  }
              });
              navData = newData;
          }

          function addCategory() {
              const name = prompt('请输入新分类名称:');
              if (!name) return;
              if (navData[name]) {
                  alert('分类已存在');
                  return;
              }
              navData[name] = [];
              render();
          }

          function deleteCategory(cat) {
              if (confirm('确定要删除分类“' + cat + '”及其所有网址吗?')) {
                  delete navData[cat];
                  render();
              }
          }

          function addLink(cat) {
              if (!navData[cat]) navData[cat] = [];
              navData[cat].push({ name: '新网站', url: 'https://' });
              render();
          }

          function updateLink(cat, idx, field, val) {
              if (navData[cat] && navData[cat][idx]) {
                  navData[cat][idx][field] = val;
              }
          }

          function deleteLink(cat, idx) {
              navData[cat].splice(idx, 1);
              render();
          }

          async function saveData() {
              const urlParams = new URLSearchParams(location.search);
              const token = urlParams.get('token');
              try {
                  const res = await fetch('/zadmin?token=' + encodeURIComponent(token), {
                      method: 'POST',
                      headers: { 'Content-Type': 'application/json' },
                      body: JSON.stringify(navData)
                  });
                  const result = await res.json();
                  document.getElementById('msg').innerText = result.message;
                  setTimeout(() => { document.getElementById('msg').innerText = ''; }, 3000);
              } catch (e) {
                  alert('保存出错: ' + e.message);
              }
          }

          render();
      </script>
  </body>
  </html>`;
}

标签: none

添加新评论