HBB BLOG
首页
后台
🌙
基于 Cloudflare Workers + KV + R2 的轻量免维护博客搭建与实践
📅 2026-07-21
📂 网站
📝 12916 字
⏱️ 44 分钟
👁️ 22
在独立博客的选择上,我们往往需要在“动态博客(如 WordPress)的维护成本与加载速度”以及“静态博客(如 Hexo)的本地编译繁琐性”之间做出妥协。 为了寻找更优解,我基于 Cloudflare 边缘计算平台,整理并部署了一套**零服务器成本、免日常运维、且前后台体验良好的独立博客系统 (HBB BLOG)**。 本文将分享这套博客的设计特点,并提供完整的搭建指南,帮助你快速部署属于自己的边缘博客。 --- ## 🛠️ 系统架构与设计特点 本博客系统不依赖传统的虚拟主机或 VPS,而是完全运行在 Cloudflare 的 Serverless 架构上: 1. **计算层 (Cloudflare Workers)**:利用边缘节点进行页面动态渲染与路由分发,实现毫秒级的响应速度。 2. **数据层 (Cloudflare KV)**:将文章元数据索引、正文内容、评论数据等存储在键值数据库中,告别传统数据库的冷启动与维护烦恼。 3. **多媒体 (Cloudflare R2)**:兼容 S3 协议的对象存储,用于免费托管文章插图与封面,省去昂贵的对象存储流量费。 ### 主要功能特性: * **前台阅读体验**:经典的 NexT Pisces 响应式布局,支持亮色/暗色模式、自动生成滚动高亮文章目录(TOC)、顶部阅读进度条、代码块一键复制(Copy)功能。 * **磨砂玻璃后台**:半透明毛玻璃质感(Glassmorphism)的管理面板,支持双栏 Markdown 实时预览、富文本插入工具栏、全屏编辑及封面图一键上传。 * **安全防护防护**:内置严格的 CSP 策略(基于 Nonce 的脚本沙箱)、PBKDF2 强加密密码学鉴权、防 XSS/CSRF。 * **防垃圾评论系统**:自带 Honeypot(蜜罐)静默过滤机器人垃圾评论,支持 IP 速率限流与管理员后台直接删除评论。 --- ## 🚀 零基础部署指南 由于本系统为**单文件、零构建**设计,你只需要一个 Cloudflare 账号,5分钟内即可完成部署。 ### 第一步:创建资源绑定 1. 登录 [Cloudflare 控制台](https://dash.cloudflare.com/)。 2. **创建 KV 命名空间**: * 进入“存储与数据库” -> “KV”,创建一个名为 `BLOG_KV` 的命名空间。 3. **创建 R2 存储桶**: * 进入“存储与数据库” -> “R2”,创建一个名为 `BLOG_R2` 的存储桶(Bucket)。 ### 第二步:生成密码哈希 本博客不存储明文密码。请在任意浏览器中按 `F12` 打开控制台(Console),粘贴并运行以下代码。输入你想设置的管理员密码与一段自定义的密钥: ```javascript async function genHash(password, jwtSecret) { const encoder = new TextEncoder(); const baseKey = await crypto.subtle.importKey( "raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"] ); const derivedBits = await crypto.subtle.deriveBits( { name: "PBKDF2", salt: encoder.encode(jwtSecret), iterations: 100000, hash: "SHA-256" }, baseKey, 256 ); return Array.from(new Uint8Array(derivedBits)) .map(b => b.toString(16).padStart(2, '0')).join(''); } // 请在此处修改你的“密码”与“JWT密钥” genHash("你的管理员密码", "你的JWT_SECRET").then(console.log); ``` 运行后,控制台会输出一个 **64位的十六进制字符串**。请记录该字符串以及你的 `JWT_SECRET`。 ### 第三步:新建并配置 Worker 1. 在 Cloudflare 菜单中点击“Workers 和 Pages” -> “创建 Worker”。 2. 填入名称(例如 `hbb-blog`)并部署。 3. 点击“编辑代码”,将博客源码完全覆盖写入并保存(先不忙部署,需要绑定变量)。 4. 返回该 Worker 的设置页面,进入“设置” -> “绑定”: * **KV 命名空间绑定**:添加绑定,变量名称填 `BLOG_KV`,选择第一步创建的 KV。 * **R2 存储桶绑定**:添加绑定,变量名称填 `BLOG_R2`,选择第一步创建的 R2。 5. 进入“设置” -> “变量与机密”,添加以下两个环境变量: * `ADMIN_PASSWORD_HASH`:填入第二步生成的64位哈希字符串。 * `JWT_SECRET`:填入第二步你自定义的 `JWT_SECRET` 原文。 6. 返回代码编辑页面,点击右上角的 **Quick Deploy(部署)**。 ### 第四步:初始化站点 1. 访问 `https://你的Worker域名/admin`,系统会自动重定向至登录页面。 2. 输入你在第二步设置的密码。 3. 登录成功后,点击右上角的 **“🔄 索引”** 按钮完成系统初始化(该操作会扫描并建立 KV 索引,重建空库)。 4. 点击 **“📝 写新文章”** 开始你的第一篇写作! --- ## 💬 交流与互动 博客搭建完成后,你可以在下方评论区留个言,顺便测试本站的限流和蜜罐反垃圾评论系统是否工作正常。 如果你在部署过程中遇到任何疑问,或者希望对这套架构进行定制与交流,欢迎加入我的 Telegram 群组一起探讨! * **作者:** HBB * **Telegram 交流群:** [https://t.me/hejiuchigua](https://t.me/hejiuchigua) --- ## 源码 ```javascript /** * Workers-KV-R2 Blog Engine — NexT Style Elite (Classic Edition) * Single-file, Zero-build, Pure JS · Cloudflare Workers * Deploy: compatibility_date = "2024-09-01" */ // ═══════════════════════════════════════════ // 可手动设置区域 // ═══════════════════════════════════════════ const CONFIG = { siteName: "HBB BLOG", siteSubtitle: "Powered by Cloudflare Workers", authorName: "HBB", authorAvatar: "", authorBio: "1000篇免维护Blog系统", footerText: "© 2026 HBB · Powered by Cloudflare Workers", social: { github: "", twitter: "", email: "blog@hbb.cloudns.org" }, postsPerPage: 8, readingSpeed: 300, excerptLength: 200, comments: { maxPerPost: 200, rateLimitPerIP: 3, rateLimitTTL: 60, maxNameLength: 30, maxContentLength: 1000 }, theme: { accent: "#0066cc", accentHover: "#0052a3", bgPage: "#f5f7f9", bgCard: "#ffffff", textPrimary: "#222222", textSecondary: "#666666", textMuted: "#999999", border: "#e8e8e8", shadow: "0 2px 12px rgba(0,0,0,0.06)", radius: "8px", contentWidth: "760px", sidebarWidth: "280px", }, darkTheme: { accent: "#4da6ff", accentHover: "#80bfff", bgPage: "#1a1a2e", bgCard: "#16213e", textPrimary: "#e0e0e0", textSecondary: "#a0a0a0", textMuted: "#707070", border: "#2a2a4a", shadow: "0 2px 12px rgba(0,0,0,0.3)", }, cdn: { marked: "https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js", markedSRI: "sha512-zAs8dHhwlTbfcVGRX1x0EZAH/L99NjAFzX6muwOcOJc7dbGFNaW4O7b9QOyCMRYBNjO+E0Kx6yLDsiPQhhWm7g==", prism: "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js", prismSRI: "sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==", prismTheme: "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css", prismThemeSRI: "sha512-vswe+cgvic/XBoF1OcM/TeJ2FW0OofqAVdCZiEYkd6dwGXthvkSFWOoGGJgS2CW70VK5dQM5Oh+7ne47s74VTg==", prismJS: "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js", prismJSSRI: "sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==", }, }; function cssVars() { const t = CONFIG.theme; return ":root{--accent:" + t.accent + ";--accent-hover:" + t.accentHover + ";--bg-page:" + t.bgPage + ";--bg-card:" + t.bgCard + ";--text-primary:" + t.textPrimary + ";--text-secondary:" + t.textSecondary + ";--text-muted:" + t.textMuted + ";--border:" + t.border + ";--shadow:" + t.shadow + ";--radius:" + t.radius + ";--content-width:" + t.contentWidth + ";--sidebar-width:" + t.sidebarWidth + "}"; } function cssDarkVars() { const d = CONFIG.darkTheme; return '[data-theme="dark"]{--accent:' + d.accent + ';--accent-hover:' + d.accentHover + ';--bg-page:' + d.bgPage + ';--bg-card:' + d.bgCard + ';--text-primary:' + d.textPrimary + ';--text-secondary:' + d.textSecondary + ';--text-muted:' + d.textMuted + ';--border:' + d.border + ';--shadow:' + d.shadow + '}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]){--accent:' + d.accent + ';--accent-hover:' + d.accentHover + ';--bg-page:' + d.bgPage + ';--bg-card:' + d.bgCard + ';--text-primary:' + d.textPrimary + ';--text-secondary:' + d.textSecondary + ';--text-muted:' + d.textMuted + ';--border:' + d.border + ';--shadow:' + d.shadow + '}}'; } // ═══════════════════════════════════════════ // 路由入口 // ═══════════════════════════════════════════ export default { async fetch(request, env, ctx) { try { return await handleRequest(request, env, ctx); } catch (err) { console.error("Unhandled:", err.message, err.stack); return new Response(JSON.stringify({ error: "Internal Server Error" }), { status: 500, headers: { "Content-Type": "application/json", "X-Content-Type-Options": "nosniff" } }); } }, }; async function handleRequest(request, env, ctx) { const url = new URL(request.url); const method = request.method; const nonce = crypto.randomUUID(); if (url.pathname.startsWith("/media/")) return addSecurityHeaders(await handleMediaProxy(url.pathname.replace("/media/", ""), env), nonce); if (url.pathname === "/sitemap.xml") return addSecurityHeaders(await handleSitemap(env, url.origin), nonce); if (url.pathname.startsWith("/posts/")) { const slug = url.pathname.replace("/posts/", ""); if (!slug || !/^[a-z0-9-]+$/.test(slug)) return addSecurityHeaders(new Response("Not Found", { status: 404 }), nonce); return addSecurityHeaders(await handlePostDetail(slug, env, ctx, nonce), nonce); } if (url.pathname === "/api/search") return addSecurityHeaders(await handleSearch(env, url.searchParams.get("q") || ""), nonce); if (url.pathname === "/api/comments" && method === "GET") return addSecurityHeaders(await handleGetComments(env, url), nonce); if (url.pathname === "/api/comments" && method === "POST") return addSecurityHeaders(await handlePostComment(request, env), nonce); if (url.pathname === "/admin/login" && method === "POST") return addSecurityHeaders(await handleAdminLogin(request, env), nonce); if (url.pathname === "/admin" || url.pathname.startsWith("/admin/")) return await handleAdminRouter(request, env, url, method, nonce, ctx); if (url.pathname === "/") return addSecurityHeaders(await handleHome(env, url.searchParams, nonce), nonce); return addSecurityHeaders(new Response("Not Found", { status: 404 }), nonce); } // ═══════════════════════════════════════════ // 安全工具 // ═══════════════════════════════════════════ function escapeHtml(s) { if (typeof s !== "string") return ""; return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'"); } function timingSafeEqual(a, b) { if (typeof a !== "string" || typeof b !== "string") return false; if (a.length !== b.length) return false; const A = new TextEncoder().encode(a), B = new TextEncoder().encode(b); let r = 0; for (let i = 0; i < A.length; i++) r |= A[i] ^ B[i]; return r === 0; } function isValidOrigin(req, url) { const o = req.headers.get("Origin") || req.headers.get("Referer"); if (!o) return false; try { return new URL(o).host === url.host; } catch { return false; } } function addSecurityHeaders(response, nonce) { const h = new Headers(response.headers); h.set("X-Content-Type-Options", "nosniff"); h.set("X-Frame-Options", "DENY"); h.set("Referrer-Policy", "strict-origin-when-cross-origin"); if ((h.get("Content-Type") || "").includes("text/html")) h.set("Content-Security-Policy", "default-src 'self' https:; script-src 'self' 'nonce-" + nonce + "' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: https:; frame-ancestors 'none';"); return new Response(response.body, { status: response.status, statusText: response.statusText, headers: h }); } function truncateToBytes(str, max) { if (typeof str !== "string") return ""; const e = new TextEncoder(); if (e.encode(str).length <= max) return str; let lo = 0, hi = str.length; while (lo < hi) { const m = (lo+hi+1)>>1; if (e.encode(str.slice(0,m)).length <= max) lo = m; else hi = m-1; } return str.slice(0, lo); } async function safeKVGet(kv, key) { try { return await kv.get(key); } catch (e) { console.error("KV:", key, e.message); return null; } } function jsonResponse(d, s) { return new Response(JSON.stringify(d), { status: s || 200, headers: { "Content-Type": "application/json" } }); } // ═══════════════════════════════════════════ // 密码学 // ═══════════════════════════════════════════ async function hashPassword(pw, salt) { const e = new TextEncoder(); const k = await crypto.subtle.importKey("raw", e.encode(pw), "PBKDF2", false, ["deriveBits"]); const b = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: e.encode(salt), iterations: 100000, hash: "SHA-256" }, k, 256); return Array.from(new Uint8Array(b)).map(x => x.toString(16).padStart(2,"0")).join(""); } async function hmacSha256(msg, sec) { const e = new TextEncoder(); const k = await crypto.subtle.importKey("raw", e.encode(sec), { name: "HMAC", hash: { name: "SHA-256" } }, false, ["sign"]); const s = await crypto.subtle.sign("HMAC", k, e.encode(msg)); return Array.from(new Uint8Array(s)).map(x => x.toString(16).padStart(2,"0")).join(""); } async function generateToken(u, sec) { const a = new Uint32Array(1); crypto.getRandomValues(a); const p = JSON.stringify({ username: u, exp: Date.now() + 86400000, nonce: a[0] }); const enc = btoa(p); return enc + "." + await hmacSha256(enc, sec); } async function verifyToken(tok, sec) { try { const d = tok.indexOf("."); if (d === -1) return null; const enc = tok.slice(0,d), sig = tok.slice(d+1); if (!enc || !sig) return null; if (!timingSafeEqual(sig, await hmacSha256(enc, sec))) return null; const p = JSON.parse(atob(enc)); return Date.now() > p.exp ? null : p; } catch { return null; } } // ═══════════════════════════════════════════ // 业务逻辑 // ═══════════════════════════════════════════ async function handleMediaProxy(fn, env) { if (!env.BLOG_R2) return new Response("R2 missing", { status: 500 }); const d = decodeURIComponent(fn); if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(d)) return new Response("Forbidden", { status: 400 }); const o = await env.BLOG_R2.get(d); if (!o) return new Response("Not Found", { status: 404 }); const h = new Headers(); o.writeHttpMetadata(h); h.set("etag", o.httpEtag); h.set("Cache-Control", "public, max-age=31536000, immutable"); return new Response(o.body, { headers: h }); } async function handleSitemap(env, origin) { const r = await safeKVGet(env.BLOG_KV, "post:list_index"); const ps = r ? JSON.parse(r) : []; let x = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>' + escapeHtml(origin) + '/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>'; for (const p of ps) x += '\n <url><loc>' + escapeHtml(origin) + '/posts/' + escapeHtml(p.slug) + '</loc><lastmod>' + escapeHtml(p.date||"") + '</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>'; x += "\n</urlset>"; return new Response(x, { headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=86400" } }); } async function handleSearch(env, q) { const r = await safeKVGet(env.BLOG_KV, "post:list_index"); if (!r) return jsonResponse([]); const ps = JSON.parse(r); const kw = q.toLowerCase().trim(); if (!kw) return jsonResponse(ps); return jsonResponse(ps.filter(p => (p.title&&p.title.toLowerCase().includes(kw))||(p.summary&&p.summary.toLowerCase().includes(kw)))); } async function handleGetComments(env, url) { const s = url.searchParams.get("slug"); if (!s||!/^[a-z0-9-]+$/.test(s)) return jsonResponse({error:"Invalid slug"},400); const r = await safeKVGet(env.BLOG_KV, "comments:"+s); const c = r ? JSON.parse(r) : []; return jsonResponse({comments:c,count:c.length}); } async function handlePostComment(req, env) { try { const ip = req.headers.get("CF-Connecting-IP")||"local"; const rk = "rate:comment:"+ip; const at = parseInt((await safeKVGet(env.BLOG_KV, rk))||"0"); if (at >= CONFIG.comments.rateLimitPerIP) return jsonResponse({error:"评论过于频繁"},429); const {slug,name,content,honeypot} = await req.json(); if (honeypot) return jsonResponse({success:true}); if (!slug||!/^[a-z0-9-]+$/.test(slug)) return jsonResponse({error:"Invalid slug"},400); if (!name||!name.trim()) return jsonResponse({error:"昵称不能为空"},400); if (!content||!content.trim()) return jsonResponse({error:"内容不能为空"},400); const sn = truncateToBytes(name.trim(), CONFIG.comments.maxNameLength*3); const sc = truncateToBytes(content.trim(), CONFIG.comments.maxContentLength*3); const raw = await safeKVGet(env.BLOG_KV, "comments:"+slug); let cs = raw ? JSON.parse(raw) : []; if (cs.length >= CONFIG.comments.maxPerPost) return jsonResponse({error:"评论已达上限"},400); cs.push({id:Date.now().toString(36)+Math.random().toString(36).slice(2,6),name:sn,content:sc,date:new Date().toISOString()}); await env.BLOG_KV.put("comments:"+slug, JSON.stringify(cs)); await env.BLOG_KV.put(rk, String(at+1), {expirationTtl:CONFIG.comments.rateLimitTTL}); return jsonResponse({success:true}); } catch(e){return jsonResponse({error:e.message},500);} } async function handleAdminLogin(req, env) { try { const ip = req.headers.get("CF-Connecting-IP")||"local"; const rk = "rate:login:"+ip; const at = parseInt((await safeKVGet(env.BLOG_KV, rk))||"0"); if (at>=5) return jsonResponse({error:"限流中"},429); const {password} = await req.json(); const salt = (env.JWT_SECRET||"").trim(); const sh = (env.ADMIN_PASSWORD_HASH||"").trim(); if (timingSafeEqual(await hashPassword(password, salt), sh)) { await env.BLOG_KV.delete(rk); const tok = await generateToken("admin", salt); const h = new Headers(); h.set("Content-Type","application/json"); h.set("Set-Cookie","admin_token="+tok+"; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400"); return new Response(JSON.stringify({success:true}),{headers:h}); } await env.BLOG_KV.put(rk, String(at+1), {expirationTtl:60}); return jsonResponse({error:"密码错误"},401); } catch{return jsonResponse({error:"异常"},500);} } async function handleAdminRouter(req, env, url, method, nonce, ctx) { const ch = req.headers.get("Cookie")||""; const cr = ch.split("; ").find(r=>r.startsWith("admin_token=")); const tok = cr ? cr.substring("admin_token=".length) : null; const salt = (env.JWT_SECRET||"").trim(); const user = tok ? await verifyToken(tok, salt) : null; if (url.pathname === "/admin/login") { if (user) return Response.redirect(url.origin+"/admin",302); return addSecurityHeaders(renderLoginView(nonce), nonce); } if (!user) return Response.redirect(url.origin+"/admin/login", 302); if (method==="POST"||method==="DELETE") { if (!isValidOrigin(req, url)) return jsonResponse({error:"CSRF Blocked"},403); } if (url.pathname==="/admin/save-post"&&method==="POST") return await handleSavePost(req, env, ctx); if (url.pathname==="/admin/delete-post"&&method==="POST") return await handleDeletePost(req, env, ctx); if (url.pathname==="/admin/upload"&&method==="POST") return await handleUpload(req, env); if (url.pathname==="/admin/clean-cache"&&method==="POST") return await handleCleanCache(req, env); if (url.pathname==="/admin/rebuild-index"&&method==="POST") return await handleRebuildIndex(env); if (url.pathname==="/admin/get-post"&&method==="GET") return await handleGetPost(env, url); if (url.pathname==="/admin/delete-comment"&&method==="POST") return await handleDeleteComment(req, env); if (url.pathname==="/admin/logout") { const h = new Headers(); h.set("Set-Cookie","admin_token=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0"); h.set("Location","/admin/login"); return new Response(null,{status:302,headers:h}); } return addSecurityHeaders(await renderDashboardView(env, nonce), nonce); } async function handleGetPost(env, url) { const s = url.searchParams.get("slug"); if (!s||!/^[a-z0-9-]+$/.test(s)) return jsonResponse({error:"Invalid"},400); const md = await safeKVGet(env.BLOG_KV, "post:"+s); if (!md) return jsonResponse({error:"Not found"},404); return jsonResponse({markdown:md}); } async function handleSavePost(req, env, ctx) { try { const {title,slug,date,category,tags,summary,markdown,cover} = await req.json(); if (!slug||!title||!markdown||!/^[a-z0-9-]+$/.test(slug)) return jsonResponse({error:"请填写完整"},400); const sm = {title:truncateToBytes(title||"",120),date:(date||new Date().toISOString().split("T")[0]).slice(0,20),category:truncateToBytes(category||"",60),tags:(tags||[]).slice(0,5).map(t=>truncateToBytes(String(t),40)),summary:truncateToBytes(summary||"",200),cover:(cover&&/^\/media\/[a-zA-Z0-9._-]+$/.test(cover))?cover:""}; await env.BLOG_KV.put("post:"+slug, markdown, {metadata:sm}); let idx=[]; const ir = await safeKVGet(env.BLOG_KV,"post:list_index"); if(ir){try{idx=JSON.parse(ir);}catch{idx=[];}} idx=idx.filter(p=>p.slug!==slug); idx.unshift({slug,...sm}); idx.sort((a,b)=>new Date(b.date)-new Date(a.date)); await env.BLOG_KV.put("post:list_index",JSON.stringify(idx)); ctx.waitUntil(purgeCache(env,req,slug).catch(e=>console.error("Purge:",e.message))); return jsonResponse({success:true}); } catch(e){return jsonResponse({error:e.message},500);} } async function handleDeletePost(req, env, ctx) { try { const {slug} = await req.json(); if (!slug||!/^[a-z0-9-]+$/.test(slug)) return jsonResponse({error:"Invalid"},400); await env.BLOG_KV.delete("post:"+slug); await env.BLOG_KV.delete("comments:"+slug); let idx=[]; const ir = await safeKVGet(env.BLOG_KV,"post:list_index"); if(ir){try{idx=JSON.parse(ir);}catch{idx=[];}} idx=idx.filter(p=>p.slug!==slug); await env.BLOG_KV.put("post:list_index",JSON.stringify(idx)); ctx.waitUntil(purgeCache(env,req,slug).catch(e=>console.error("Purge:",e.message))); return jsonResponse({success:true}); } catch(e){return jsonResponse({error:e.message},500);} } async function handleDeleteComment(req, env) { try { const {slug,id} = await req.json(); if (!slug||!/^[a-z0-9-]+$/.test(slug)||!id) return jsonResponse({error:"Invalid"},400); const r = await safeKVGet(env.BLOG_KV,"comments:"+slug); if(!r) return jsonResponse({error:"None"},404); let cs=JSON.parse(r); cs=cs.filter(c=>c.id!==id); await env.BLOG_KV.put("comments:"+slug,JSON.stringify(cs)); return jsonResponse({success:true}); } catch(e){return jsonResponse({error:e.message},500);} } async function handleUpload(req, env) { try { const fd = await req.formData(); const f = fd.get("file"); if(!f) return jsonResponse({error:"No file"},400); const m={"image/jpeg":"jpg","image/png":"png","image/webp":"webp","image/gif":"gif"}; const ext=m[f.type]; if(!ext) return jsonResponse({error:"仅限图片"},400); const n=Date.now()+"-"+Math.random().toString(36).slice(2,7)+"."+ext; await env.BLOG_R2.put(n, f.stream(), {httpMetadata:{contentType:f.type}}); return jsonResponse({success:true,url:"/media/"+n}); } catch(e){return jsonResponse({error:e.message},500);} } async function handleCleanCache(req, env) { try { const r = await purgeCache(env,req,null); return r.success ? jsonResponse({success:true,message:"CDN 已清空"}) : jsonResponse({error:"失败"},500); } catch(e){return jsonResponse({error:e.message},500);} } async function handleRebuildIndex(env) { try { let cur; const ks=[]; do{const l=await env.BLOG_KV.list({prefix:"post:",cursor:cur});ks.push(...l.keys);cur=l.cursor;}while(cur); const idx=[]; for(const k of ks.filter(k=>k.name!=="post:list_index")){const s=k.name.replace("post:","");const m=k.metadata||{title:s,date:new Date().toISOString().split("T")[0],category:"未分类",summary:"",cover:""};idx.push({slug:s,...m});} idx.sort((a,b)=>new Date(b.date)-new Date(a.date)); await env.BLOG_KV.put("post:list_index",JSON.stringify(idx)); return jsonResponse({success:true}); } catch(e){return jsonResponse({error:e.message},500);} } async function purgeCache(env, req, slug) { if(!env.CF_ZONE_ID||!env.CF_PURGE_TOKEN) return {success:true}; const u=new URL(req.url); if(u.host.endsWith(".workers.dev")) return {success:true}; const o=u.origin,d=u.host; const body=slug?{files:[o+"/",o+"/sitemap.xml",o+"/posts/"+slug]}:{files:[o+"/",o+"/sitemap.xml"],prefixes:[d+"/posts"]}; const c=new AbortController(); const t=setTimeout(()=>c.abort(),5000); try{const r=await fetch("https://api.cloudflare.com/client/v4/zones/"+env.CF_ZONE_ID+"/purge_cache",{method:"POST",headers:{Authorization:"Bearer "+env.CF_PURGE_TOKEN,"Content-Type":"application/json"},body:JSON.stringify(body),signal:c.signal});clearTimeout(t);return await r.json();}catch(e){clearTimeout(t);return{success:false,error:e.message};} } // ═══════════════════════════════════════════ // 共享 CSS 片段 // ═══════════════════════════════════════════ function toastCSS() { return `.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(80px);padding:12px 24px;border-radius:10px;font-size:.88rem;font-weight:500;z-index:9999;background:var(--bg-card);color:var(--text-primary);box-shadow:0 8px 30px rgba(0,0,0,.18);border:1px solid var(--border);opacity:0;transition:all .35s cubic-bezier(.4,0,.2,1);pointer-events:none} .toast.show{opacity:1;transform:translateX(-50%) translateY(0)} .toast.ok{border-left:4px solid #22c55e}.toast.err{border-left:4px solid #ef4444}`; } function toastJS() { return 'function toast(msg,type){var el=document.createElement("div");el.className="toast "+(type||"ok");el.textContent=msg;document.body.appendChild(el);requestAnimationFrame(function(){el.classList.add("show");});setTimeout(function(){el.classList.remove("show");setTimeout(function(){el.remove();},400);},2500);}'; } function trendCSS() { return `@view-transition{navigation:auto} ::view-transition-old(root){animation:vt-out .25s ease both} ::view-transition-new(root){animation:vt-in .35s ease both} @keyframes vt-out{to{opacity:0;transform:scale(.98)}} @keyframes vt-in{from{opacity:0;transform:scale(1.01)}} body::after{content:"";position:fixed;inset:0;z-index:9998;pointer-events:none;opacity:.025;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")} @supports (animation-timeline: scroll()){.reading-progress{animation:pgrow linear both;animation-timeline:scroll(root)}} @keyframes pgrow{from{transform:scaleX(0)}to{transform:scaleX(1)}} @supports (animation-timeline: view()){.post-card{animation:creveal linear both;animation-timeline:view();animation-range:entry 0% entry 40%}} @keyframes creveal{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}} @media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}} :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px} button:active,.btn:active,.page-btn:active,.side-tag:active{transform:scale(.96)!important}`; } // ═══════════════════════════════════════════ // 前台 CSS // ═══════════════════════════════════════════ function baseCSS() { return cssVars() + cssDarkVars() + ` *{margin:0;padding:0;box-sizing:border-box}html{scroll-behavior:smooth} body{font-family:-apple-system,"Noto Sans SC","PingFang SC","Helvetica Neue",sans-serif;background:var(--bg-page);color:var(--text-primary);line-height:1.8;font-size:16px;transition:background .3s,color .3s;display:flex;flex-direction:column;min-height:100vh} a{color:var(--accent);text-decoration:none;transition:color .2s}a:hover{color:var(--accent-hover)}img{max-width:100%;border-radius:4px} .nav{position:sticky;top:0;z-index:100;background:rgba(255,255,255,.82);backdrop-filter:blur(16px) saturate(180%);border-bottom:1px solid var(--border)} [data-theme="dark"] .nav{background:rgba(22,33,62,.82)}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]) .nav{background:rgba(22,33,62,.82)}} .nav-inner{max-width:1100px;margin:0 auto;padding:0 24px;height:60px;display:flex;align-items:center;justify-content:space-between} .nav-logo{font-size:1.25rem;font-weight:800;color:var(--text-primary);letter-spacing:-.5px}.nav-logo:hover{color:var(--accent)} .nav-links{display:flex;gap:20px;align-items:center}.nav-links a{color:var(--text-secondary);font-size:.9rem;font-weight:500}.nav-links a:hover{color:var(--accent)} .nav-toggle{background:none;border:none;font-size:1.2rem;cursor:pointer;color:var(--text-secondary);padding:4px 8px;border-radius:4px}.nav-toggle:hover{background:var(--bg-page)} .layout{max-width:1100px;margin:0 auto;padding:32px 24px;display:flex;gap:40px;flex:1;width:100%} .content{flex:1;min-width:0;max-width:var(--content-width)}.sidebar{width:var(--sidebar-width);flex-shrink:0} @media(max-width:900px){.layout{flex-direction:column-reverse}.sidebar{width:100%}} .side-card{background:var(--bg-card);border-radius:var(--radius);box-shadow:var(--shadow);padding:24px;margin-bottom:20px} .side-avatar{width:80px;height:80px;border-radius:50%;margin:0 auto 12px;display:flex;align-items:center;justify-content:center;font-size:2.5rem;background:var(--bg-page);overflow:hidden}.side-avatar img{width:100%;height:100%;object-fit:cover} .side-name{text-align:center;font-weight:700;font-size:1.1rem;margin-bottom:4px}.side-bio{text-align:center;font-size:.85rem;color:var(--text-muted);margin-bottom:16px} .side-stats{display:flex;justify-content:center;gap:24px;padding:12px 0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);margin-bottom:16px} .side-stat{text-align:center}.side-stat b{display:block;font-size:1.1rem}.side-stat span{font-size:.75rem;color:var(--text-muted)} .side-section-title{font-size:.8rem;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px} .side-cat{display:flex;justify-content:space-between;padding:6px 0;font-size:.9rem;color:var(--text-secondary);border-bottom:1px dashed var(--border)}.side-cat:last-child{border-bottom:none}.side-cat:hover{color:var(--accent)} .side-tags{display:flex;flex-wrap:wrap;gap:6px}.side-tag{font-size:.78rem;padding:3px 10px;border-radius:12px;background:var(--bg-page);color:var(--text-secondary);transition:all .2s}.side-tag:hover{background:var(--accent);color:#fff} .side-social{display:flex;justify-content:center;gap:16px;margin-top:12px}.side-social a{font-size:.85rem;color:var(--text-muted);font-weight:600}.side-social a:hover{color:var(--accent)} .post-card{background:var(--bg-card);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:24px;transition:transform .3s cubic-bezier(.4,0,.2,1),box-shadow .3s;opacity:0;transform:translateY(20px);overflow:hidden} .post-card.visible{opacity:1;transform:translateY(0)}.post-card:hover{transform:translateY(-4px);box-shadow:0 8px 24px rgba(0,0,0,.1)} .post-card-cover{width:100%;height:200px;object-fit:cover;display:block;transition:transform .5s cubic-bezier(.4,0,.2,1)}.post-card:hover .post-card-cover{transform:scale(1.04)} .post-card-body{padding:28px 32px} .post-card-title{font-size:1.5rem;font-weight:700;margin-bottom:8px;line-height:1.4}.post-card-title a{color:var(--text-primary);background:linear-gradient(var(--accent),var(--accent)) no-repeat 0 100%/0 2px;transition:background-size .3s}.post-card-title a:hover{background-size:100% 2px;color:var(--text-primary)} .post-card-meta{display:flex;flex-wrap:wrap;gap:12px;font-size:.82rem;color:var(--text-muted);margin-bottom:12px} .post-card-excerpt{color:var(--text-secondary);font-size:.95rem;line-height:1.8;margin-bottom:16px} .post-card-more{font-size:.88rem;font-weight:600;color:var(--accent)}.post-card-more:hover{color:var(--accent-hover)} .search-info{font-size:.82rem;color:var(--text-muted);margin:-12px 0 16px} .pagination{display:flex;justify-content:center;gap:8px;margin-top:32px} .page-btn{padding:8px 14px;border-radius:6px;font-size:.88rem;color:var(--text-secondary);background:var(--bg-card);border:1px solid var(--border);transition:all .2s} .page-btn:hover{border-color:var(--accent);color:var(--accent)}.page-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)} .post-header{margin-bottom:32px;padding-bottom:24px;border-bottom:1px solid var(--border)} .post-title{font-size:2rem;font-weight:800;line-height:1.3;margin-bottom:12px} .post-meta{display:flex;flex-wrap:wrap;gap:16px;font-size:.85rem;color:var(--text-muted)} .post-body{font-size:1rem;line-height:1.9}.post-body h1{font-size:1.75rem;font-weight:800;margin:2.5rem 0 1rem;padding-bottom:.5rem;border-bottom:1px solid var(--border)} .post-body h2{font-size:1.4rem;font-weight:700;margin:2rem 0 .8rem}.post-body h3{font-size:1.15rem;font-weight:700;margin:1.5rem 0 .6rem} .post-body p{margin-bottom:1.2rem}.post-body ul,.post-body ol{margin:0 0 1.2rem 1.5rem}.post-body li{margin-bottom:.4rem} .post-body blockquote{border-left:4px solid var(--accent);padding:12px 20px;margin:1.5rem 0;background:var(--bg-page);border-radius:0 var(--radius) var(--radius) 0;color:var(--text-secondary)} .post-body pre{border-radius:var(--radius);padding:20px;margin:1.5rem 0;overflow-x:auto;font-size:.88rem;line-height:1.6;position:relative} .post-body code{font-family:"JetBrains Mono","Fira Code",monospace;font-size:.88em} .post-body p code{background:var(--bg-page);padding:2px 6px;border-radius:4px}.post-body pre code{background:none;padding:0} .post-body img{margin:1.5rem 0;box-shadow:var(--shadow)} .post-body table{width:100%;border-collapse:collapse;margin:1.5rem 0}.post-body th,.post-body td{border:1px solid var(--border);padding:10px 14px;text-align:left;font-size:.9rem}.post-body th{background:var(--bg-page);font-weight:600} .post-tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:32px;padding-top:20px;border-top:1px solid var(--border)} .post-tag{font-size:.8rem;padding:4px 12px;border-radius:14px;background:var(--bg-page);color:var(--text-secondary)}.post-tag:hover{background:var(--accent);color:#fff} .post-nav{display:flex;justify-content:space-between;gap:16px;margin-top:32px} .post-nav-item{flex:1;padding:16px;background:var(--bg-card);border-radius:var(--radius);box-shadow:var(--shadow);transition:transform .2s}.post-nav-item:hover{transform:translateY(-2px)} .post-nav-label{font-size:.75rem;color:var(--text-muted);margin-bottom:4px}.post-nav-title{font-size:.9rem;font-weight:600;color:var(--text-primary)} .toc{position:sticky;top:80px}.toc-title{font-size:.8rem;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px} .toc-list{list-style:none;font-size:.85rem}.toc-list li{margin-bottom:6px} .toc-list a{color:var(--text-secondary);display:block;padding:2px 0;border-left:2px solid transparent;padding-left:12px;transition:all .2s} .toc-list a:hover,.toc-list a.active{color:var(--accent);border-left-color:var(--accent)}.toc-list .toc-h3{padding-left:24px} .reading-progress{position:fixed;top:0;left:0;height:3px;width:100%;background:linear-gradient(90deg,var(--accent),var(--accent-hover));z-index:9999;transform:scaleX(0);transform-origin:left} .back-top{position:fixed;bottom:32px;right:32px;width:44px;height:44px;border-radius:50%;background:var(--bg-card);box-shadow:var(--shadow);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:1.1rem;color:var(--text-secondary);opacity:0;transform:translateY(16px);transition:all .3s;z-index:90} .back-top.show{opacity:1;transform:translateY(0)}.back-top:hover{border-color:var(--accent);color:var(--accent);transform:translateY(-2px)} .footer{text-align:center;padding:40px 24px;color:var(--text-muted);font-size:.82rem;border-top:1px solid var(--border);margin-top:auto} .search-box{position:relative;margin-bottom:24px} .search-box input{width:100%;padding:12px 16px 12px 40px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-card);color:var(--text-primary);font-size:.95rem;transition:all .2s} .search-box input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(0,102,204,.1)} .search-box::before{content:"🔍";position:absolute;left:14px;top:50%;transform:translateY(-50%);font-size:.9rem} .copy-code-btn{position:absolute;top:8px;right:8px;padding:4px 8px;font-size:.72rem;background:rgba(255,255,255,.15);color:#fff;border:1px solid rgba(255,255,255,.25);border-radius:4px;cursor:pointer;opacity:0;transition:opacity .2s} pre:hover .copy-code-btn{opacity:1}.copy-code-btn:hover{background:rgba(255,255,255,.3)} .comment-section{margin-top:48px;padding-top:32px;border-top:2px solid var(--border)} .comment-section-title{font-size:1.2rem;font-weight:700;margin-bottom:20px} .comment-form{background:var(--bg-card);border-radius:var(--radius);box-shadow:var(--shadow);padding:24px;margin-bottom:24px} .comment-form-row{display:flex;gap:12px;margin-bottom:12px} .comment-form-row input{flex:1;padding:10px 14px;border:1px solid var(--border);border-radius:6px;font-size:.9rem;background:var(--bg-page);color:var(--text-primary)} .comment-form-row input:focus{outline:none;border-color:var(--accent)} .comment-form textarea{width:100%;padding:12px 14px;border:1px solid var(--border);border-radius:6px;font-size:.9rem;min-height:100px;resize:vertical;background:var(--bg-page);color:var(--text-primary);font-family:inherit} .comment-form textarea:focus{outline:none;border-color:var(--accent)} .comment-count{text-align:right;font-size:.72rem;color:var(--text-muted);margin-top:4px} .comment-form button{margin-top:12px;padding:10px 24px;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:.9rem;font-weight:600;cursor:pointer}.comment-form button:hover{background:var(--accent-hover)}.comment-form button:disabled{opacity:.5;cursor:not-allowed} .comment-item{padding:16px 0;border-bottom:1px solid var(--border)}.comment-item:last-child{border-bottom:none} .comment-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px} .comment-author{font-weight:600;font-size:.9rem}.comment-date{font-size:.75rem;color:var(--text-muted)} .comment-content{font-size:.92rem;color:var(--text-secondary);line-height:1.7;white-space:pre-wrap;word-break:break-word} .comment-empty{text-align:center;padding:32px 0;color:var(--text-muted);font-size:.9rem} .hp-field{position:absolute;left:-9999px;opacity:0;height:0;overflow:hidden} .skeleton{background:linear-gradient(90deg,var(--bg-page) 25%,rgba(0,0,0,.05) 50%,var(--bg-page) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:6px} @keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}} .sk-title{height:32px;width:70%;margin-bottom:16px}.sk-line{height:16px;width:100%;margin-bottom:10px}.sk-short{width:40%} ` + toastCSS() + trendCSS(); } // ═══════════════════════════════════════════ // 前台渲染 // ═══════════════════════════════════════════ function renderNav() { return '<nav class="nav"><div class="nav-inner"><a href="/" class="nav-logo">' + escapeHtml(CONFIG.siteName) + '</a><div class="nav-links"><a href="/">首页</a><a href="/admin">后台</a><button class="nav-toggle" id="themeToggle" aria-label="主题">🌙</button></div></div></nav>'; } function renderFooter() { return '<footer class="footer">' + escapeHtml(CONFIG.footerText) + '</footer>'; } function renderSidebar(posts) { const cats={},tags=new Set(); posts.forEach(p=>{if(p.category)cats[p.category]=(cats[p.category]||0)+1;if(p.tags)p.tags.forEach(t=>tags.add(t));}); const ce=Object.entries(cats).sort((a,b)=>b[1]-a[1]); const ta=[...tags].slice(0,30); const av=CONFIG.authorAvatar?'<img src="'+escapeHtml(CONFIG.authorAvatar)+'" alt="av">':"👤"; const sl=[]; if(CONFIG.social.github)sl.push('<a href="'+escapeHtml(CONFIG.social.github)+'" target="_blank" rel="noopener">GitHub</a>'); if(CONFIG.social.twitter)sl.push('<a href="'+escapeHtml(CONFIG.social.twitter)+'" target="_blank" rel="noopener">Twitter</a>'); if(CONFIG.social.email)sl.push('<a href="mailto:'+escapeHtml(CONFIG.social.email)+'">Email</a>'); return '<aside class="sidebar"><div class="side-card"><div class="side-avatar">'+av+'</div><div class="side-name">'+escapeHtml(CONFIG.authorName)+'</div><div class="side-bio">'+escapeHtml(CONFIG.authorBio)+'</div><div class="side-stats"><div class="side-stat"><b>'+posts.length+'</b><span>文章</span></div><div class="side-stat"><b>'+ce.length+'</b><span>分类</span></div><div class="side-stat"><b>'+tags.size+'</b><span>标签</span></div></div>'+(sl.length?'<div class="side-social">'+sl.join("")+'</div>':"")+'</div>'+(ce.length?'<div class="side-card"><div class="side-section-title">📂 分类</div>'+ce.map(e=>'<a href="/?category='+encodeURIComponent(e[0])+'" class="side-cat"><span>'+escapeHtml(e[0])+'</span><span>'+e[1]+'</span></a>').join("")+'</div>':"")+(ta.length?'<div class="side-card"><div class="side-section-title">🏷️ 标签</div><div class="side-tags">'+ta.map(t=>'<a href="/?tag='+encodeURIComponent(t)+'" class="side-tag">'+escapeHtml(t)+'</a>').join("")+'</div></div>':"")+'</aside>'; } function getWordCount(t){if(!t)return 0;return(t.match(/[\u4e00-\u9fa5]/g)||[]).length+(t.replace(/[\u4e00-\u9fa5]/g,"").match(/[a-zA-Z0-9]+/g)||[]).length;} function getReadingTime(t){return Math.ceil(getWordCount(t)/CONFIG.readingSpeed)+" 分钟";} function darkModeScript(){return 'var toggle=document.getElementById("themeToggle");var stored=localStorage.getItem("theme");if(stored)document.documentElement.setAttribute("data-theme",stored);function ui(){var t=document.documentElement.getAttribute("data-theme");toggle.textContent=(t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme:dark)").matches))?"☀️":"🌙";}ui();toggle.addEventListener("click",function(){var c=document.documentElement.getAttribute("data-theme");var n=(c==="dark"||(c!=="light"&&window.matchMedia("(prefers-color-scheme:dark)").matches))?"light":"dark";document.documentElement.setAttribute("data-theme",n);localStorage.setItem("theme",n);ui();});';} async function handleHome(env, params, nonce) { let posts=[]; try{const r=await safeKVGet(env.BLOG_KV,"post:list_index");posts=r?JSON.parse(r):[];}catch(e){console.error(e.message);} const fc=params.get("category"),ft=params.get("tag"),page=Math.max(1,parseInt(params.get("page"))||1); let filtered=posts; if(fc)filtered=posts.filter(p=>p.category===fc); else if(ft)filtered=posts.filter(p=>p.tags&&p.tags.includes(ft)); const tp=Math.max(1,Math.ceil(filtered.length/CONFIG.postsPerPage)); const dp=filtered.slice((page-1)*CONFIG.postsPerPage,page*CONFIG.postsPerPage); let fb=""; if(fc)fb='<div style="margin-bottom:20px;font-size:.9rem;color:var(--accent);background:var(--bg-card);padding:10px 16px;border-radius:var(--radius);display:inline-block">📁 '+escapeHtml(fc)+' <a href="/" style="margin-left:8px;color:var(--text-muted)">✕</a></div>'; if(ft)fb='<div style="margin-bottom:20px;font-size:.9rem;color:var(--accent);background:var(--bg-card);padding:10px 16px;border-radius:var(--radius);display:inline-block">🏷️ '+escapeHtml(ft)+' <a href="/" style="margin-left:8px;color:var(--text-muted)">✕</a></div>'; const ph=dp.length===0?'<div style="text-align:center;padding:60px 0;color:var(--text-muted)">暂无文章</div>':dp.map(p=>{const ex=(p.summary||"").slice(0,CONFIG.excerptLength);const cv=p.cover?'<img class="post-card-cover" src="'+escapeHtml(p.cover)+'" alt="'+escapeHtml(p.title)+'" loading="lazy">':"";return '<article class="post-card">'+cv+'<div class="post-card-body"><h2 class="post-card-title"><a href="/posts/'+escapeHtml(p.slug)+'">'+escapeHtml(p.title)+'</a></h2><div class="post-card-meta"><span>📅 '+escapeHtml(p.date||"")+'</span>'+(p.category?'<span>📂 '+escapeHtml(p.category)+'</span>':"")+'<span>📖 '+getReadingTime(p.summary||"")+'</span></div><p class="post-card-excerpt">'+escapeHtml(ex)+((p.summary||"").length>CONFIG.excerptLength?"...":"")+'</p><a href="/posts/'+escapeHtml(p.slug)+'" class="post-card-more">阅读全文 →</a></div></article>';}).join(""); let pg=""; if(tp>1){const base=fc?"/?category="+encodeURIComponent(fc)+"&":ft?"/?tag="+encodeURIComponent(ft)+"&":"/?";let b="";if(page>1)b+='<a href="'+base+'page='+(page-1)+'" class="page-btn">←</a>';const s=Math.max(1,page-3),e=Math.min(tp,page+3);if(s>1)b+='<a href="'+base+'page=1" class="page-btn">1</a><span class="page-btn" style="border:none;cursor:default">…</span>';for(let i=s;i<=e;i++)b+=i===page?'<span class="page-btn active">'+i+'</span>':'<a href="'+base+'page='+i+'" class="page-btn">'+i+'</a>';if(e<tp)b+='<span class="page-btn" style="border:none;cursor:default">…</span><a href="'+base+'page='+tp+'" class="page-btn">'+tp+'</a>';if(page<tp)b+='<a href="'+base+'page='+(page+1)+'" class="page-btn">→</a>';pg='<nav class="pagination">'+b+'</nav>';} const homeScript = 'var allPosts='+JSON.stringify(posts).replace(/</g,"\\u003c")+';' +'var si=document.getElementById("searchInput"),pl=document.getElementById("postsList"),sinfo=document.getElementById("searchInfo");' +'function esc(s){if(typeof s!=="string")return"";return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/\'/g,"'");}' +'function hl(s,kw){var e=esc(s);if(!kw)return e;var k=kw.replace(/[.*+?^${}()|[\\]\\\\]/g,"\\\\$&");var re=new RegExp("("+k+")","gi");return e.replace(re,\'<mark style="background:rgba(0,102,204,.18);color:var(--accent);padding:0 2px;border-radius:2px">$1</mark>\');}' +'function rc(l,kw){if(!l.length){pl.innerHTML=\'<div style="text-align:center;padding:60px 0;color:var(--text-muted)">未检索到匹配文章</div>\';sinfo.textContent=kw?"找到 0 篇文章":"";return;}sinfo.textContent=kw?"找到 "+l.length+" 篇文章":"";pl.innerHTML=l.map(function(p){var ex=hl((p.summary||"").slice(0,'+CONFIG.excerptLength+'),kw);var ti=hl(p.title,kw);var cv=p.cover?\'<img class="post-card-cover" src="\'+esc(p.cover)+\'" alt="" loading="lazy">\':"";return \'<article class="post-card visible">\'+cv+\'<div class="post-card-body"><h2 class="post-card-title"><a href="/posts/\'+esc(p.slug)+\'">\'+ti+\'</a></h2><div class="post-card-meta"><span>📅 \'+esc(p.date||"")+\'</span>\'+(p.category?\'<span>📂 \'+esc(p.category)+\'</span>\':"")+\'</div><p class="post-card-excerpt">\'+ex+\'</p><a href="/posts/\'+esc(p.slug)+\'" class="post-card-more">阅读全文 →</a></div></article>\';}).join("");}' +'si.addEventListener("input",function(e){var kw=e.target.value.toLowerCase().trim();if(!kw){rc(allPosts.slice(0,'+CONFIG.postsPerPage+'),"");return;}rc(allPosts.filter(function(p){return(p.title&&p.title.toLowerCase().includes(kw))||(p.summary&&p.summary.toLowerCase().includes(kw));}),kw);});' +'if(!CSS.supports("animation-timeline: view()")){var ob=new IntersectionObserver(function(en){en.forEach(function(x){if(x.isIntersecting){x.target.classList.add("visible");ob.unobserve(x.target);}});},{threshold:.1});document.querySelectorAll(".post-card").forEach(function(el){ob.observe(el);});}' +toastJS()+darkModeScript(); 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>'+escapeHtml(CONFIG.siteName)+' - '+escapeHtml(CONFIG.siteSubtitle)+'</title><meta name="description" content="'+escapeHtml(CONFIG.authorBio)+'"><style>'+baseCSS()+'</style></head><body>'+renderNav()+'<div class="layout"><main class="content"><div class="search-box"><input type="text" id="searchInput" placeholder="搜索文章..."></div><div class="search-info" id="searchInfo"></div>'+fb+'<div id="postsList">'+ph+'</div>'+pg+'</main>'+renderSidebar(posts)+'</div>'+renderFooter()+'<script nonce="'+nonce+'">'+homeScript+'</script></body></html>'; return new Response(html,{headers:{"Content-Type":"text/html; charset=utf-8"}}); } async function handlePostDetail(slug, env, ctx, nonce) { const pc=await safeKVGet(env.BLOG_KV,"post:"+slug); if(!pc) return new Response("Not Found",{status:404}); let posts=[]; try{const r=await safeKVGet(env.BLOG_KV,"post:list_index");posts=r?JSON.parse(r):[];}catch(e){console.error(e.message);} const meta=posts.find(p=>p.slug===slug)||{title:"文章",summary:""}; const idx=posts.findIndex(p=>p.slug===slug); const prev=idx<posts.length-1?posts[idx+1]:null, next=idx>0?posts[idx-1]:null; ctx.waitUntil((async()=>{try{const c=parseInt((await env.BLOG_KV.get("stat:views:"+slug))||"0");await env.BLOG_KV.put("stat:views:"+slug,String(c+1));}catch(e){console.error(e.message);}})()); let views=1; try{views=parseInt((await env.BLOG_KV.get("stat:views:"+slug))||"0")+1;}catch{views=1;} const wc=getWordCount(pc),rt=getReadingTime(pc); const th=(meta.tags&&meta.tags.length)?'<div class="post-tags">'+meta.tags.map(t=>'<a href="/?tag='+encodeURIComponent(t)+'" class="post-tag"># '+escapeHtml(t)+'</a>').join("")+'</div>':""; const nh='<div class="post-nav">'+(prev?'<a href="/posts/'+escapeHtml(prev.slug)+'" class="post-nav-item"><div class="post-nav-label">← 上一篇</div><div class="post-nav-title">'+escapeHtml(prev.title)+'</div></a>':'<span></span>')+(next?'<a href="/posts/'+escapeHtml(next.slug)+'" class="post-nav-item" style="text-align:right"><div class="post-nav-label">下一篇 →</div><div class="post-nav-title">'+escapeHtml(next.title)+'</div></a>':'<span></span>')+'</div>'; const cs='<section class="comment-section"><h2 class="comment-section-title">💬 评论</h2><div class="comment-form"><div class="comment-form-row"><input type="text" id="cName" placeholder="昵称 *" maxlength="'+CONFIG.comments.maxNameLength+'"><input type="text" class="hp-field" id="cHp" tabindex="-1" autocomplete="off"></div><textarea id="cContent" placeholder="写下你的评论..." maxlength="'+CONFIG.comments.maxContentLength+'"></textarea><div class="comment-count"><span id="cCount">0</span>/'+CONFIG.comments.maxContentLength+'</div><button id="cBtn">发表评论</button></div><div id="cList"><div class="comment-empty">加载中...</div></div></section>'; const skeleton='<div class="sk-title skeleton"></div><div class="sk-line skeleton"></div><div class="sk-line skeleton"></div><div class="sk-line skeleton"></div><div class="sk-line skeleton sk-short"></div>'; const detailScript = '(function(){var raw=document.getElementById("raw-md").value,body=document.getElementById("postBody");' +'body.innerHTML=marked.parse(raw);Prism.highlightAllUnder(body);' +'body.querySelectorAll("pre").forEach(function(pre){pre.style.position="relative";var b=document.createElement("button");b.className="copy-code-btn";b.textContent="Copy";b.addEventListener("click",function(){var c=pre.querySelector("code")?pre.querySelector("code").innerText:pre.innerText;navigator.clipboard.writeText(c).then(function(){b.textContent="Copied!";setTimeout(function(){b.textContent="Copy";},2000);});});pre.appendChild(b);});' +'var hs=body.querySelectorAll("h2,h3"),tl=document.getElementById("tocList"),th="";hs.forEach(function(h,i){var id="h-"+i;h.id=id;th+="<li"+(h.tagName==="H3"?" class=\\"toc-h3\\"":"")+"><a href=\\"#"+id+"\\">"+h.textContent+"</a></li>";});tl.innerHTML=th||"<li style=\\"color:var(--text-muted)\\">暂无目录</li>";' +'var tlinks=tl.querySelectorAll("a"),rp=document.getElementById("rp"),bt=document.getElementById("backTop"),sst=CSS.supports("animation-timeline: scroll()");' +'window.addEventListener("scroll",function(){var st=window.scrollY,dh=document.documentElement.scrollHeight-window.innerHeight;if(!sst)rp.style.transform="scaleX("+(dh>0?st/dh:0)+")";bt.classList.toggle("show",st>400);var cur=-1;hs.forEach(function(h,i){if(h.getBoundingClientRect().top<=100)cur=i;});tlinks.forEach(function(a,i){a.classList.toggle("active",i===cur);});});' +'bt.addEventListener("click",function(){window.scrollTo({top:0,behavior:"smooth"});});' +'var slug="'+escapeHtml(slug)+'",cl=document.getElementById("cList");' +'function esc(s){if(typeof s!=="string")return"";return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/\'/g,"'");}' +'function relTime(iso){var diff=Date.now()-new Date(iso).getTime();var m=Math.floor(diff/60000);if(m<1)return"刚刚";if(m<60)return m+" 分钟前";var h=Math.floor(m/60);if(h<24)return h+" 小时前";var d=Math.floor(h/24);if(d<30)return d+" 天前";return iso.slice(0,10);}' +'function rcs(cs){if(!cs.length){cl.innerHTML=\'<div class="comment-empty">💬 暂无评论,来发表第一条吧!</div>\';return;}cl.innerHTML=cs.map(function(c){return \'<div class="comment-item"><div class="comment-header"><span class="comment-author">\'+esc(c.name)+\'</span><span class="comment-date">\'+relTime(c.date)+\'</span></div><div class="comment-content">\'+esc(c.content)+\'</div></div>\';}).join("");}' +'function lc(){fetch("/api/comments?slug="+encodeURIComponent(slug)).then(function(r){return r.json();}).then(function(d){rcs(d.comments||[]);}).catch(function(){cl.innerHTML=\'<div class="comment-empty">加载失败</div>\';});}lc();' +'document.getElementById("cContent").addEventListener("input",function(){var c=document.getElementById("cCount");c.textContent=this.value.length;c.style.color=this.value.length>'+ (CONFIG.comments.maxContentLength-100) +'?"#ef4444":"";});' +'var sb=document.getElementById("cBtn");sb.addEventListener("click",function(){var n=document.getElementById("cName").value.trim(),c=document.getElementById("cContent").value.trim(),hp=document.getElementById("cHp").value;if(!n){toast("请输入昵称","err");return;}if(!c){toast("请输入评论内容","err");return;}sb.disabled=true;sb.textContent="发送中...";fetch("/api/comments",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({slug:slug,name:n,content:c,honeypot:hp})}).then(function(r){return r.json();}).then(function(d){if(d.success){document.getElementById("cContent").value="";document.getElementById("cCount").textContent="0";lc();toast("评论发表成功","ok");}else toast(d.error||"发送失败","err");}).catch(function(){toast("网络错误","err");}).finally(function(){sb.disabled=false;sb.textContent="发表评论";});});' +toastJS()+darkModeScript()+'})();'; 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>'+escapeHtml(meta.title)+' - '+escapeHtml(CONFIG.siteName)+'</title><meta name="description" content="'+escapeHtml(meta.summary||"")+'"><meta property="og:title" content="'+escapeHtml(meta.title)+'"><meta property="og:type" content="article"><link href="'+CONFIG.cdn.prismTheme+'" rel="stylesheet" integrity="'+CONFIG.cdn.prismThemeSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"><style>'+baseCSS()+'@media(max-width:900px){.toc-wrap{display:none}}</style></head><body><div class="reading-progress" id="rp"></div>'+renderNav()+'<div class="layout"><main class="content"><header class="post-header"><h1 class="post-title">'+escapeHtml(meta.title)+'</h1><div class="post-meta"><span>📅 '+escapeHtml(meta.date||"")+'</span>'+(meta.category?'<span>📂 '+escapeHtml(meta.category)+'</span>':"")+'<span>📝 '+wc+' 字</span><span>⏱️ '+rt+'</span><span>👁️ '+views+'</span></div></header><noscript><div class="post-body">'+escapeHtml(pc)+'</div></noscript><article id="postBody" class="post-body">'+skeleton+'</article>'+th+nh+cs+'<textarea id="raw-md" style="display:none">'+escapeHtml(pc)+'</textarea></main><aside class="sidebar toc-wrap"><div class="side-card toc"><div class="toc-title">📑 目录</div><ul class="toc-list" id="tocList"></ul></div></aside></div>'+renderFooter()+'<button class="back-top" id="backTop" aria-label="返回顶部">↑</button><script src="'+CONFIG.cdn.marked+'" integrity="'+CONFIG.cdn.markedSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"></script><script src="'+CONFIG.cdn.prism+'" integrity="'+CONFIG.cdn.prismSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"></script><script src="'+CONFIG.cdn.prismJS+'" integrity="'+CONFIG.cdn.prismJSSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"></script><script nonce="'+nonce+'">'+detailScript+'</script></body></html>'; return new Response(html,{headers:{"Content-Type":"text/html; charset=utf-8"}}); } function renderLoginView(nonce) { const loginScript='document.getElementById("lf").addEventListener("submit",async function(e){e.preventDefault();var r=await fetch("/admin/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:document.getElementById("pw").value})});var d=await r.json();if(r.status===200&&d.success)window.location.href="/admin";else{var el=document.getElementById("em");el.textContent=d.error||"失败";el.style.display="block";}});'; return new Response('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>登录 - '+escapeHtml(CONFIG.siteName)+'</title><style>'+cssVars()+cssDarkVars()+'*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,"Noto Sans SC",sans-serif;background:var(--bg-page);display:flex;align-items:center;justify-content:center;height:100vh}.card{background:var(--bg-card);padding:2.5rem;border-radius:var(--radius);box-shadow:var(--shadow);width:100%;max-width:360px}h1{font-size:1.4rem;font-weight:800;text-align:center;margin-bottom:1.5rem}label{display:block;font-size:.85rem;color:var(--text-secondary);margin-bottom:.3rem}input{width:100%;padding:.6rem .8rem;border:1px solid var(--border);border-radius:6px;font-size:1rem;background:var(--bg-page);color:var(--text-primary);margin-bottom:1rem}input:focus{outline:none;border-color:var(--accent)}button{width:100%;background:var(--accent);color:#fff;padding:.7rem;border:none;border-radius:6px;font-size:1rem;font-weight:600;cursor:pointer}button:hover{background:var(--accent-hover)}.error{color:#dc2626;font-size:.85rem;text-align:center;margin-top:1rem;display:none}</style></head><body><div class="card"><h1>⚡ '+escapeHtml(CONFIG.siteName)+'</h1><form id="lf"><label>管理员密码</label><input type="password" id="pw" required><button type="submit">确认登录</button></form><p id="em" class="error"></p></div><script nonce="'+nonce+'">'+loginScript+'</script></body></html>',{headers:{"Content-Type":"text/html; charset=utf-8"}}); } // ═══════════════════════════════════════════ // 后台面板 (经典黑白 + 独立日/夜切换) // ═══════════════════════════════════════════ function adminCSS() { return cssVars() + cssDarkVars() + ` *{margin:0;padding:0;box-sizing:border-box} body{font-family:-apple-system,"Noto Sans SC",sans-serif;background:var(--bg-page);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;padding:16px;gap:16px;transition:background .3s,color .3s} a{color:var(--accent);text-decoration:none} .side{width:270px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);display:flex;flex-direction:column;height:calc(100vh - 32px);transition:background .3s,border-color .3s} .side-hd{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center} .side-hd h1{font-size:1.05rem;font-weight:800} .side-hd .acts{display:flex;gap:8px;align-items:center} .side-hd .acts a{font-size:.72rem;color:#dc2626} .theme-btn{background:var(--bg-page);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-size:.8rem;cursor:pointer;color:var(--text-secondary);font-weight:600;transition:all .2s;display:inline-flex;align-items:center;gap:6px} .theme-btn:hover{border-color:var(--accent);color:var(--accent)} .side-btns{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:8px} .btn{padding:9px 14px;border:none;border-radius:8px;font-size:.8rem;font-weight:600;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;justify-content:center;gap:5px} .btn-p{background:var(--accent);color:#fff}.btn-p:hover{background:var(--accent-hover)} .btn-p:disabled{opacity:.6;cursor:not-allowed} .btn-g{background:var(--bg-page);color:var(--text-secondary);border:1px solid var(--border)}.btn-g:hover{border-color:var(--accent);color:var(--accent)} .btn-d{background:transparent;color:#dc2626;border:1px solid #fecaca}.btn-d:hover{background:rgba(220,38,38,.08)} [data-theme="dark"] .btn-d{border-color:rgba(220,38,38,.35)} .btn-row{display:flex;gap:8px}.btn-row .btn{flex:1} .pfilter{width:100%;padding:8px 12px;margin:12px 16px 0;border:1px solid var(--border);border-radius:8px;font-size:.8rem;background:var(--bg-page);color:var(--text-primary);width:calc(100% - 32px)} .pfilter:focus{outline:none;border-color:var(--accent)} .slist{flex:1;overflow-y:auto;padding:12px 16px} .di{padding:11px 13px;border-radius:8px;cursor:pointer;margin-bottom:6px;border:1px solid transparent;transition:all .2s;background:var(--bg-page)} .di:hover{border-color:var(--border);transform:translateX(2px)} .di.on{background:var(--accent);color:#fff;border-color:var(--accent)} .di h3{font-size:.82rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .di span{font-size:.7rem;color:var(--text-muted);margin-top:3px;display:block} .di.on span{color:rgba(255,255,255,.7)} .main{flex:1;display:flex;flex-direction:column;height:calc(100vh - 32px);background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;transition:background .3s,border-color .3s} .tb{padding:10px 20px;border-bottom:1px solid var(--border);background:var(--bg-page);display:flex;gap:4px;flex-wrap:wrap;align-items:center} .tb button{padding:5px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-card);color:var(--text-secondary);font-size:.78rem;cursor:pointer;font-weight:600;transition:all .15s} .tb button:hover{border-color:var(--accent);color:var(--accent)} .tb .sep{width:1px;height:18px;background:var(--border);margin:0 6px} .tb .vm.on{background:var(--accent);color:#fff;border-color:var(--accent)} .meta{padding:12px 20px;border-bottom:1px solid var(--border);display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px} .meta label{display:block;font-size:.7rem;color:var(--text-muted);margin-bottom:3px;font-weight:600} .meta input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:.83rem;background:var(--bg-page);color:var(--text-primary);transition:all .2s} .meta input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(0,102,204,.1)} .mfull{padding:8px 20px;border-bottom:1px solid var(--border)} .mfull label{display:block;font-size:.7rem;color:var(--text-muted);margin-bottom:3px;font-weight:600} .mfull input,.mfull textarea{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:.83rem;background:var(--bg-page);color:var(--text-primary);margin-top:3px;transition:all .2s} .mfull input:focus,.mfull textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(0,102,204,.1)} .mfull textarea{resize:vertical;min-height:44px} .crow{display:flex;gap:8px;align-items:center}.crow input{flex:1} .cpv{max-height:70px;border-radius:6px;margin-top:8px;display:none;box-shadow:var(--shadow)} .panes{flex:1;display:flex;overflow:hidden} .pane{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0} .pane:first-child{border-right:1px solid var(--border)} .ph{padding:8px 16px;font-size:.7rem;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid var(--border);background:var(--bg-page)} #md{flex:1;width:100%;padding:20px;border:none;resize:none;font-family:"JetBrains Mono","Fira Code",monospace;font-size:.88rem;line-height:1.75;background:var(--bg-card);color:var(--text-primary)} #md:focus{outline:none} #pv{flex:1;overflow-y:auto;padding:20px;background:var(--bg-card)} #pv h1{font-size:1.5rem;font-weight:800;margin:1.4rem 0 .7rem;border-bottom:1px solid var(--border);padding-bottom:.3rem} #pv h2{font-size:1.25rem;font-weight:700;margin:1.1rem 0 .5rem} #pv h3{font-size:1.05rem;font-weight:700;margin:.9rem 0 .4rem} #pv p{margin-bottom:.9rem;line-height:1.8} #pv ul,#pv ol{margin:0 0 .9rem 1.4rem} #pv blockquote{border-left:4px solid var(--accent);padding:8px 14px;margin:.9rem 0;background:var(--bg-page);border-radius:0 6px 6px 0} #pv pre{border-radius:8px;padding:14px;margin:.9rem 0;overflow-x:auto;font-size:.83rem;position:relative} #pv code{font-family:"JetBrains Mono","Fira Code",monospace;font-size:.85em} #pv p code{background:var(--bg-page);padding:2px 5px;border-radius:3px} #pv pre code{background:none;padding:0} #pv img{max-width:100%;border-radius:6px} #pv table{width:100%;border-collapse:collapse;margin:.9rem 0} #pv th,#pv td{border:1px solid var(--border);padding:7px 11px;font-size:.85rem} #pv th{background:var(--bg-page)} .ccb{position:absolute;top:6px;right:6px;padding:3px 7px;font-size:.68rem;background:rgba(255,255,255,.15);color:#fff;border:1px solid rgba(255,255,255,.25);border-radius:4px;cursor:pointer;opacity:0;transition:opacity .2s} #pv pre:hover .ccb{opacity:1} .status{padding:7px 20px;border-top:1px solid var(--border);background:var(--bg-page);display:flex;justify-content:space-between;font-size:.72rem;color:var(--text-muted)} .ft{padding:12px 20px;border-top:1px solid var(--border);background:var(--bg-card);display:flex;justify-content:space-between;align-items:center} body.mode-edit .pane:last-child{display:none} body.mode-edit .pane:first-child{border-right:none} body.mode-preview .pane:first-child{display:none} body.fullscreen{padding:0} body.fullscreen .side{display:none} body.fullscreen .meta,body.fullscreen .mfull{display:none} body.fullscreen .main{height:100vh;border-radius:0;border:none} @media(max-width:768px){.side{width:200px}.panes{flex-direction:column}.pane:first-child{border-right:none;border-bottom:1px solid var(--border)}.meta{grid-template-columns:1fr 1fr}} ` + toastCSS() + trendCSS(); } async function renderDashboardView(env, nonce) { let posts=[]; try{const r=await safeKVGet(env.BLOG_KV,"post:list_index");posts=r?JSON.parse(r):[];}catch(e){console.error(e.message);} const si=posts.map(p=>'<div class="di" data-slug="'+escapeHtml(p.slug)+'"><h3>'+escapeHtml(p.title)+'</h3><span>📅 '+escapeHtml(p.date||"")+'</span></div>').join(""); const script = 'var posts='+JSON.stringify(posts).replace(/</g,"\\u003c")+';' +'var cur="",editor=document.getElementById("md"),preview=document.getElementById("pv"),pt=null;' // --- 后台独立日/夜切换 (admin-theme) --- +'var at=document.getElementById("adminTheme");var as=localStorage.getItem("admin-theme");if(as)document.documentElement.setAttribute("data-theme",as);' +'function aui(){var dark=document.documentElement.getAttribute("data-theme")==="dark"||(document.documentElement.getAttribute("data-theme")!=="light"&&window.matchMedia("(prefers-color-scheme:dark)").matches);at.textContent=dark?"☀️ 日间模式":"🌙 夜间模式";}' +'aui();' +'at.addEventListener("click",function(){var dark=document.documentElement.getAttribute("data-theme")==="dark"||(document.documentElement.getAttribute("data-theme")!=="light"&&window.matchMedia("(prefers-color-scheme:dark)").matches);var n=dark?"light":"dark";document.documentElement.setAttribute("data-theme",n);localStorage.setItem("admin-theme",n);aui();});' // --- 预览引擎 --- +'function acb(c){c.querySelectorAll("pre").forEach(function(pre){if(pre.querySelector(".ccb"))return;pre.style.position="relative";var b=document.createElement("button");b.className="ccb";b.textContent="Copy";b.addEventListener("click",function(){var code=pre.querySelector("code")?pre.querySelector("code").innerText:pre.innerText;navigator.clipboard.writeText(code).then(function(){b.textContent="✓";setTimeout(function(){b.textContent="Copy";},1500);});});pre.appendChild(b);});}' +'function up(){preview.innerHTML=marked.parse(editor.value||"*输入 Markdown 开始预览*");Prism.highlightAllUnder(preview);acb(preview);us();}' +'editor.addEventListener("input",function(){clearTimeout(pt);pt=setTimeout(up,120);saveDraft();});' +'editor.addEventListener("scroll",function(){var r=editor.scrollTop/(editor.scrollHeight-editor.clientHeight||1);preview.scrollTop=r*(preview.scrollHeight-preview.clientHeight);});' +'editor.addEventListener("keyup",uc);editor.addEventListener("click",uc);' +'function us(){var t=editor.value;var cn=(t.match(/[\\u4e00-\\u9fa5]/g)||[]).length;var en=(t.replace(/[\\u4e00-\\u9fa5]/g,"").match(/[a-zA-Z0-9]+/g)||[]).length;var ln=t.split("\\n").length;var rd=Math.ceil((cn+en)/300);document.getElementById("sL").textContent="字数: "+(cn+en)+" | 行数: "+ln+" | 约 "+rd+" 分钟";}' +'function uc(){var p=editor.selectionStart;var t=editor.value.slice(0,p);var ln=t.split("\\n").length;var col=p-t.lastIndexOf("\\n");document.getElementById("sR").textContent="Ln "+ln+", Col "+col;}' +'function ins(pre,suf,ph){var s=editor.selectionStart,e=editor.selectionEnd;var sel=editor.value.slice(s,e)||ph;editor.value=editor.value.slice(0,s)+pre+sel+suf+editor.value.slice(e);editor.focus();editor.selectionStart=s+pre.length;editor.selectionEnd=s+pre.length+sel.length;editor.dispatchEvent(new Event("input"));}' +'var acts={bold:function(){ins("**","**","粗体");},italic:function(){ins("*","*","斜体");},strike:function(){ins("~~","~~","删除线");},h2:function(){ins("\\n## ","","标题");},h3:function(){ins("\\n### ","","标题");},link:function(){ins("[","](https://)","文本");},img:function(){ins("","描述");},code:function(){ins("\\n```javascript\\n","\\n```\\n","// code");},inline:function(){ins("`","`","code");},quote:function(){ins("\\n> ","","引用");},ul:function(){ins("\\n- ","","列表");},ol:function(){ins("\\n1. ","","列表");},task:function(){ins("\\n- [ ] ","","待办");},hr:function(){ins("\\n---\\n","","");},table:function(){ins("\\n| A | B | C |\\n|---|---|---|\\n| 1 | 2 | 3 |\\n","","");}};' +'document.getElementById("tb").addEventListener("click",function(e){var b=e.target.closest("[data-a]");if(b&&acts[b.dataset.a])acts[b.dataset.a]();});' +'editor.addEventListener("keydown",function(e){if(e.key==="Tab"){e.preventDefault();var s=this.selectionStart;this.value=this.value.slice(0,s)+" "+this.value.slice(this.selectionEnd);this.selectionStart=this.selectionEnd=s+2;this.dispatchEvent(new Event("input"));return;}if(e.ctrlKey||e.metaKey){if(e.key==="b"){e.preventDefault();acts.bold();}else if(e.key==="i"){e.preventDefault();acts.italic();}else if(e.key==="k"&&!e.shiftKey){e.preventDefault();acts.link();}else if(e.key==="k"&&e.shiftKey){e.preventDefault();acts.code();}else if(e.key==="s"){e.preventDefault();doSave();}}});' +'function setMode(m){document.body.className=m;document.querySelectorAll(".vm").forEach(function(b){b.classList.toggle("on",b.dataset.m===m);});}' +'document.querySelectorAll(".vm").forEach(function(b){b.addEventListener("click",function(){setMode(b.dataset.m);});});' // --- 自动草稿 --- +'var draftTimer=null;function saveDraft(){clearTimeout(draftTimer);draftTimer=setTimeout(function(){if(!cur){localStorage.setItem("hbb-draft",JSON.stringify({t:document.getElementById("f-title").value,s:document.getElementById("f-slug").value,c:document.getElementById("f-cat").value,d:document.getElementById("f-date").value,tg:document.getElementById("f-tags").value,sm:document.getElementById("f-summary").value,cv:document.getElementById("f-cover").value,md:editor.value}));document.getElementById("sR").textContent="草稿已保存";}},1500);}' +'function loadDraft(){var d=localStorage.getItem("hbb-draft");if(!d)return;try{var o=JSON.parse(d);if(o.md&&!cur){document.getElementById("f-title").value=o.t||"";document.getElementById("f-slug").value=o.s||"";document.getElementById("f-cat").value=o.c||"";document.getElementById("f-date").value=o.d||"";document.getElementById("f-tags").value=o.tg||"";document.getElementById("f-summary").value=o.sm||"";document.getElementById("f-cover").value=o.cv||"";editor.value=o.md;up();}}catch(e){}}' // --- 图片上传 (带进度反馈) --- +'async function uploadFile(file){var bmp=await createImageBitmap(file);var cv=document.createElement("canvas");var sc=Math.min(1,1920/bmp.width);cv.width=bmp.width*sc;cv.height=bmp.height*sc;cv.getContext("2d").drawImage(bmp,0,0,cv.width,cv.height);var blob=await new Promise(function(r){cv.toBlob(r,"image/webp",0.85);});var cf=new File([blob],file.name.replace(/\\.\\w+$/,".webp"),{type:"image/webp"});var fd=new FormData();fd.append("file",cf);var res=await fetch("/admin/upload",{method:"POST",body:fd});return await res.json();}' +'async function uploadWithFeedback(file){var marker="up-"+Date.now();ins("","图片");try{var d=await uploadFile(file);if(d.success){editor.value=editor.value.replace("/media/"+marker,d.url);editor.dispatchEvent(new Event("input"));toast("图片已上传","ok");}else{editor.value=editor.value.replace("","");editor.dispatchEvent(new Event("input"));toast(d.error||"上传失败","err");}}catch(err){editor.value=editor.value.replace("","");editor.dispatchEvent(new Event("input"));toast("上传失败","err");}}' +'document.getElementById("btnUp").addEventListener("click",function(){document.getElementById("fileIn").click();});' +'document.getElementById("fileIn").addEventListener("change",async function(e){var f=e.target.files[0];if(f)await uploadWithFeedback(f);e.target.value="";});' +'editor.addEventListener("dragover",function(e){e.preventDefault();editor.style.borderColor="var(--accent)";});' +'editor.addEventListener("dragleave",function(){editor.style.borderColor="";});' +'editor.addEventListener("drop",async function(e){e.preventDefault();editor.style.borderColor="";var f=e.dataTransfer.files[0];if(f&&f.type.startsWith("image/"))await uploadWithFeedback(f);});' // --- 封面 --- +'document.getElementById("btnCov").addEventListener("click",function(){document.getElementById("covIn").click();});' +'document.getElementById("covIn").addEventListener("change",async function(e){var f=e.target.files[0];if(!f)return;try{var d=await uploadFile(f);if(d.success){document.getElementById("f-cover").value=d.url;var p=document.getElementById("covPv");p.src=d.url;p.style.display="block";toast("封面已上传","ok");}else toast(d.error||"上传失败","err");}catch(err){toast("上传失败","err");}e.target.value="";});' +'document.getElementById("f-cover").addEventListener("input",function(){var p=document.getElementById("covPv");if(this.value){p.src=this.value;p.style.display="block";}else p.style.display="none";});' // --- 文章列表过滤 --- +'document.getElementById("postFilter").addEventListener("input",function(e){var kw=e.target.value.toLowerCase();document.querySelectorAll(".di").forEach(function(el){el.style.display=el.textContent.toLowerCase().indexOf(kw)>-1?"":"none";});});' // --- CRUD --- +'function newPost(){cur="";document.getElementById("f-title").value="";document.getElementById("f-slug").value="";document.getElementById("f-slug").disabled=false;document.getElementById("f-cat").value="";document.getElementById("f-tags").value="";document.getElementById("f-date").value=new Date().toISOString().split("T")[0];document.getElementById("f-summary").value="";document.getElementById("f-cover").value="";document.getElementById("covPv").style.display="none";editor.value="";document.getElementById("delBtn").style.display="none";document.querySelectorAll(".di").forEach(function(el){el.classList.remove("on");});localStorage.removeItem("hbb-draft");up();}' +'async function editPost(slug){var res=await fetch("/admin/get-post?slug="+encodeURIComponent(slug));if(res.status!==200){toast("获取文章失败","err");return;}var meta=posts.find(function(p){return p.slug===slug;});var data=await res.json();cur=slug;document.getElementById("f-title").value=(meta&&meta.title)||"";document.getElementById("f-slug").value=slug;document.getElementById("f-slug").disabled=true;document.getElementById("f-cat").value=(meta&&meta.category)||"";document.getElementById("f-tags").value=((meta&&meta.tags)||[]).join(",");document.getElementById("f-date").value=(meta&&meta.date)||"";document.getElementById("f-summary").value=(meta&&meta.summary)||"";document.getElementById("f-cover").value=(meta&&meta.cover)||"";var p=document.getElementById("covPv");if(meta&&meta.cover){p.src=meta.cover;p.style.display="block";}else p.style.display="none";editor.value=data.markdown||"";document.getElementById("delBtn").style.display="inline-flex";document.querySelectorAll(".di").forEach(function(el){el.classList.toggle("on",el.dataset.slug===slug);});up();}' // --- 保存 (状态机 + Toast) --- +'async function doSave(){var sb=document.getElementById("saveBtn");var sv=document.getElementById("f-slug").value;if(!/^[a-z0-9-]+$/.test(sv)){toast("Slug 仅限 a-z0-9-","err");return;}var ti=document.getElementById("f-title").value;if(!ti){toast("标题必填","err");return;}if(!editor.value){toast("正文必填","err");return;}sb.disabled=true;sb.textContent="⏳ 保存中...";var payload={title:ti,slug:sv,category:document.getElementById("f-cat").value,tags:document.getElementById("f-tags").value.split(",").map(function(t){return t.trim();}).filter(Boolean),date:document.getElementById("f-date").value,summary:document.getElementById("f-summary").value,markdown:editor.value,cover:document.getElementById("f-cover").value.trim()};try{var res=await fetch("/admin/save-post",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});var d=await res.json();if(d.success){localStorage.removeItem("hbb-draft");sb.textContent="✅ 已保存";toast("保存成功,CDN 已刷新","ok");setTimeout(function(){window.location.reload();},800);}else{sb.textContent="💾 保存 (Ctrl+S)";sb.disabled=false;toast(d.error||"保存失败","err");}}catch(e){sb.textContent="💾 保存 (Ctrl+S)";sb.disabled=false;toast("网络错误","err");}}' +'document.getElementById("btnNew").addEventListener("click",newPost);' +'document.getElementById("sideList").addEventListener("click",function(e){var it=e.target.closest(".di");if(it&&it.dataset.slug)editPost(it.dataset.slug);});' +'document.getElementById("saveBtn").addEventListener("click",doSave);' +'document.getElementById("delBtn").addEventListener("click",async function(){if(!cur||!confirm("确定删除?不可撤销。"))return;var res=await fetch("/admin/delete-post",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({slug:cur})});var d=await res.json();if(d.success){toast("已删除","ok");setTimeout(function(){window.location.reload();},600);}else toast(d.error||"删除失败","err");});' +'document.getElementById("btnCC").addEventListener("click",async function(){if(!confirm("清空 CDN 缓存?"))return;var r=await fetch("/admin/clean-cache",{method:"POST"});var d=await r.json();toast(d.message||d.error||"完成",d.success?"ok":"err");});' +'document.getElementById("btnRI").addEventListener("click",async function(){if(!confirm("重建索引?"))return;var r=await fetch("/admin/rebuild-index",{method:"POST"});var d=await r.json();if(d.success){toast("索引重建完成","ok");setTimeout(function(){window.location.reload();},600);}else toast(d.error||"失败","err");});' +toastJS() +'loadDraft();up();'; const html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>控制台 - '+escapeHtml(CONFIG.siteName)+'</title>' +'<link href="'+CONFIG.cdn.prismTheme+'" rel="stylesheet" integrity="'+CONFIG.cdn.prismThemeSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer">' +'<style>'+adminCSS()+'</style></head><body class="mode-split">' +'<aside class="side"><div class="side-hd"><h1>⚡ 控制台</h1><div class="acts"><button class="theme-btn" id="adminTheme">🌙 夜间模式</button><a href="/admin/logout">登出</a></div></div>' +'<div class="side-btns"><button class="btn btn-p" id="btnNew">📝 写新文章</button><div class="btn-row"><button class="btn btn-g" id="btnCC">🧹 CDN</button><button class="btn btn-g" id="btnRI">🔄 索引</button></div></div>' +'<input type="text" id="postFilter" class="pfilter" placeholder="🔍 过滤文章...">' +'<div class="slist" id="sideList">'+si+'</div></aside>' +'<main class="main">' +'<div class="tb" id="tb">' +'<button data-a="bold" title="Ctrl+B">B</button><button data-a="italic" title="Ctrl+I"><i>I</i></button><button data-a="strike" title="删除线"><s>S</s></button>' +'<span class="sep"></span>' +'<button data-a="h2">H2</button><button data-a="h3">H3</button>' +'<span class="sep"></span>' +'<button data-a="link" title="Ctrl+K">🔗</button><button data-a="img">🖼️</button><button data-a="code" title="Ctrl+Shift+K">⌨️</button><button data-a="inline">`</button><button data-a="quote">❝</button><button data-a="ul">•</button><button data-a="ol">1.</button><button data-a="task">☑</button><button data-a="hr">—</button><button data-a="table">▦</button>' +'<span class="sep"></span>' +'<button id="btnUp" title="上传图片">📤</button><input type="file" id="fileIn" accept="image/*" style="display:none">' +'<span class="sep"></span>' +'<button class="vm" data-m="mode-edit" title="纯编辑">✏️</button><button class="vm on" data-m="mode-split" title="分栏">📖</button><button class="vm" data-m="mode-preview" title="纯预览">👁️</button>' +'</div>' +'<div class="meta"><div><label>标题 *</label><input id="f-title"></div><div><label>Slug *</label><input id="f-slug"></div><div><label>分类</label><input id="f-cat"></div><div><label>日期</label><input type="date" id="f-date"></div></div>' +'<div class="mfull"><label>标签(逗号分隔)</label><input id="f-tags"></div>' +'<div class="mfull"><label>摘要</label><textarea id="f-summary" rows="2"></textarea></div>' +'<div class="mfull"><label>封面图</label><div class="crow"><input id="f-cover" placeholder="/media/xxx.webp"><button class="btn btn-g" id="btnCov" type="button">📷</button><input type="file" id="covIn" accept="image/*" style="display:none"></div><img id="covPv" class="cpv" alt=""></div>' +'<div class="panes"><div class="pane"><div class="ph">✏️ Markdown <span style="float:right;font-weight:400;text-transform:none">拖拽图片可直接上传</span></div><textarea id="md" placeholder="输入 Markdown...(Ctrl+S 保存)"></textarea></div><div class="pane"><div class="ph">👁️ 预览</div><div id="pv"></div></div></div>' +'<div class="status"><span id="sL">字数: 0 | 行数: 0</span><span id="sR">Ln 1, Col 1</span></div>' +'<div class="ft"><button class="btn btn-d" id="delBtn" style="display:none">❌ 删除</button><span style="flex:1"></span><button class="btn btn-p" id="saveBtn" style="padding:10px 28px">💾 保存 (Ctrl+S)</button></div>' +'</main>' +'<script src="'+CONFIG.cdn.marked+'" integrity="'+CONFIG.cdn.markedSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"></script>' +'<script src="'+CONFIG.cdn.prism+'" integrity="'+CONFIG.cdn.prismSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"></script>' +'<script src="'+CONFIG.cdn.prismJS+'" integrity="'+CONFIG.cdn.prismJSSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"></script>' +'<script nonce="'+nonce+'">'+script+'</script></body></html>'; return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } ```
💬 评论
0
/1000
发表评论
加载中...
在独立博客的选择上,我们往往需要在“动态博客(如 WordPress)的维护成本与加载速度”以及“静态博客(如 Hexo)的本地编译繁琐性”之间做出妥协。 为了寻找更优解,我基于 Cloudflare 边缘计算平台,整理并部署了一套**零服务器成本、免日常运维、且前后台体验良好的独立博客系统 (HBB BLOG)**。 本文将分享这套博客的设计特点,并提供完整的搭建指南,帮助你快速部署属于自己的边缘博客。 --- ## 🛠️ 系统架构与设计特点 本博客系统不依赖传统的虚拟主机或 VPS,而是完全运行在 Cloudflare 的 Serverless 架构上: 1. **计算层 (Cloudflare Workers)**:利用边缘节点进行页面动态渲染与路由分发,实现毫秒级的响应速度。 2. **数据层 (Cloudflare KV)**:将文章元数据索引、正文内容、评论数据等存储在键值数据库中,告别传统数据库的冷启动与维护烦恼。 3. **多媒体 (Cloudflare R2)**:兼容 S3 协议的对象存储,用于免费托管文章插图与封面,省去昂贵的对象存储流量费。 ### 主要功能特性: * **前台阅读体验**:经典的 NexT Pisces 响应式布局,支持亮色/暗色模式、自动生成滚动高亮文章目录(TOC)、顶部阅读进度条、代码块一键复制(Copy)功能。 * **磨砂玻璃后台**:半透明毛玻璃质感(Glassmorphism)的管理面板,支持双栏 Markdown 实时预览、富文本插入工具栏、全屏编辑及封面图一键上传。 * **安全防护防护**:内置严格的 CSP 策略(基于 Nonce 的脚本沙箱)、PBKDF2 强加密密码学鉴权、防 XSS/CSRF。 * **防垃圾评论系统**:自带 Honeypot(蜜罐)静默过滤机器人垃圾评论,支持 IP 速率限流与管理员后台直接删除评论。 --- ## 🚀 零基础部署指南 由于本系统为**单文件、零构建**设计,你只需要一个 Cloudflare 账号,5分钟内即可完成部署。 ### 第一步:创建资源绑定 1. 登录 [Cloudflare 控制台](https://dash.cloudflare.com/)。 2. **创建 KV 命名空间**: * 进入“存储与数据库” -> “KV”,创建一个名为 `BLOG_KV` 的命名空间。 3. **创建 R2 存储桶**: * 进入“存储与数据库” -> “R2”,创建一个名为 `BLOG_R2` 的存储桶(Bucket)。 ### 第二步:生成密码哈希 本博客不存储明文密码。请在任意浏览器中按 `F12` 打开控制台(Console),粘贴并运行以下代码。输入你想设置的管理员密码与一段自定义的密钥: ```javascript async function genHash(password, jwtSecret) { const encoder = new TextEncoder(); const baseKey = await crypto.subtle.importKey( "raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"] ); const derivedBits = await crypto.subtle.deriveBits( { name: "PBKDF2", salt: encoder.encode(jwtSecret), iterations: 100000, hash: "SHA-256" }, baseKey, 256 ); return Array.from(new Uint8Array(derivedBits)) .map(b => b.toString(16).padStart(2, '0')).join(''); } // 请在此处修改你的“密码”与“JWT密钥” genHash("你的管理员密码", "你的JWT_SECRET").then(console.log); ``` 运行后,控制台会输出一个 **64位的十六进制字符串**。请记录该字符串以及你的 `JWT_SECRET`。 ### 第三步:新建并配置 Worker 1. 在 Cloudflare 菜单中点击“Workers 和 Pages” -> “创建 Worker”。 2. 填入名称(例如 `hbb-blog`)并部署。 3. 点击“编辑代码”,将博客源码完全覆盖写入并保存(先不忙部署,需要绑定变量)。 4. 返回该 Worker 的设置页面,进入“设置” -> “绑定”: * **KV 命名空间绑定**:添加绑定,变量名称填 `BLOG_KV`,选择第一步创建的 KV。 * **R2 存储桶绑定**:添加绑定,变量名称填 `BLOG_R2`,选择第一步创建的 R2。 5. 进入“设置” -> “变量与机密”,添加以下两个环境变量: * `ADMIN_PASSWORD_HASH`:填入第二步生成的64位哈希字符串。 * `JWT_SECRET`:填入第二步你自定义的 `JWT_SECRET` 原文。 6. 返回代码编辑页面,点击右上角的 **Quick Deploy(部署)**。 ### 第四步:初始化站点 1. 访问 `https://你的Worker域名/admin`,系统会自动重定向至登录页面。 2. 输入你在第二步设置的密码。 3. 登录成功后,点击右上角的 **“🔄 索引”** 按钮完成系统初始化(该操作会扫描并建立 KV 索引,重建空库)。 4. 点击 **“📝 写新文章”** 开始你的第一篇写作! --- ## 💬 交流与互动 博客搭建完成后,你可以在下方评论区留个言,顺便测试本站的限流和蜜罐反垃圾评论系统是否工作正常。 如果你在部署过程中遇到任何疑问,或者希望对这套架构进行定制与交流,欢迎加入我的 Telegram 群组一起探讨! * **作者:** HBB * **Telegram 交流群:** [https://t.me/hejiuchigua](https://t.me/hejiuchigua) --- ## 源码 ```javascript /** * Workers-KV-R2 Blog Engine — NexT Style Elite (Classic Edition) * Single-file, Zero-build, Pure JS · Cloudflare Workers * Deploy: compatibility_date = "2024-09-01" */ // ═══════════════════════════════════════════ // 可手动设置区域 // ═══════════════════════════════════════════ const CONFIG = { siteName: "HBB BLOG", siteSubtitle: "Powered by Cloudflare Workers", authorName: "HBB", authorAvatar: "", authorBio: "1000篇免维护Blog系统", footerText: "© 2026 HBB · Powered by Cloudflare Workers", social: { github: "", twitter: "", email: "blog@hbb.cloudns.org" }, postsPerPage: 8, readingSpeed: 300, excerptLength: 200, comments: { maxPerPost: 200, rateLimitPerIP: 3, rateLimitTTL: 60, maxNameLength: 30, maxContentLength: 1000 }, theme: { accent: "#0066cc", accentHover: "#0052a3", bgPage: "#f5f7f9", bgCard: "#ffffff", textPrimary: "#222222", textSecondary: "#666666", textMuted: "#999999", border: "#e8e8e8", shadow: "0 2px 12px rgba(0,0,0,0.06)", radius: "8px", contentWidth: "760px", sidebarWidth: "280px", }, darkTheme: { accent: "#4da6ff", accentHover: "#80bfff", bgPage: "#1a1a2e", bgCard: "#16213e", textPrimary: "#e0e0e0", textSecondary: "#a0a0a0", textMuted: "#707070", border: "#2a2a4a", shadow: "0 2px 12px rgba(0,0,0,0.3)", }, cdn: { marked: "https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js", markedSRI: "sha512-zAs8dHhwlTbfcVGRX1x0EZAH/L99NjAFzX6muwOcOJc7dbGFNaW4O7b9QOyCMRYBNjO+E0Kx6yLDsiPQhhWm7g==", prism: "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js", prismSRI: "sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==", prismTheme: "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css", prismThemeSRI: "sha512-vswe+cgvic/XBoF1OcM/TeJ2FW0OofqAVdCZiEYkd6dwGXthvkSFWOoGGJgS2CW70VK5dQM5Oh+7ne47s74VTg==", prismJS: "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js", prismJSSRI: "sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==", }, }; function cssVars() { const t = CONFIG.theme; return ":root{--accent:" + t.accent + ";--accent-hover:" + t.accentHover + ";--bg-page:" + t.bgPage + ";--bg-card:" + t.bgCard + ";--text-primary:" + t.textPrimary + ";--text-secondary:" + t.textSecondary + ";--text-muted:" + t.textMuted + ";--border:" + t.border + ";--shadow:" + t.shadow + ";--radius:" + t.radius + ";--content-width:" + t.contentWidth + ";--sidebar-width:" + t.sidebarWidth + "}"; } function cssDarkVars() { const d = CONFIG.darkTheme; return '[data-theme="dark"]{--accent:' + d.accent + ';--accent-hover:' + d.accentHover + ';--bg-page:' + d.bgPage + ';--bg-card:' + d.bgCard + ';--text-primary:' + d.textPrimary + ';--text-secondary:' + d.textSecondary + ';--text-muted:' + d.textMuted + ';--border:' + d.border + ';--shadow:' + d.shadow + '}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]){--accent:' + d.accent + ';--accent-hover:' + d.accentHover + ';--bg-page:' + d.bgPage + ';--bg-card:' + d.bgCard + ';--text-primary:' + d.textPrimary + ';--text-secondary:' + d.textSecondary + ';--text-muted:' + d.textMuted + ';--border:' + d.border + ';--shadow:' + d.shadow + '}}'; } // ═══════════════════════════════════════════ // 路由入口 // ═══════════════════════════════════════════ export default { async fetch(request, env, ctx) { try { return await handleRequest(request, env, ctx); } catch (err) { console.error("Unhandled:", err.message, err.stack); return new Response(JSON.stringify({ error: "Internal Server Error" }), { status: 500, headers: { "Content-Type": "application/json", "X-Content-Type-Options": "nosniff" } }); } }, }; async function handleRequest(request, env, ctx) { const url = new URL(request.url); const method = request.method; const nonce = crypto.randomUUID(); if (url.pathname.startsWith("/media/")) return addSecurityHeaders(await handleMediaProxy(url.pathname.replace("/media/", ""), env), nonce); if (url.pathname === "/sitemap.xml") return addSecurityHeaders(await handleSitemap(env, url.origin), nonce); if (url.pathname.startsWith("/posts/")) { const slug = url.pathname.replace("/posts/", ""); if (!slug || !/^[a-z0-9-]+$/.test(slug)) return addSecurityHeaders(new Response("Not Found", { status: 404 }), nonce); return addSecurityHeaders(await handlePostDetail(slug, env, ctx, nonce), nonce); } if (url.pathname === "/api/search") return addSecurityHeaders(await handleSearch(env, url.searchParams.get("q") || ""), nonce); if (url.pathname === "/api/comments" && method === "GET") return addSecurityHeaders(await handleGetComments(env, url), nonce); if (url.pathname === "/api/comments" && method === "POST") return addSecurityHeaders(await handlePostComment(request, env), nonce); if (url.pathname === "/admin/login" && method === "POST") return addSecurityHeaders(await handleAdminLogin(request, env), nonce); if (url.pathname === "/admin" || url.pathname.startsWith("/admin/")) return await handleAdminRouter(request, env, url, method, nonce, ctx); if (url.pathname === "/") return addSecurityHeaders(await handleHome(env, url.searchParams, nonce), nonce); return addSecurityHeaders(new Response("Not Found", { status: 404 }), nonce); } // ═══════════════════════════════════════════ // 安全工具 // ═══════════════════════════════════════════ function escapeHtml(s) { if (typeof s !== "string") return ""; return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'"); } function timingSafeEqual(a, b) { if (typeof a !== "string" || typeof b !== "string") return false; if (a.length !== b.length) return false; const A = new TextEncoder().encode(a), B = new TextEncoder().encode(b); let r = 0; for (let i = 0; i < A.length; i++) r |= A[i] ^ B[i]; return r === 0; } function isValidOrigin(req, url) { const o = req.headers.get("Origin") || req.headers.get("Referer"); if (!o) return false; try { return new URL(o).host === url.host; } catch { return false; } } function addSecurityHeaders(response, nonce) { const h = new Headers(response.headers); h.set("X-Content-Type-Options", "nosniff"); h.set("X-Frame-Options", "DENY"); h.set("Referrer-Policy", "strict-origin-when-cross-origin"); if ((h.get("Content-Type") || "").includes("text/html")) h.set("Content-Security-Policy", "default-src 'self' https:; script-src 'self' 'nonce-" + nonce + "' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: https:; frame-ancestors 'none';"); return new Response(response.body, { status: response.status, statusText: response.statusText, headers: h }); } function truncateToBytes(str, max) { if (typeof str !== "string") return ""; const e = new TextEncoder(); if (e.encode(str).length <= max) return str; let lo = 0, hi = str.length; while (lo < hi) { const m = (lo+hi+1)>>1; if (e.encode(str.slice(0,m)).length <= max) lo = m; else hi = m-1; } return str.slice(0, lo); } async function safeKVGet(kv, key) { try { return await kv.get(key); } catch (e) { console.error("KV:", key, e.message); return null; } } function jsonResponse(d, s) { return new Response(JSON.stringify(d), { status: s || 200, headers: { "Content-Type": "application/json" } }); } // ═══════════════════════════════════════════ // 密码学 // ═══════════════════════════════════════════ async function hashPassword(pw, salt) { const e = new TextEncoder(); const k = await crypto.subtle.importKey("raw", e.encode(pw), "PBKDF2", false, ["deriveBits"]); const b = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: e.encode(salt), iterations: 100000, hash: "SHA-256" }, k, 256); return Array.from(new Uint8Array(b)).map(x => x.toString(16).padStart(2,"0")).join(""); } async function hmacSha256(msg, sec) { const e = new TextEncoder(); const k = await crypto.subtle.importKey("raw", e.encode(sec), { name: "HMAC", hash: { name: "SHA-256" } }, false, ["sign"]); const s = await crypto.subtle.sign("HMAC", k, e.encode(msg)); return Array.from(new Uint8Array(s)).map(x => x.toString(16).padStart(2,"0")).join(""); } async function generateToken(u, sec) { const a = new Uint32Array(1); crypto.getRandomValues(a); const p = JSON.stringify({ username: u, exp: Date.now() + 86400000, nonce: a[0] }); const enc = btoa(p); return enc + "." + await hmacSha256(enc, sec); } async function verifyToken(tok, sec) { try { const d = tok.indexOf("."); if (d === -1) return null; const enc = tok.slice(0,d), sig = tok.slice(d+1); if (!enc || !sig) return null; if (!timingSafeEqual(sig, await hmacSha256(enc, sec))) return null; const p = JSON.parse(atob(enc)); return Date.now() > p.exp ? null : p; } catch { return null; } } // ═══════════════════════════════════════════ // 业务逻辑 // ═══════════════════════════════════════════ async function handleMediaProxy(fn, env) { if (!env.BLOG_R2) return new Response("R2 missing", { status: 500 }); const d = decodeURIComponent(fn); if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(d)) return new Response("Forbidden", { status: 400 }); const o = await env.BLOG_R2.get(d); if (!o) return new Response("Not Found", { status: 404 }); const h = new Headers(); o.writeHttpMetadata(h); h.set("etag", o.httpEtag); h.set("Cache-Control", "public, max-age=31536000, immutable"); return new Response(o.body, { headers: h }); } async function handleSitemap(env, origin) { const r = await safeKVGet(env.BLOG_KV, "post:list_index"); const ps = r ? JSON.parse(r) : []; let x = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>' + escapeHtml(origin) + '/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>'; for (const p of ps) x += '\n <url><loc>' + escapeHtml(origin) + '/posts/' + escapeHtml(p.slug) + '</loc><lastmod>' + escapeHtml(p.date||"") + '</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>'; x += "\n</urlset>"; return new Response(x, { headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=86400" } }); } async function handleSearch(env, q) { const r = await safeKVGet(env.BLOG_KV, "post:list_index"); if (!r) return jsonResponse([]); const ps = JSON.parse(r); const kw = q.toLowerCase().trim(); if (!kw) return jsonResponse(ps); return jsonResponse(ps.filter(p => (p.title&&p.title.toLowerCase().includes(kw))||(p.summary&&p.summary.toLowerCase().includes(kw)))); } async function handleGetComments(env, url) { const s = url.searchParams.get("slug"); if (!s||!/^[a-z0-9-]+$/.test(s)) return jsonResponse({error:"Invalid slug"},400); const r = await safeKVGet(env.BLOG_KV, "comments:"+s); const c = r ? JSON.parse(r) : []; return jsonResponse({comments:c,count:c.length}); } async function handlePostComment(req, env) { try { const ip = req.headers.get("CF-Connecting-IP")||"local"; const rk = "rate:comment:"+ip; const at = parseInt((await safeKVGet(env.BLOG_KV, rk))||"0"); if (at >= CONFIG.comments.rateLimitPerIP) return jsonResponse({error:"评论过于频繁"},429); const {slug,name,content,honeypot} = await req.json(); if (honeypot) return jsonResponse({success:true}); if (!slug||!/^[a-z0-9-]+$/.test(slug)) return jsonResponse({error:"Invalid slug"},400); if (!name||!name.trim()) return jsonResponse({error:"昵称不能为空"},400); if (!content||!content.trim()) return jsonResponse({error:"内容不能为空"},400); const sn = truncateToBytes(name.trim(), CONFIG.comments.maxNameLength*3); const sc = truncateToBytes(content.trim(), CONFIG.comments.maxContentLength*3); const raw = await safeKVGet(env.BLOG_KV, "comments:"+slug); let cs = raw ? JSON.parse(raw) : []; if (cs.length >= CONFIG.comments.maxPerPost) return jsonResponse({error:"评论已达上限"},400); cs.push({id:Date.now().toString(36)+Math.random().toString(36).slice(2,6),name:sn,content:sc,date:new Date().toISOString()}); await env.BLOG_KV.put("comments:"+slug, JSON.stringify(cs)); await env.BLOG_KV.put(rk, String(at+1), {expirationTtl:CONFIG.comments.rateLimitTTL}); return jsonResponse({success:true}); } catch(e){return jsonResponse({error:e.message},500);} } async function handleAdminLogin(req, env) { try { const ip = req.headers.get("CF-Connecting-IP")||"local"; const rk = "rate:login:"+ip; const at = parseInt((await safeKVGet(env.BLOG_KV, rk))||"0"); if (at>=5) return jsonResponse({error:"限流中"},429); const {password} = await req.json(); const salt = (env.JWT_SECRET||"").trim(); const sh = (env.ADMIN_PASSWORD_HASH||"").trim(); if (timingSafeEqual(await hashPassword(password, salt), sh)) { await env.BLOG_KV.delete(rk); const tok = await generateToken("admin", salt); const h = new Headers(); h.set("Content-Type","application/json"); h.set("Set-Cookie","admin_token="+tok+"; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400"); return new Response(JSON.stringify({success:true}),{headers:h}); } await env.BLOG_KV.put(rk, String(at+1), {expirationTtl:60}); return jsonResponse({error:"密码错误"},401); } catch{return jsonResponse({error:"异常"},500);} } async function handleAdminRouter(req, env, url, method, nonce, ctx) { const ch = req.headers.get("Cookie")||""; const cr = ch.split("; ").find(r=>r.startsWith("admin_token=")); const tok = cr ? cr.substring("admin_token=".length) : null; const salt = (env.JWT_SECRET||"").trim(); const user = tok ? await verifyToken(tok, salt) : null; if (url.pathname === "/admin/login") { if (user) return Response.redirect(url.origin+"/admin",302); return addSecurityHeaders(renderLoginView(nonce), nonce); } if (!user) return Response.redirect(url.origin+"/admin/login", 302); if (method==="POST"||method==="DELETE") { if (!isValidOrigin(req, url)) return jsonResponse({error:"CSRF Blocked"},403); } if (url.pathname==="/admin/save-post"&&method==="POST") return await handleSavePost(req, env, ctx); if (url.pathname==="/admin/delete-post"&&method==="POST") return await handleDeletePost(req, env, ctx); if (url.pathname==="/admin/upload"&&method==="POST") return await handleUpload(req, env); if (url.pathname==="/admin/clean-cache"&&method==="POST") return await handleCleanCache(req, env); if (url.pathname==="/admin/rebuild-index"&&method==="POST") return await handleRebuildIndex(env); if (url.pathname==="/admin/get-post"&&method==="GET") return await handleGetPost(env, url); if (url.pathname==="/admin/delete-comment"&&method==="POST") return await handleDeleteComment(req, env); if (url.pathname==="/admin/logout") { const h = new Headers(); h.set("Set-Cookie","admin_token=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0"); h.set("Location","/admin/login"); return new Response(null,{status:302,headers:h}); } return addSecurityHeaders(await renderDashboardView(env, nonce), nonce); } async function handleGetPost(env, url) { const s = url.searchParams.get("slug"); if (!s||!/^[a-z0-9-]+$/.test(s)) return jsonResponse({error:"Invalid"},400); const md = await safeKVGet(env.BLOG_KV, "post:"+s); if (!md) return jsonResponse({error:"Not found"},404); return jsonResponse({markdown:md}); } async function handleSavePost(req, env, ctx) { try { const {title,slug,date,category,tags,summary,markdown,cover} = await req.json(); if (!slug||!title||!markdown||!/^[a-z0-9-]+$/.test(slug)) return jsonResponse({error:"请填写完整"},400); const sm = {title:truncateToBytes(title||"",120),date:(date||new Date().toISOString().split("T")[0]).slice(0,20),category:truncateToBytes(category||"",60),tags:(tags||[]).slice(0,5).map(t=>truncateToBytes(String(t),40)),summary:truncateToBytes(summary||"",200),cover:(cover&&/^\/media\/[a-zA-Z0-9._-]+$/.test(cover))?cover:""}; await env.BLOG_KV.put("post:"+slug, markdown, {metadata:sm}); let idx=[]; const ir = await safeKVGet(env.BLOG_KV,"post:list_index"); if(ir){try{idx=JSON.parse(ir);}catch{idx=[];}} idx=idx.filter(p=>p.slug!==slug); idx.unshift({slug,...sm}); idx.sort((a,b)=>new Date(b.date)-new Date(a.date)); await env.BLOG_KV.put("post:list_index",JSON.stringify(idx)); ctx.waitUntil(purgeCache(env,req,slug).catch(e=>console.error("Purge:",e.message))); return jsonResponse({success:true}); } catch(e){return jsonResponse({error:e.message},500);} } async function handleDeletePost(req, env, ctx) { try { const {slug} = await req.json(); if (!slug||!/^[a-z0-9-]+$/.test(slug)) return jsonResponse({error:"Invalid"},400); await env.BLOG_KV.delete("post:"+slug); await env.BLOG_KV.delete("comments:"+slug); let idx=[]; const ir = await safeKVGet(env.BLOG_KV,"post:list_index"); if(ir){try{idx=JSON.parse(ir);}catch{idx=[];}} idx=idx.filter(p=>p.slug!==slug); await env.BLOG_KV.put("post:list_index",JSON.stringify(idx)); ctx.waitUntil(purgeCache(env,req,slug).catch(e=>console.error("Purge:",e.message))); return jsonResponse({success:true}); } catch(e){return jsonResponse({error:e.message},500);} } async function handleDeleteComment(req, env) { try { const {slug,id} = await req.json(); if (!slug||!/^[a-z0-9-]+$/.test(slug)||!id) return jsonResponse({error:"Invalid"},400); const r = await safeKVGet(env.BLOG_KV,"comments:"+slug); if(!r) return jsonResponse({error:"None"},404); let cs=JSON.parse(r); cs=cs.filter(c=>c.id!==id); await env.BLOG_KV.put("comments:"+slug,JSON.stringify(cs)); return jsonResponse({success:true}); } catch(e){return jsonResponse({error:e.message},500);} } async function handleUpload(req, env) { try { const fd = await req.formData(); const f = fd.get("file"); if(!f) return jsonResponse({error:"No file"},400); const m={"image/jpeg":"jpg","image/png":"png","image/webp":"webp","image/gif":"gif"}; const ext=m[f.type]; if(!ext) return jsonResponse({error:"仅限图片"},400); const n=Date.now()+"-"+Math.random().toString(36).slice(2,7)+"."+ext; await env.BLOG_R2.put(n, f.stream(), {httpMetadata:{contentType:f.type}}); return jsonResponse({success:true,url:"/media/"+n}); } catch(e){return jsonResponse({error:e.message},500);} } async function handleCleanCache(req, env) { try { const r = await purgeCache(env,req,null); return r.success ? jsonResponse({success:true,message:"CDN 已清空"}) : jsonResponse({error:"失败"},500); } catch(e){return jsonResponse({error:e.message},500);} } async function handleRebuildIndex(env) { try { let cur; const ks=[]; do{const l=await env.BLOG_KV.list({prefix:"post:",cursor:cur});ks.push(...l.keys);cur=l.cursor;}while(cur); const idx=[]; for(const k of ks.filter(k=>k.name!=="post:list_index")){const s=k.name.replace("post:","");const m=k.metadata||{title:s,date:new Date().toISOString().split("T")[0],category:"未分类",summary:"",cover:""};idx.push({slug:s,...m});} idx.sort((a,b)=>new Date(b.date)-new Date(a.date)); await env.BLOG_KV.put("post:list_index",JSON.stringify(idx)); return jsonResponse({success:true}); } catch(e){return jsonResponse({error:e.message},500);} } async function purgeCache(env, req, slug) { if(!env.CF_ZONE_ID||!env.CF_PURGE_TOKEN) return {success:true}; const u=new URL(req.url); if(u.host.endsWith(".workers.dev")) return {success:true}; const o=u.origin,d=u.host; const body=slug?{files:[o+"/",o+"/sitemap.xml",o+"/posts/"+slug]}:{files:[o+"/",o+"/sitemap.xml"],prefixes:[d+"/posts"]}; const c=new AbortController(); const t=setTimeout(()=>c.abort(),5000); try{const r=await fetch("https://api.cloudflare.com/client/v4/zones/"+env.CF_ZONE_ID+"/purge_cache",{method:"POST",headers:{Authorization:"Bearer "+env.CF_PURGE_TOKEN,"Content-Type":"application/json"},body:JSON.stringify(body),signal:c.signal});clearTimeout(t);return await r.json();}catch(e){clearTimeout(t);return{success:false,error:e.message};} } // ═══════════════════════════════════════════ // 共享 CSS 片段 // ═══════════════════════════════════════════ function toastCSS() { return `.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(80px);padding:12px 24px;border-radius:10px;font-size:.88rem;font-weight:500;z-index:9999;background:var(--bg-card);color:var(--text-primary);box-shadow:0 8px 30px rgba(0,0,0,.18);border:1px solid var(--border);opacity:0;transition:all .35s cubic-bezier(.4,0,.2,1);pointer-events:none} .toast.show{opacity:1;transform:translateX(-50%) translateY(0)} .toast.ok{border-left:4px solid #22c55e}.toast.err{border-left:4px solid #ef4444}`; } function toastJS() { return 'function toast(msg,type){var el=document.createElement("div");el.className="toast "+(type||"ok");el.textContent=msg;document.body.appendChild(el);requestAnimationFrame(function(){el.classList.add("show");});setTimeout(function(){el.classList.remove("show");setTimeout(function(){el.remove();},400);},2500);}'; } function trendCSS() { return `@view-transition{navigation:auto} ::view-transition-old(root){animation:vt-out .25s ease both} ::view-transition-new(root){animation:vt-in .35s ease both} @keyframes vt-out{to{opacity:0;transform:scale(.98)}} @keyframes vt-in{from{opacity:0;transform:scale(1.01)}} body::after{content:"";position:fixed;inset:0;z-index:9998;pointer-events:none;opacity:.025;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")} @supports (animation-timeline: scroll()){.reading-progress{animation:pgrow linear both;animation-timeline:scroll(root)}} @keyframes pgrow{from{transform:scaleX(0)}to{transform:scaleX(1)}} @supports (animation-timeline: view()){.post-card{animation:creveal linear both;animation-timeline:view();animation-range:entry 0% entry 40%}} @keyframes creveal{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}} @media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}} :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px} button:active,.btn:active,.page-btn:active,.side-tag:active{transform:scale(.96)!important}`; } // ═══════════════════════════════════════════ // 前台 CSS // ═══════════════════════════════════════════ function baseCSS() { return cssVars() + cssDarkVars() + ` *{margin:0;padding:0;box-sizing:border-box}html{scroll-behavior:smooth} body{font-family:-apple-system,"Noto Sans SC","PingFang SC","Helvetica Neue",sans-serif;background:var(--bg-page);color:var(--text-primary);line-height:1.8;font-size:16px;transition:background .3s,color .3s;display:flex;flex-direction:column;min-height:100vh} a{color:var(--accent);text-decoration:none;transition:color .2s}a:hover{color:var(--accent-hover)}img{max-width:100%;border-radius:4px} .nav{position:sticky;top:0;z-index:100;background:rgba(255,255,255,.82);backdrop-filter:blur(16px) saturate(180%);border-bottom:1px solid var(--border)} [data-theme="dark"] .nav{background:rgba(22,33,62,.82)}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]) .nav{background:rgba(22,33,62,.82)}} .nav-inner{max-width:1100px;margin:0 auto;padding:0 24px;height:60px;display:flex;align-items:center;justify-content:space-between} .nav-logo{font-size:1.25rem;font-weight:800;color:var(--text-primary);letter-spacing:-.5px}.nav-logo:hover{color:var(--accent)} .nav-links{display:flex;gap:20px;align-items:center}.nav-links a{color:var(--text-secondary);font-size:.9rem;font-weight:500}.nav-links a:hover{color:var(--accent)} .nav-toggle{background:none;border:none;font-size:1.2rem;cursor:pointer;color:var(--text-secondary);padding:4px 8px;border-radius:4px}.nav-toggle:hover{background:var(--bg-page)} .layout{max-width:1100px;margin:0 auto;padding:32px 24px;display:flex;gap:40px;flex:1;width:100%} .content{flex:1;min-width:0;max-width:var(--content-width)}.sidebar{width:var(--sidebar-width);flex-shrink:0} @media(max-width:900px){.layout{flex-direction:column-reverse}.sidebar{width:100%}} .side-card{background:var(--bg-card);border-radius:var(--radius);box-shadow:var(--shadow);padding:24px;margin-bottom:20px} .side-avatar{width:80px;height:80px;border-radius:50%;margin:0 auto 12px;display:flex;align-items:center;justify-content:center;font-size:2.5rem;background:var(--bg-page);overflow:hidden}.side-avatar img{width:100%;height:100%;object-fit:cover} .side-name{text-align:center;font-weight:700;font-size:1.1rem;margin-bottom:4px}.side-bio{text-align:center;font-size:.85rem;color:var(--text-muted);margin-bottom:16px} .side-stats{display:flex;justify-content:center;gap:24px;padding:12px 0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);margin-bottom:16px} .side-stat{text-align:center}.side-stat b{display:block;font-size:1.1rem}.side-stat span{font-size:.75rem;color:var(--text-muted)} .side-section-title{font-size:.8rem;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px} .side-cat{display:flex;justify-content:space-between;padding:6px 0;font-size:.9rem;color:var(--text-secondary);border-bottom:1px dashed var(--border)}.side-cat:last-child{border-bottom:none}.side-cat:hover{color:var(--accent)} .side-tags{display:flex;flex-wrap:wrap;gap:6px}.side-tag{font-size:.78rem;padding:3px 10px;border-radius:12px;background:var(--bg-page);color:var(--text-secondary);transition:all .2s}.side-tag:hover{background:var(--accent);color:#fff} .side-social{display:flex;justify-content:center;gap:16px;margin-top:12px}.side-social a{font-size:.85rem;color:var(--text-muted);font-weight:600}.side-social a:hover{color:var(--accent)} .post-card{background:var(--bg-card);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:24px;transition:transform .3s cubic-bezier(.4,0,.2,1),box-shadow .3s;opacity:0;transform:translateY(20px);overflow:hidden} .post-card.visible{opacity:1;transform:translateY(0)}.post-card:hover{transform:translateY(-4px);box-shadow:0 8px 24px rgba(0,0,0,.1)} .post-card-cover{width:100%;height:200px;object-fit:cover;display:block;transition:transform .5s cubic-bezier(.4,0,.2,1)}.post-card:hover .post-card-cover{transform:scale(1.04)} .post-card-body{padding:28px 32px} .post-card-title{font-size:1.5rem;font-weight:700;margin-bottom:8px;line-height:1.4}.post-card-title a{color:var(--text-primary);background:linear-gradient(var(--accent),var(--accent)) no-repeat 0 100%/0 2px;transition:background-size .3s}.post-card-title a:hover{background-size:100% 2px;color:var(--text-primary)} .post-card-meta{display:flex;flex-wrap:wrap;gap:12px;font-size:.82rem;color:var(--text-muted);margin-bottom:12px} .post-card-excerpt{color:var(--text-secondary);font-size:.95rem;line-height:1.8;margin-bottom:16px} .post-card-more{font-size:.88rem;font-weight:600;color:var(--accent)}.post-card-more:hover{color:var(--accent-hover)} .search-info{font-size:.82rem;color:var(--text-muted);margin:-12px 0 16px} .pagination{display:flex;justify-content:center;gap:8px;margin-top:32px} .page-btn{padding:8px 14px;border-radius:6px;font-size:.88rem;color:var(--text-secondary);background:var(--bg-card);border:1px solid var(--border);transition:all .2s} .page-btn:hover{border-color:var(--accent);color:var(--accent)}.page-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)} .post-header{margin-bottom:32px;padding-bottom:24px;border-bottom:1px solid var(--border)} .post-title{font-size:2rem;font-weight:800;line-height:1.3;margin-bottom:12px} .post-meta{display:flex;flex-wrap:wrap;gap:16px;font-size:.85rem;color:var(--text-muted)} .post-body{font-size:1rem;line-height:1.9}.post-body h1{font-size:1.75rem;font-weight:800;margin:2.5rem 0 1rem;padding-bottom:.5rem;border-bottom:1px solid var(--border)} .post-body h2{font-size:1.4rem;font-weight:700;margin:2rem 0 .8rem}.post-body h3{font-size:1.15rem;font-weight:700;margin:1.5rem 0 .6rem} .post-body p{margin-bottom:1.2rem}.post-body ul,.post-body ol{margin:0 0 1.2rem 1.5rem}.post-body li{margin-bottom:.4rem} .post-body blockquote{border-left:4px solid var(--accent);padding:12px 20px;margin:1.5rem 0;background:var(--bg-page);border-radius:0 var(--radius) var(--radius) 0;color:var(--text-secondary)} .post-body pre{border-radius:var(--radius);padding:20px;margin:1.5rem 0;overflow-x:auto;font-size:.88rem;line-height:1.6;position:relative} .post-body code{font-family:"JetBrains Mono","Fira Code",monospace;font-size:.88em} .post-body p code{background:var(--bg-page);padding:2px 6px;border-radius:4px}.post-body pre code{background:none;padding:0} .post-body img{margin:1.5rem 0;box-shadow:var(--shadow)} .post-body table{width:100%;border-collapse:collapse;margin:1.5rem 0}.post-body th,.post-body td{border:1px solid var(--border);padding:10px 14px;text-align:left;font-size:.9rem}.post-body th{background:var(--bg-page);font-weight:600} .post-tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:32px;padding-top:20px;border-top:1px solid var(--border)} .post-tag{font-size:.8rem;padding:4px 12px;border-radius:14px;background:var(--bg-page);color:var(--text-secondary)}.post-tag:hover{background:var(--accent);color:#fff} .post-nav{display:flex;justify-content:space-between;gap:16px;margin-top:32px} .post-nav-item{flex:1;padding:16px;background:var(--bg-card);border-radius:var(--radius);box-shadow:var(--shadow);transition:transform .2s}.post-nav-item:hover{transform:translateY(-2px)} .post-nav-label{font-size:.75rem;color:var(--text-muted);margin-bottom:4px}.post-nav-title{font-size:.9rem;font-weight:600;color:var(--text-primary)} .toc{position:sticky;top:80px}.toc-title{font-size:.8rem;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px} .toc-list{list-style:none;font-size:.85rem}.toc-list li{margin-bottom:6px} .toc-list a{color:var(--text-secondary);display:block;padding:2px 0;border-left:2px solid transparent;padding-left:12px;transition:all .2s} .toc-list a:hover,.toc-list a.active{color:var(--accent);border-left-color:var(--accent)}.toc-list .toc-h3{padding-left:24px} .reading-progress{position:fixed;top:0;left:0;height:3px;width:100%;background:linear-gradient(90deg,var(--accent),var(--accent-hover));z-index:9999;transform:scaleX(0);transform-origin:left} .back-top{position:fixed;bottom:32px;right:32px;width:44px;height:44px;border-radius:50%;background:var(--bg-card);box-shadow:var(--shadow);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:1.1rem;color:var(--text-secondary);opacity:0;transform:translateY(16px);transition:all .3s;z-index:90} .back-top.show{opacity:1;transform:translateY(0)}.back-top:hover{border-color:var(--accent);color:var(--accent);transform:translateY(-2px)} .footer{text-align:center;padding:40px 24px;color:var(--text-muted);font-size:.82rem;border-top:1px solid var(--border);margin-top:auto} .search-box{position:relative;margin-bottom:24px} .search-box input{width:100%;padding:12px 16px 12px 40px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-card);color:var(--text-primary);font-size:.95rem;transition:all .2s} .search-box input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(0,102,204,.1)} .search-box::before{content:"🔍";position:absolute;left:14px;top:50%;transform:translateY(-50%);font-size:.9rem} .copy-code-btn{position:absolute;top:8px;right:8px;padding:4px 8px;font-size:.72rem;background:rgba(255,255,255,.15);color:#fff;border:1px solid rgba(255,255,255,.25);border-radius:4px;cursor:pointer;opacity:0;transition:opacity .2s} pre:hover .copy-code-btn{opacity:1}.copy-code-btn:hover{background:rgba(255,255,255,.3)} .comment-section{margin-top:48px;padding-top:32px;border-top:2px solid var(--border)} .comment-section-title{font-size:1.2rem;font-weight:700;margin-bottom:20px} .comment-form{background:var(--bg-card);border-radius:var(--radius);box-shadow:var(--shadow);padding:24px;margin-bottom:24px} .comment-form-row{display:flex;gap:12px;margin-bottom:12px} .comment-form-row input{flex:1;padding:10px 14px;border:1px solid var(--border);border-radius:6px;font-size:.9rem;background:var(--bg-page);color:var(--text-primary)} .comment-form-row input:focus{outline:none;border-color:var(--accent)} .comment-form textarea{width:100%;padding:12px 14px;border:1px solid var(--border);border-radius:6px;font-size:.9rem;min-height:100px;resize:vertical;background:var(--bg-page);color:var(--text-primary);font-family:inherit} .comment-form textarea:focus{outline:none;border-color:var(--accent)} .comment-count{text-align:right;font-size:.72rem;color:var(--text-muted);margin-top:4px} .comment-form button{margin-top:12px;padding:10px 24px;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:.9rem;font-weight:600;cursor:pointer}.comment-form button:hover{background:var(--accent-hover)}.comment-form button:disabled{opacity:.5;cursor:not-allowed} .comment-item{padding:16px 0;border-bottom:1px solid var(--border)}.comment-item:last-child{border-bottom:none} .comment-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px} .comment-author{font-weight:600;font-size:.9rem}.comment-date{font-size:.75rem;color:var(--text-muted)} .comment-content{font-size:.92rem;color:var(--text-secondary);line-height:1.7;white-space:pre-wrap;word-break:break-word} .comment-empty{text-align:center;padding:32px 0;color:var(--text-muted);font-size:.9rem} .hp-field{position:absolute;left:-9999px;opacity:0;height:0;overflow:hidden} .skeleton{background:linear-gradient(90deg,var(--bg-page) 25%,rgba(0,0,0,.05) 50%,var(--bg-page) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:6px} @keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}} .sk-title{height:32px;width:70%;margin-bottom:16px}.sk-line{height:16px;width:100%;margin-bottom:10px}.sk-short{width:40%} ` + toastCSS() + trendCSS(); } // ═══════════════════════════════════════════ // 前台渲染 // ═══════════════════════════════════════════ function renderNav() { return '<nav class="nav"><div class="nav-inner"><a href="/" class="nav-logo">' + escapeHtml(CONFIG.siteName) + '</a><div class="nav-links"><a href="/">首页</a><a href="/admin">后台</a><button class="nav-toggle" id="themeToggle" aria-label="主题">🌙</button></div></div></nav>'; } function renderFooter() { return '<footer class="footer">' + escapeHtml(CONFIG.footerText) + '</footer>'; } function renderSidebar(posts) { const cats={},tags=new Set(); posts.forEach(p=>{if(p.category)cats[p.category]=(cats[p.category]||0)+1;if(p.tags)p.tags.forEach(t=>tags.add(t));}); const ce=Object.entries(cats).sort((a,b)=>b[1]-a[1]); const ta=[...tags].slice(0,30); const av=CONFIG.authorAvatar?'<img src="'+escapeHtml(CONFIG.authorAvatar)+'" alt="av">':"👤"; const sl=[]; if(CONFIG.social.github)sl.push('<a href="'+escapeHtml(CONFIG.social.github)+'" target="_blank" rel="noopener">GitHub</a>'); if(CONFIG.social.twitter)sl.push('<a href="'+escapeHtml(CONFIG.social.twitter)+'" target="_blank" rel="noopener">Twitter</a>'); if(CONFIG.social.email)sl.push('<a href="mailto:'+escapeHtml(CONFIG.social.email)+'">Email</a>'); return '<aside class="sidebar"><div class="side-card"><div class="side-avatar">'+av+'</div><div class="side-name">'+escapeHtml(CONFIG.authorName)+'</div><div class="side-bio">'+escapeHtml(CONFIG.authorBio)+'</div><div class="side-stats"><div class="side-stat"><b>'+posts.length+'</b><span>文章</span></div><div class="side-stat"><b>'+ce.length+'</b><span>分类</span></div><div class="side-stat"><b>'+tags.size+'</b><span>标签</span></div></div>'+(sl.length?'<div class="side-social">'+sl.join("")+'</div>':"")+'</div>'+(ce.length?'<div class="side-card"><div class="side-section-title">📂 分类</div>'+ce.map(e=>'<a href="/?category='+encodeURIComponent(e[0])+'" class="side-cat"><span>'+escapeHtml(e[0])+'</span><span>'+e[1]+'</span></a>').join("")+'</div>':"")+(ta.length?'<div class="side-card"><div class="side-section-title">🏷️ 标签</div><div class="side-tags">'+ta.map(t=>'<a href="/?tag='+encodeURIComponent(t)+'" class="side-tag">'+escapeHtml(t)+'</a>').join("")+'</div></div>':"")+'</aside>'; } function getWordCount(t){if(!t)return 0;return(t.match(/[\u4e00-\u9fa5]/g)||[]).length+(t.replace(/[\u4e00-\u9fa5]/g,"").match(/[a-zA-Z0-9]+/g)||[]).length;} function getReadingTime(t){return Math.ceil(getWordCount(t)/CONFIG.readingSpeed)+" 分钟";} function darkModeScript(){return 'var toggle=document.getElementById("themeToggle");var stored=localStorage.getItem("theme");if(stored)document.documentElement.setAttribute("data-theme",stored);function ui(){var t=document.documentElement.getAttribute("data-theme");toggle.textContent=(t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme:dark)").matches))?"☀️":"🌙";}ui();toggle.addEventListener("click",function(){var c=document.documentElement.getAttribute("data-theme");var n=(c==="dark"||(c!=="light"&&window.matchMedia("(prefers-color-scheme:dark)").matches))?"light":"dark";document.documentElement.setAttribute("data-theme",n);localStorage.setItem("theme",n);ui();});';} async function handleHome(env, params, nonce) { let posts=[]; try{const r=await safeKVGet(env.BLOG_KV,"post:list_index");posts=r?JSON.parse(r):[];}catch(e){console.error(e.message);} const fc=params.get("category"),ft=params.get("tag"),page=Math.max(1,parseInt(params.get("page"))||1); let filtered=posts; if(fc)filtered=posts.filter(p=>p.category===fc); else if(ft)filtered=posts.filter(p=>p.tags&&p.tags.includes(ft)); const tp=Math.max(1,Math.ceil(filtered.length/CONFIG.postsPerPage)); const dp=filtered.slice((page-1)*CONFIG.postsPerPage,page*CONFIG.postsPerPage); let fb=""; if(fc)fb='<div style="margin-bottom:20px;font-size:.9rem;color:var(--accent);background:var(--bg-card);padding:10px 16px;border-radius:var(--radius);display:inline-block">📁 '+escapeHtml(fc)+' <a href="/" style="margin-left:8px;color:var(--text-muted)">✕</a></div>'; if(ft)fb='<div style="margin-bottom:20px;font-size:.9rem;color:var(--accent);background:var(--bg-card);padding:10px 16px;border-radius:var(--radius);display:inline-block">🏷️ '+escapeHtml(ft)+' <a href="/" style="margin-left:8px;color:var(--text-muted)">✕</a></div>'; const ph=dp.length===0?'<div style="text-align:center;padding:60px 0;color:var(--text-muted)">暂无文章</div>':dp.map(p=>{const ex=(p.summary||"").slice(0,CONFIG.excerptLength);const cv=p.cover?'<img class="post-card-cover" src="'+escapeHtml(p.cover)+'" alt="'+escapeHtml(p.title)+'" loading="lazy">':"";return '<article class="post-card">'+cv+'<div class="post-card-body"><h2 class="post-card-title"><a href="/posts/'+escapeHtml(p.slug)+'">'+escapeHtml(p.title)+'</a></h2><div class="post-card-meta"><span>📅 '+escapeHtml(p.date||"")+'</span>'+(p.category?'<span>📂 '+escapeHtml(p.category)+'</span>':"")+'<span>📖 '+getReadingTime(p.summary||"")+'</span></div><p class="post-card-excerpt">'+escapeHtml(ex)+((p.summary||"").length>CONFIG.excerptLength?"...":"")+'</p><a href="/posts/'+escapeHtml(p.slug)+'" class="post-card-more">阅读全文 →</a></div></article>';}).join(""); let pg=""; if(tp>1){const base=fc?"/?category="+encodeURIComponent(fc)+"&":ft?"/?tag="+encodeURIComponent(ft)+"&":"/?";let b="";if(page>1)b+='<a href="'+base+'page='+(page-1)+'" class="page-btn">←</a>';const s=Math.max(1,page-3),e=Math.min(tp,page+3);if(s>1)b+='<a href="'+base+'page=1" class="page-btn">1</a><span class="page-btn" style="border:none;cursor:default">…</span>';for(let i=s;i<=e;i++)b+=i===page?'<span class="page-btn active">'+i+'</span>':'<a href="'+base+'page='+i+'" class="page-btn">'+i+'</a>';if(e<tp)b+='<span class="page-btn" style="border:none;cursor:default">…</span><a href="'+base+'page='+tp+'" class="page-btn">'+tp+'</a>';if(page<tp)b+='<a href="'+base+'page='+(page+1)+'" class="page-btn">→</a>';pg='<nav class="pagination">'+b+'</nav>';} const homeScript = 'var allPosts='+JSON.stringify(posts).replace(/</g,"\\u003c")+';' +'var si=document.getElementById("searchInput"),pl=document.getElementById("postsList"),sinfo=document.getElementById("searchInfo");' +'function esc(s){if(typeof s!=="string")return"";return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/\'/g,"'");}' +'function hl(s,kw){var e=esc(s);if(!kw)return e;var k=kw.replace(/[.*+?^${}()|[\\]\\\\]/g,"\\\\$&");var re=new RegExp("("+k+")","gi");return e.replace(re,\'<mark style="background:rgba(0,102,204,.18);color:var(--accent);padding:0 2px;border-radius:2px">$1</mark>\');}' +'function rc(l,kw){if(!l.length){pl.innerHTML=\'<div style="text-align:center;padding:60px 0;color:var(--text-muted)">未检索到匹配文章</div>\';sinfo.textContent=kw?"找到 0 篇文章":"";return;}sinfo.textContent=kw?"找到 "+l.length+" 篇文章":"";pl.innerHTML=l.map(function(p){var ex=hl((p.summary||"").slice(0,'+CONFIG.excerptLength+'),kw);var ti=hl(p.title,kw);var cv=p.cover?\'<img class="post-card-cover" src="\'+esc(p.cover)+\'" alt="" loading="lazy">\':"";return \'<article class="post-card visible">\'+cv+\'<div class="post-card-body"><h2 class="post-card-title"><a href="/posts/\'+esc(p.slug)+\'">\'+ti+\'</a></h2><div class="post-card-meta"><span>📅 \'+esc(p.date||"")+\'</span>\'+(p.category?\'<span>📂 \'+esc(p.category)+\'</span>\':"")+\'</div><p class="post-card-excerpt">\'+ex+\'</p><a href="/posts/\'+esc(p.slug)+\'" class="post-card-more">阅读全文 →</a></div></article>\';}).join("");}' +'si.addEventListener("input",function(e){var kw=e.target.value.toLowerCase().trim();if(!kw){rc(allPosts.slice(0,'+CONFIG.postsPerPage+'),"");return;}rc(allPosts.filter(function(p){return(p.title&&p.title.toLowerCase().includes(kw))||(p.summary&&p.summary.toLowerCase().includes(kw));}),kw);});' +'if(!CSS.supports("animation-timeline: view()")){var ob=new IntersectionObserver(function(en){en.forEach(function(x){if(x.isIntersecting){x.target.classList.add("visible");ob.unobserve(x.target);}});},{threshold:.1});document.querySelectorAll(".post-card").forEach(function(el){ob.observe(el);});}' +toastJS()+darkModeScript(); 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>'+escapeHtml(CONFIG.siteName)+' - '+escapeHtml(CONFIG.siteSubtitle)+'</title><meta name="description" content="'+escapeHtml(CONFIG.authorBio)+'"><style>'+baseCSS()+'</style></head><body>'+renderNav()+'<div class="layout"><main class="content"><div class="search-box"><input type="text" id="searchInput" placeholder="搜索文章..."></div><div class="search-info" id="searchInfo"></div>'+fb+'<div id="postsList">'+ph+'</div>'+pg+'</main>'+renderSidebar(posts)+'</div>'+renderFooter()+'<script nonce="'+nonce+'">'+homeScript+'</script></body></html>'; return new Response(html,{headers:{"Content-Type":"text/html; charset=utf-8"}}); } async function handlePostDetail(slug, env, ctx, nonce) { const pc=await safeKVGet(env.BLOG_KV,"post:"+slug); if(!pc) return new Response("Not Found",{status:404}); let posts=[]; try{const r=await safeKVGet(env.BLOG_KV,"post:list_index");posts=r?JSON.parse(r):[];}catch(e){console.error(e.message);} const meta=posts.find(p=>p.slug===slug)||{title:"文章",summary:""}; const idx=posts.findIndex(p=>p.slug===slug); const prev=idx<posts.length-1?posts[idx+1]:null, next=idx>0?posts[idx-1]:null; ctx.waitUntil((async()=>{try{const c=parseInt((await env.BLOG_KV.get("stat:views:"+slug))||"0");await env.BLOG_KV.put("stat:views:"+slug,String(c+1));}catch(e){console.error(e.message);}})()); let views=1; try{views=parseInt((await env.BLOG_KV.get("stat:views:"+slug))||"0")+1;}catch{views=1;} const wc=getWordCount(pc),rt=getReadingTime(pc); const th=(meta.tags&&meta.tags.length)?'<div class="post-tags">'+meta.tags.map(t=>'<a href="/?tag='+encodeURIComponent(t)+'" class="post-tag"># '+escapeHtml(t)+'</a>').join("")+'</div>':""; const nh='<div class="post-nav">'+(prev?'<a href="/posts/'+escapeHtml(prev.slug)+'" class="post-nav-item"><div class="post-nav-label">← 上一篇</div><div class="post-nav-title">'+escapeHtml(prev.title)+'</div></a>':'<span></span>')+(next?'<a href="/posts/'+escapeHtml(next.slug)+'" class="post-nav-item" style="text-align:right"><div class="post-nav-label">下一篇 →</div><div class="post-nav-title">'+escapeHtml(next.title)+'</div></a>':'<span></span>')+'</div>'; const cs='<section class="comment-section"><h2 class="comment-section-title">💬 评论</h2><div class="comment-form"><div class="comment-form-row"><input type="text" id="cName" placeholder="昵称 *" maxlength="'+CONFIG.comments.maxNameLength+'"><input type="text" class="hp-field" id="cHp" tabindex="-1" autocomplete="off"></div><textarea id="cContent" placeholder="写下你的评论..." maxlength="'+CONFIG.comments.maxContentLength+'"></textarea><div class="comment-count"><span id="cCount">0</span>/'+CONFIG.comments.maxContentLength+'</div><button id="cBtn">发表评论</button></div><div id="cList"><div class="comment-empty">加载中...</div></div></section>'; const skeleton='<div class="sk-title skeleton"></div><div class="sk-line skeleton"></div><div class="sk-line skeleton"></div><div class="sk-line skeleton"></div><div class="sk-line skeleton sk-short"></div>'; const detailScript = '(function(){var raw=document.getElementById("raw-md").value,body=document.getElementById("postBody");' +'body.innerHTML=marked.parse(raw);Prism.highlightAllUnder(body);' +'body.querySelectorAll("pre").forEach(function(pre){pre.style.position="relative";var b=document.createElement("button");b.className="copy-code-btn";b.textContent="Copy";b.addEventListener("click",function(){var c=pre.querySelector("code")?pre.querySelector("code").innerText:pre.innerText;navigator.clipboard.writeText(c).then(function(){b.textContent="Copied!";setTimeout(function(){b.textContent="Copy";},2000);});});pre.appendChild(b);});' +'var hs=body.querySelectorAll("h2,h3"),tl=document.getElementById("tocList"),th="";hs.forEach(function(h,i){var id="h-"+i;h.id=id;th+="<li"+(h.tagName==="H3"?" class=\\"toc-h3\\"":"")+"><a href=\\"#"+id+"\\">"+h.textContent+"</a></li>";});tl.innerHTML=th||"<li style=\\"color:var(--text-muted)\\">暂无目录</li>";' +'var tlinks=tl.querySelectorAll("a"),rp=document.getElementById("rp"),bt=document.getElementById("backTop"),sst=CSS.supports("animation-timeline: scroll()");' +'window.addEventListener("scroll",function(){var st=window.scrollY,dh=document.documentElement.scrollHeight-window.innerHeight;if(!sst)rp.style.transform="scaleX("+(dh>0?st/dh:0)+")";bt.classList.toggle("show",st>400);var cur=-1;hs.forEach(function(h,i){if(h.getBoundingClientRect().top<=100)cur=i;});tlinks.forEach(function(a,i){a.classList.toggle("active",i===cur);});});' +'bt.addEventListener("click",function(){window.scrollTo({top:0,behavior:"smooth"});});' +'var slug="'+escapeHtml(slug)+'",cl=document.getElementById("cList");' +'function esc(s){if(typeof s!=="string")return"";return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/\'/g,"'");}' +'function relTime(iso){var diff=Date.now()-new Date(iso).getTime();var m=Math.floor(diff/60000);if(m<1)return"刚刚";if(m<60)return m+" 分钟前";var h=Math.floor(m/60);if(h<24)return h+" 小时前";var d=Math.floor(h/24);if(d<30)return d+" 天前";return iso.slice(0,10);}' +'function rcs(cs){if(!cs.length){cl.innerHTML=\'<div class="comment-empty">💬 暂无评论,来发表第一条吧!</div>\';return;}cl.innerHTML=cs.map(function(c){return \'<div class="comment-item"><div class="comment-header"><span class="comment-author">\'+esc(c.name)+\'</span><span class="comment-date">\'+relTime(c.date)+\'</span></div><div class="comment-content">\'+esc(c.content)+\'</div></div>\';}).join("");}' +'function lc(){fetch("/api/comments?slug="+encodeURIComponent(slug)).then(function(r){return r.json();}).then(function(d){rcs(d.comments||[]);}).catch(function(){cl.innerHTML=\'<div class="comment-empty">加载失败</div>\';});}lc();' +'document.getElementById("cContent").addEventListener("input",function(){var c=document.getElementById("cCount");c.textContent=this.value.length;c.style.color=this.value.length>'+ (CONFIG.comments.maxContentLength-100) +'?"#ef4444":"";});' +'var sb=document.getElementById("cBtn");sb.addEventListener("click",function(){var n=document.getElementById("cName").value.trim(),c=document.getElementById("cContent").value.trim(),hp=document.getElementById("cHp").value;if(!n){toast("请输入昵称","err");return;}if(!c){toast("请输入评论内容","err");return;}sb.disabled=true;sb.textContent="发送中...";fetch("/api/comments",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({slug:slug,name:n,content:c,honeypot:hp})}).then(function(r){return r.json();}).then(function(d){if(d.success){document.getElementById("cContent").value="";document.getElementById("cCount").textContent="0";lc();toast("评论发表成功","ok");}else toast(d.error||"发送失败","err");}).catch(function(){toast("网络错误","err");}).finally(function(){sb.disabled=false;sb.textContent="发表评论";});});' +toastJS()+darkModeScript()+'})();'; 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>'+escapeHtml(meta.title)+' - '+escapeHtml(CONFIG.siteName)+'</title><meta name="description" content="'+escapeHtml(meta.summary||"")+'"><meta property="og:title" content="'+escapeHtml(meta.title)+'"><meta property="og:type" content="article"><link href="'+CONFIG.cdn.prismTheme+'" rel="stylesheet" integrity="'+CONFIG.cdn.prismThemeSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"><style>'+baseCSS()+'@media(max-width:900px){.toc-wrap{display:none}}</style></head><body><div class="reading-progress" id="rp"></div>'+renderNav()+'<div class="layout"><main class="content"><header class="post-header"><h1 class="post-title">'+escapeHtml(meta.title)+'</h1><div class="post-meta"><span>📅 '+escapeHtml(meta.date||"")+'</span>'+(meta.category?'<span>📂 '+escapeHtml(meta.category)+'</span>':"")+'<span>📝 '+wc+' 字</span><span>⏱️ '+rt+'</span><span>👁️ '+views+'</span></div></header><noscript><div class="post-body">'+escapeHtml(pc)+'</div></noscript><article id="postBody" class="post-body">'+skeleton+'</article>'+th+nh+cs+'<textarea id="raw-md" style="display:none">'+escapeHtml(pc)+'</textarea></main><aside class="sidebar toc-wrap"><div class="side-card toc"><div class="toc-title">📑 目录</div><ul class="toc-list" id="tocList"></ul></div></aside></div>'+renderFooter()+'<button class="back-top" id="backTop" aria-label="返回顶部">↑</button><script src="'+CONFIG.cdn.marked+'" integrity="'+CONFIG.cdn.markedSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"></script><script src="'+CONFIG.cdn.prism+'" integrity="'+CONFIG.cdn.prismSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"></script><script src="'+CONFIG.cdn.prismJS+'" integrity="'+CONFIG.cdn.prismJSSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"></script><script nonce="'+nonce+'">'+detailScript+'</script></body></html>'; return new Response(html,{headers:{"Content-Type":"text/html; charset=utf-8"}}); } function renderLoginView(nonce) { const loginScript='document.getElementById("lf").addEventListener("submit",async function(e){e.preventDefault();var r=await fetch("/admin/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:document.getElementById("pw").value})});var d=await r.json();if(r.status===200&&d.success)window.location.href="/admin";else{var el=document.getElementById("em");el.textContent=d.error||"失败";el.style.display="block";}});'; return new Response('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>登录 - '+escapeHtml(CONFIG.siteName)+'</title><style>'+cssVars()+cssDarkVars()+'*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,"Noto Sans SC",sans-serif;background:var(--bg-page);display:flex;align-items:center;justify-content:center;height:100vh}.card{background:var(--bg-card);padding:2.5rem;border-radius:var(--radius);box-shadow:var(--shadow);width:100%;max-width:360px}h1{font-size:1.4rem;font-weight:800;text-align:center;margin-bottom:1.5rem}label{display:block;font-size:.85rem;color:var(--text-secondary);margin-bottom:.3rem}input{width:100%;padding:.6rem .8rem;border:1px solid var(--border);border-radius:6px;font-size:1rem;background:var(--bg-page);color:var(--text-primary);margin-bottom:1rem}input:focus{outline:none;border-color:var(--accent)}button{width:100%;background:var(--accent);color:#fff;padding:.7rem;border:none;border-radius:6px;font-size:1rem;font-weight:600;cursor:pointer}button:hover{background:var(--accent-hover)}.error{color:#dc2626;font-size:.85rem;text-align:center;margin-top:1rem;display:none}</style></head><body><div class="card"><h1>⚡ '+escapeHtml(CONFIG.siteName)+'</h1><form id="lf"><label>管理员密码</label><input type="password" id="pw" required><button type="submit">确认登录</button></form><p id="em" class="error"></p></div><script nonce="'+nonce+'">'+loginScript+'</script></body></html>',{headers:{"Content-Type":"text/html; charset=utf-8"}}); } // ═══════════════════════════════════════════ // 后台面板 (经典黑白 + 独立日/夜切换) // ═══════════════════════════════════════════ function adminCSS() { return cssVars() + cssDarkVars() + ` *{margin:0;padding:0;box-sizing:border-box} body{font-family:-apple-system,"Noto Sans SC",sans-serif;background:var(--bg-page);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;padding:16px;gap:16px;transition:background .3s,color .3s} a{color:var(--accent);text-decoration:none} .side{width:270px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);display:flex;flex-direction:column;height:calc(100vh - 32px);transition:background .3s,border-color .3s} .side-hd{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center} .side-hd h1{font-size:1.05rem;font-weight:800} .side-hd .acts{display:flex;gap:8px;align-items:center} .side-hd .acts a{font-size:.72rem;color:#dc2626} .theme-btn{background:var(--bg-page);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-size:.8rem;cursor:pointer;color:var(--text-secondary);font-weight:600;transition:all .2s;display:inline-flex;align-items:center;gap:6px} .theme-btn:hover{border-color:var(--accent);color:var(--accent)} .side-btns{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:8px} .btn{padding:9px 14px;border:none;border-radius:8px;font-size:.8rem;font-weight:600;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;justify-content:center;gap:5px} .btn-p{background:var(--accent);color:#fff}.btn-p:hover{background:var(--accent-hover)} .btn-p:disabled{opacity:.6;cursor:not-allowed} .btn-g{background:var(--bg-page);color:var(--text-secondary);border:1px solid var(--border)}.btn-g:hover{border-color:var(--accent);color:var(--accent)} .btn-d{background:transparent;color:#dc2626;border:1px solid #fecaca}.btn-d:hover{background:rgba(220,38,38,.08)} [data-theme="dark"] .btn-d{border-color:rgba(220,38,38,.35)} .btn-row{display:flex;gap:8px}.btn-row .btn{flex:1} .pfilter{width:100%;padding:8px 12px;margin:12px 16px 0;border:1px solid var(--border);border-radius:8px;font-size:.8rem;background:var(--bg-page);color:var(--text-primary);width:calc(100% - 32px)} .pfilter:focus{outline:none;border-color:var(--accent)} .slist{flex:1;overflow-y:auto;padding:12px 16px} .di{padding:11px 13px;border-radius:8px;cursor:pointer;margin-bottom:6px;border:1px solid transparent;transition:all .2s;background:var(--bg-page)} .di:hover{border-color:var(--border);transform:translateX(2px)} .di.on{background:var(--accent);color:#fff;border-color:var(--accent)} .di h3{font-size:.82rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .di span{font-size:.7rem;color:var(--text-muted);margin-top:3px;display:block} .di.on span{color:rgba(255,255,255,.7)} .main{flex:1;display:flex;flex-direction:column;height:calc(100vh - 32px);background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;transition:background .3s,border-color .3s} .tb{padding:10px 20px;border-bottom:1px solid var(--border);background:var(--bg-page);display:flex;gap:4px;flex-wrap:wrap;align-items:center} .tb button{padding:5px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-card);color:var(--text-secondary);font-size:.78rem;cursor:pointer;font-weight:600;transition:all .15s} .tb button:hover{border-color:var(--accent);color:var(--accent)} .tb .sep{width:1px;height:18px;background:var(--border);margin:0 6px} .tb .vm.on{background:var(--accent);color:#fff;border-color:var(--accent)} .meta{padding:12px 20px;border-bottom:1px solid var(--border);display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px} .meta label{display:block;font-size:.7rem;color:var(--text-muted);margin-bottom:3px;font-weight:600} .meta input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:.83rem;background:var(--bg-page);color:var(--text-primary);transition:all .2s} .meta input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(0,102,204,.1)} .mfull{padding:8px 20px;border-bottom:1px solid var(--border)} .mfull label{display:block;font-size:.7rem;color:var(--text-muted);margin-bottom:3px;font-weight:600} .mfull input,.mfull textarea{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:.83rem;background:var(--bg-page);color:var(--text-primary);margin-top:3px;transition:all .2s} .mfull input:focus,.mfull textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(0,102,204,.1)} .mfull textarea{resize:vertical;min-height:44px} .crow{display:flex;gap:8px;align-items:center}.crow input{flex:1} .cpv{max-height:70px;border-radius:6px;margin-top:8px;display:none;box-shadow:var(--shadow)} .panes{flex:1;display:flex;overflow:hidden} .pane{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0} .pane:first-child{border-right:1px solid var(--border)} .ph{padding:8px 16px;font-size:.7rem;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid var(--border);background:var(--bg-page)} #md{flex:1;width:100%;padding:20px;border:none;resize:none;font-family:"JetBrains Mono","Fira Code",monospace;font-size:.88rem;line-height:1.75;background:var(--bg-card);color:var(--text-primary)} #md:focus{outline:none} #pv{flex:1;overflow-y:auto;padding:20px;background:var(--bg-card)} #pv h1{font-size:1.5rem;font-weight:800;margin:1.4rem 0 .7rem;border-bottom:1px solid var(--border);padding-bottom:.3rem} #pv h2{font-size:1.25rem;font-weight:700;margin:1.1rem 0 .5rem} #pv h3{font-size:1.05rem;font-weight:700;margin:.9rem 0 .4rem} #pv p{margin-bottom:.9rem;line-height:1.8} #pv ul,#pv ol{margin:0 0 .9rem 1.4rem} #pv blockquote{border-left:4px solid var(--accent);padding:8px 14px;margin:.9rem 0;background:var(--bg-page);border-radius:0 6px 6px 0} #pv pre{border-radius:8px;padding:14px;margin:.9rem 0;overflow-x:auto;font-size:.83rem;position:relative} #pv code{font-family:"JetBrains Mono","Fira Code",monospace;font-size:.85em} #pv p code{background:var(--bg-page);padding:2px 5px;border-radius:3px} #pv pre code{background:none;padding:0} #pv img{max-width:100%;border-radius:6px} #pv table{width:100%;border-collapse:collapse;margin:.9rem 0} #pv th,#pv td{border:1px solid var(--border);padding:7px 11px;font-size:.85rem} #pv th{background:var(--bg-page)} .ccb{position:absolute;top:6px;right:6px;padding:3px 7px;font-size:.68rem;background:rgba(255,255,255,.15);color:#fff;border:1px solid rgba(255,255,255,.25);border-radius:4px;cursor:pointer;opacity:0;transition:opacity .2s} #pv pre:hover .ccb{opacity:1} .status{padding:7px 20px;border-top:1px solid var(--border);background:var(--bg-page);display:flex;justify-content:space-between;font-size:.72rem;color:var(--text-muted)} .ft{padding:12px 20px;border-top:1px solid var(--border);background:var(--bg-card);display:flex;justify-content:space-between;align-items:center} body.mode-edit .pane:last-child{display:none} body.mode-edit .pane:first-child{border-right:none} body.mode-preview .pane:first-child{display:none} body.fullscreen{padding:0} body.fullscreen .side{display:none} body.fullscreen .meta,body.fullscreen .mfull{display:none} body.fullscreen .main{height:100vh;border-radius:0;border:none} @media(max-width:768px){.side{width:200px}.panes{flex-direction:column}.pane:first-child{border-right:none;border-bottom:1px solid var(--border)}.meta{grid-template-columns:1fr 1fr}} ` + toastCSS() + trendCSS(); } async function renderDashboardView(env, nonce) { let posts=[]; try{const r=await safeKVGet(env.BLOG_KV,"post:list_index");posts=r?JSON.parse(r):[];}catch(e){console.error(e.message);} const si=posts.map(p=>'<div class="di" data-slug="'+escapeHtml(p.slug)+'"><h3>'+escapeHtml(p.title)+'</h3><span>📅 '+escapeHtml(p.date||"")+'</span></div>').join(""); const script = 'var posts='+JSON.stringify(posts).replace(/</g,"\\u003c")+';' +'var cur="",editor=document.getElementById("md"),preview=document.getElementById("pv"),pt=null;' // --- 后台独立日/夜切换 (admin-theme) --- +'var at=document.getElementById("adminTheme");var as=localStorage.getItem("admin-theme");if(as)document.documentElement.setAttribute("data-theme",as);' +'function aui(){var dark=document.documentElement.getAttribute("data-theme")==="dark"||(document.documentElement.getAttribute("data-theme")!=="light"&&window.matchMedia("(prefers-color-scheme:dark)").matches);at.textContent=dark?"☀️ 日间模式":"🌙 夜间模式";}' +'aui();' +'at.addEventListener("click",function(){var dark=document.documentElement.getAttribute("data-theme")==="dark"||(document.documentElement.getAttribute("data-theme")!=="light"&&window.matchMedia("(prefers-color-scheme:dark)").matches);var n=dark?"light":"dark";document.documentElement.setAttribute("data-theme",n);localStorage.setItem("admin-theme",n);aui();});' // --- 预览引擎 --- +'function acb(c){c.querySelectorAll("pre").forEach(function(pre){if(pre.querySelector(".ccb"))return;pre.style.position="relative";var b=document.createElement("button");b.className="ccb";b.textContent="Copy";b.addEventListener("click",function(){var code=pre.querySelector("code")?pre.querySelector("code").innerText:pre.innerText;navigator.clipboard.writeText(code).then(function(){b.textContent="✓";setTimeout(function(){b.textContent="Copy";},1500);});});pre.appendChild(b);});}' +'function up(){preview.innerHTML=marked.parse(editor.value||"*输入 Markdown 开始预览*");Prism.highlightAllUnder(preview);acb(preview);us();}' +'editor.addEventListener("input",function(){clearTimeout(pt);pt=setTimeout(up,120);saveDraft();});' +'editor.addEventListener("scroll",function(){var r=editor.scrollTop/(editor.scrollHeight-editor.clientHeight||1);preview.scrollTop=r*(preview.scrollHeight-preview.clientHeight);});' +'editor.addEventListener("keyup",uc);editor.addEventListener("click",uc);' +'function us(){var t=editor.value;var cn=(t.match(/[\\u4e00-\\u9fa5]/g)||[]).length;var en=(t.replace(/[\\u4e00-\\u9fa5]/g,"").match(/[a-zA-Z0-9]+/g)||[]).length;var ln=t.split("\\n").length;var rd=Math.ceil((cn+en)/300);document.getElementById("sL").textContent="字数: "+(cn+en)+" | 行数: "+ln+" | 约 "+rd+" 分钟";}' +'function uc(){var p=editor.selectionStart;var t=editor.value.slice(0,p);var ln=t.split("\\n").length;var col=p-t.lastIndexOf("\\n");document.getElementById("sR").textContent="Ln "+ln+", Col "+col;}' +'function ins(pre,suf,ph){var s=editor.selectionStart,e=editor.selectionEnd;var sel=editor.value.slice(s,e)||ph;editor.value=editor.value.slice(0,s)+pre+sel+suf+editor.value.slice(e);editor.focus();editor.selectionStart=s+pre.length;editor.selectionEnd=s+pre.length+sel.length;editor.dispatchEvent(new Event("input"));}' +'var acts={bold:function(){ins("**","**","粗体");},italic:function(){ins("*","*","斜体");},strike:function(){ins("~~","~~","删除线");},h2:function(){ins("\\n## ","","标题");},h3:function(){ins("\\n### ","","标题");},link:function(){ins("[","](https://)","文本");},img:function(){ins("","描述");},code:function(){ins("\\n```javascript\\n","\\n```\\n","// code");},inline:function(){ins("`","`","code");},quote:function(){ins("\\n> ","","引用");},ul:function(){ins("\\n- ","","列表");},ol:function(){ins("\\n1. ","","列表");},task:function(){ins("\\n- [ ] ","","待办");},hr:function(){ins("\\n---\\n","","");},table:function(){ins("\\n| A | B | C |\\n|---|---|---|\\n| 1 | 2 | 3 |\\n","","");}};' +'document.getElementById("tb").addEventListener("click",function(e){var b=e.target.closest("[data-a]");if(b&&acts[b.dataset.a])acts[b.dataset.a]();});' +'editor.addEventListener("keydown",function(e){if(e.key==="Tab"){e.preventDefault();var s=this.selectionStart;this.value=this.value.slice(0,s)+" "+this.value.slice(this.selectionEnd);this.selectionStart=this.selectionEnd=s+2;this.dispatchEvent(new Event("input"));return;}if(e.ctrlKey||e.metaKey){if(e.key==="b"){e.preventDefault();acts.bold();}else if(e.key==="i"){e.preventDefault();acts.italic();}else if(e.key==="k"&&!e.shiftKey){e.preventDefault();acts.link();}else if(e.key==="k"&&e.shiftKey){e.preventDefault();acts.code();}else if(e.key==="s"){e.preventDefault();doSave();}}});' +'function setMode(m){document.body.className=m;document.querySelectorAll(".vm").forEach(function(b){b.classList.toggle("on",b.dataset.m===m);});}' +'document.querySelectorAll(".vm").forEach(function(b){b.addEventListener("click",function(){setMode(b.dataset.m);});});' // --- 自动草稿 --- +'var draftTimer=null;function saveDraft(){clearTimeout(draftTimer);draftTimer=setTimeout(function(){if(!cur){localStorage.setItem("hbb-draft",JSON.stringify({t:document.getElementById("f-title").value,s:document.getElementById("f-slug").value,c:document.getElementById("f-cat").value,d:document.getElementById("f-date").value,tg:document.getElementById("f-tags").value,sm:document.getElementById("f-summary").value,cv:document.getElementById("f-cover").value,md:editor.value}));document.getElementById("sR").textContent="草稿已保存";}},1500);}' +'function loadDraft(){var d=localStorage.getItem("hbb-draft");if(!d)return;try{var o=JSON.parse(d);if(o.md&&!cur){document.getElementById("f-title").value=o.t||"";document.getElementById("f-slug").value=o.s||"";document.getElementById("f-cat").value=o.c||"";document.getElementById("f-date").value=o.d||"";document.getElementById("f-tags").value=o.tg||"";document.getElementById("f-summary").value=o.sm||"";document.getElementById("f-cover").value=o.cv||"";editor.value=o.md;up();}}catch(e){}}' // --- 图片上传 (带进度反馈) --- +'async function uploadFile(file){var bmp=await createImageBitmap(file);var cv=document.createElement("canvas");var sc=Math.min(1,1920/bmp.width);cv.width=bmp.width*sc;cv.height=bmp.height*sc;cv.getContext("2d").drawImage(bmp,0,0,cv.width,cv.height);var blob=await new Promise(function(r){cv.toBlob(r,"image/webp",0.85);});var cf=new File([blob],file.name.replace(/\\.\\w+$/,".webp"),{type:"image/webp"});var fd=new FormData();fd.append("file",cf);var res=await fetch("/admin/upload",{method:"POST",body:fd});return await res.json();}' +'async function uploadWithFeedback(file){var marker="up-"+Date.now();ins("","图片");try{var d=await uploadFile(file);if(d.success){editor.value=editor.value.replace("/media/"+marker,d.url);editor.dispatchEvent(new Event("input"));toast("图片已上传","ok");}else{editor.value=editor.value.replace("","");editor.dispatchEvent(new Event("input"));toast(d.error||"上传失败","err");}}catch(err){editor.value=editor.value.replace("","");editor.dispatchEvent(new Event("input"));toast("上传失败","err");}}' +'document.getElementById("btnUp").addEventListener("click",function(){document.getElementById("fileIn").click();});' +'document.getElementById("fileIn").addEventListener("change",async function(e){var f=e.target.files[0];if(f)await uploadWithFeedback(f);e.target.value="";});' +'editor.addEventListener("dragover",function(e){e.preventDefault();editor.style.borderColor="var(--accent)";});' +'editor.addEventListener("dragleave",function(){editor.style.borderColor="";});' +'editor.addEventListener("drop",async function(e){e.preventDefault();editor.style.borderColor="";var f=e.dataTransfer.files[0];if(f&&f.type.startsWith("image/"))await uploadWithFeedback(f);});' // --- 封面 --- +'document.getElementById("btnCov").addEventListener("click",function(){document.getElementById("covIn").click();});' +'document.getElementById("covIn").addEventListener("change",async function(e){var f=e.target.files[0];if(!f)return;try{var d=await uploadFile(f);if(d.success){document.getElementById("f-cover").value=d.url;var p=document.getElementById("covPv");p.src=d.url;p.style.display="block";toast("封面已上传","ok");}else toast(d.error||"上传失败","err");}catch(err){toast("上传失败","err");}e.target.value="";});' +'document.getElementById("f-cover").addEventListener("input",function(){var p=document.getElementById("covPv");if(this.value){p.src=this.value;p.style.display="block";}else p.style.display="none";});' // --- 文章列表过滤 --- +'document.getElementById("postFilter").addEventListener("input",function(e){var kw=e.target.value.toLowerCase();document.querySelectorAll(".di").forEach(function(el){el.style.display=el.textContent.toLowerCase().indexOf(kw)>-1?"":"none";});});' // --- CRUD --- +'function newPost(){cur="";document.getElementById("f-title").value="";document.getElementById("f-slug").value="";document.getElementById("f-slug").disabled=false;document.getElementById("f-cat").value="";document.getElementById("f-tags").value="";document.getElementById("f-date").value=new Date().toISOString().split("T")[0];document.getElementById("f-summary").value="";document.getElementById("f-cover").value="";document.getElementById("covPv").style.display="none";editor.value="";document.getElementById("delBtn").style.display="none";document.querySelectorAll(".di").forEach(function(el){el.classList.remove("on");});localStorage.removeItem("hbb-draft");up();}' +'async function editPost(slug){var res=await fetch("/admin/get-post?slug="+encodeURIComponent(slug));if(res.status!==200){toast("获取文章失败","err");return;}var meta=posts.find(function(p){return p.slug===slug;});var data=await res.json();cur=slug;document.getElementById("f-title").value=(meta&&meta.title)||"";document.getElementById("f-slug").value=slug;document.getElementById("f-slug").disabled=true;document.getElementById("f-cat").value=(meta&&meta.category)||"";document.getElementById("f-tags").value=((meta&&meta.tags)||[]).join(",");document.getElementById("f-date").value=(meta&&meta.date)||"";document.getElementById("f-summary").value=(meta&&meta.summary)||"";document.getElementById("f-cover").value=(meta&&meta.cover)||"";var p=document.getElementById("covPv");if(meta&&meta.cover){p.src=meta.cover;p.style.display="block";}else p.style.display="none";editor.value=data.markdown||"";document.getElementById("delBtn").style.display="inline-flex";document.querySelectorAll(".di").forEach(function(el){el.classList.toggle("on",el.dataset.slug===slug);});up();}' // --- 保存 (状态机 + Toast) --- +'async function doSave(){var sb=document.getElementById("saveBtn");var sv=document.getElementById("f-slug").value;if(!/^[a-z0-9-]+$/.test(sv)){toast("Slug 仅限 a-z0-9-","err");return;}var ti=document.getElementById("f-title").value;if(!ti){toast("标题必填","err");return;}if(!editor.value){toast("正文必填","err");return;}sb.disabled=true;sb.textContent="⏳ 保存中...";var payload={title:ti,slug:sv,category:document.getElementById("f-cat").value,tags:document.getElementById("f-tags").value.split(",").map(function(t){return t.trim();}).filter(Boolean),date:document.getElementById("f-date").value,summary:document.getElementById("f-summary").value,markdown:editor.value,cover:document.getElementById("f-cover").value.trim()};try{var res=await fetch("/admin/save-post",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});var d=await res.json();if(d.success){localStorage.removeItem("hbb-draft");sb.textContent="✅ 已保存";toast("保存成功,CDN 已刷新","ok");setTimeout(function(){window.location.reload();},800);}else{sb.textContent="💾 保存 (Ctrl+S)";sb.disabled=false;toast(d.error||"保存失败","err");}}catch(e){sb.textContent="💾 保存 (Ctrl+S)";sb.disabled=false;toast("网络错误","err");}}' +'document.getElementById("btnNew").addEventListener("click",newPost);' +'document.getElementById("sideList").addEventListener("click",function(e){var it=e.target.closest(".di");if(it&&it.dataset.slug)editPost(it.dataset.slug);});' +'document.getElementById("saveBtn").addEventListener("click",doSave);' +'document.getElementById("delBtn").addEventListener("click",async function(){if(!cur||!confirm("确定删除?不可撤销。"))return;var res=await fetch("/admin/delete-post",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({slug:cur})});var d=await res.json();if(d.success){toast("已删除","ok");setTimeout(function(){window.location.reload();},600);}else toast(d.error||"删除失败","err");});' +'document.getElementById("btnCC").addEventListener("click",async function(){if(!confirm("清空 CDN 缓存?"))return;var r=await fetch("/admin/clean-cache",{method:"POST"});var d=await r.json();toast(d.message||d.error||"完成",d.success?"ok":"err");});' +'document.getElementById("btnRI").addEventListener("click",async function(){if(!confirm("重建索引?"))return;var r=await fetch("/admin/rebuild-index",{method:"POST"});var d=await r.json();if(d.success){toast("索引重建完成","ok");setTimeout(function(){window.location.reload();},600);}else toast(d.error||"失败","err");});' +toastJS() +'loadDraft();up();'; const html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>控制台 - '+escapeHtml(CONFIG.siteName)+'</title>' +'<link href="'+CONFIG.cdn.prismTheme+'" rel="stylesheet" integrity="'+CONFIG.cdn.prismThemeSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer">' +'<style>'+adminCSS()+'</style></head><body class="mode-split">' +'<aside class="side"><div class="side-hd"><h1>⚡ 控制台</h1><div class="acts"><button class="theme-btn" id="adminTheme">🌙 夜间模式</button><a href="/admin/logout">登出</a></div></div>' +'<div class="side-btns"><button class="btn btn-p" id="btnNew">📝 写新文章</button><div class="btn-row"><button class="btn btn-g" id="btnCC">🧹 CDN</button><button class="btn btn-g" id="btnRI">🔄 索引</button></div></div>' +'<input type="text" id="postFilter" class="pfilter" placeholder="🔍 过滤文章...">' +'<div class="slist" id="sideList">'+si+'</div></aside>' +'<main class="main">' +'<div class="tb" id="tb">' +'<button data-a="bold" title="Ctrl+B">B</button><button data-a="italic" title="Ctrl+I"><i>I</i></button><button data-a="strike" title="删除线"><s>S</s></button>' +'<span class="sep"></span>' +'<button data-a="h2">H2</button><button data-a="h3">H3</button>' +'<span class="sep"></span>' +'<button data-a="link" title="Ctrl+K">🔗</button><button data-a="img">🖼️</button><button data-a="code" title="Ctrl+Shift+K">⌨️</button><button data-a="inline">`</button><button data-a="quote">❝</button><button data-a="ul">•</button><button data-a="ol">1.</button><button data-a="task">☑</button><button data-a="hr">—</button><button data-a="table">▦</button>' +'<span class="sep"></span>' +'<button id="btnUp" title="上传图片">📤</button><input type="file" id="fileIn" accept="image/*" style="display:none">' +'<span class="sep"></span>' +'<button class="vm" data-m="mode-edit" title="纯编辑">✏️</button><button class="vm on" data-m="mode-split" title="分栏">📖</button><button class="vm" data-m="mode-preview" title="纯预览">👁️</button>' +'</div>' +'<div class="meta"><div><label>标题 *</label><input id="f-title"></div><div><label>Slug *</label><input id="f-slug"></div><div><label>分类</label><input id="f-cat"></div><div><label>日期</label><input type="date" id="f-date"></div></div>' +'<div class="mfull"><label>标签(逗号分隔)</label><input id="f-tags"></div>' +'<div class="mfull"><label>摘要</label><textarea id="f-summary" rows="2"></textarea></div>' +'<div class="mfull"><label>封面图</label><div class="crow"><input id="f-cover" placeholder="/media/xxx.webp"><button class="btn btn-g" id="btnCov" type="button">📷</button><input type="file" id="covIn" accept="image/*" style="display:none"></div><img id="covPv" class="cpv" alt=""></div>' +'<div class="panes"><div class="pane"><div class="ph">✏️ Markdown <span style="float:right;font-weight:400;text-transform:none">拖拽图片可直接上传</span></div><textarea id="md" placeholder="输入 Markdown...(Ctrl+S 保存)"></textarea></div><div class="pane"><div class="ph">👁️ 预览</div><div id="pv"></div></div></div>' +'<div class="status"><span id="sL">字数: 0 | 行数: 0</span><span id="sR">Ln 1, Col 1</span></div>' +'<div class="ft"><button class="btn btn-d" id="delBtn" style="display:none">❌ 删除</button><span style="flex:1"></span><button class="btn btn-p" id="saveBtn" style="padding:10px 28px">💾 保存 (Ctrl+S)</button></div>' +'</main>' +'<script src="'+CONFIG.cdn.marked+'" integrity="'+CONFIG.cdn.markedSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"></script>' +'<script src="'+CONFIG.cdn.prism+'" integrity="'+CONFIG.cdn.prismSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"></script>' +'<script src="'+CONFIG.cdn.prismJS+'" integrity="'+CONFIG.cdn.prismJSSRI+'" crossorigin="anonymous" referrerpolicy="no-referrer"></script>' +'<script nonce="'+nonce+'">'+script+'</script></body></html>'; return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } ```
↑
💬 评论