CF自建导航页
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处理。 解决方案:改写为标准的异步
fetchPOST 请求,将前端组装的完整 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>`;
}