[{"data":1,"prerenderedAt":482},["ShallowReactive",2],{"content:\u002Ffiddling\u002Fvitepress-memos-component":3,"surround:\u002Ffiddling\u002Fvitepress-memos-component":471},{"id":4,"title":5,"body":6,"categories":446,"date":448,"description":449,"draft":450,"extension":451,"image":452,"meta":453,"navigation":455,"path":456,"permalink":452,"published":452,"readingTime":457,"recommend":452,"references":452,"seo":462,"sitemap":463,"stem":464,"tags":465,"type":469,"__hash__":470},"content\u002Fposts\u002Ffiddling\u002Fvitepress-memos-component.md","在 VitePress 中实现一个动态说说功能",{"type":7,"value":8,"toc":436},"minimark",[9,13,17,20,29,37,41,45,48,61,73,83,86,96,133,136,143,151,154,186,193,199,202,222,225,242,249,252,255,258,266,280,286,293,300,313,323,337,343,354,362,374,377,383,389,396,399,422,425,433],[10,11,12],"h3",{"id":12},"前言",[14,15,16],"p",{},"很多动态博客中都有一个说说的功能，本质就是一种特殊的博文，借助动态博客的实时性，可以做到随写随发",[14,18,19],{},"静态博客由于是在本地或服务器上静态编译成 html 后再部署，实时性比较差。写一篇博文长篇大论自然可以在电脑前，走 git 推送部署也不算麻烦，但是发一篇说说还要打开电脑，心智负担就有些重了，手机上操作 git 也比较麻烦，不是很优雅，干脆一想就不发算了",[14,21,22,23,28],{},"于是实现了一套说说系统的前后端，效果就是本博客的 ",[24,25,27],"a",{"href":26},"\u002Fmemos","碎碎念","。后端使用 CloudFlare Workers 实现，存储当然也就近存储在大善人的 KV 里，简单写了个管理页面。博客框架是 VitePress，前端也就做成了个 Vue 组件，直接嵌入一个页面作为说说页",[14,30,31,32],{},"前端效果不再多说，后端管理页面效果\n",[33,34],"img",{"alt":35,"src":36},"Memo 管理页面","https:\u002F\u002Fblog-img.774352199.xyz\u002F2025\u002F8551751fe98e55c4159d28b9ff5b9473.png",[10,38,40],{"id":39},"后端-cloudflare-workers-kv","后端 CloudFlare Workers + KV",[42,43,44],"h4",{"id":44},"基本概述",[14,46,47],{},"后端包含以下功能：",[49,50,51,55,58],"ul",{},[52,53,54],"li",{},"支持说说的增删改（基本功能）",[52,56,57],{},"页面和所有写接口都有鉴权，足够安全",[52,59,60],{},"Markdown 格式实时预览（by marked）",[14,62,63,64,68,69,72],{},"KV 中存储一个 ",[65,66,67],"code",{"code":67},"index"," key，value 是一个 uid 的数组，作为全部说说的索引。其他所有说说都存储在以 ",[65,70,71],{"code":71},"uid"," 为 key 的条目中，value 格式如",[74,75,81],"pre",{"className":76,"code":78,"language":79,"meta":80},[77],"language-js","{\n    \"uid\":\"唯一 id\",\n    \"createTime\":\"发布时间\",\n    \"content\":\"说说内容\",\n}\n","js","",[65,82,78],{"__ignoreMap":80},[42,84,85],{"id":85},"实现",[14,87,88,89,92,93],{},"首先要创建一个 CloudFlare 的 KV Space，专门存储说说相关的 KV 对。位置在",[65,90,91],{"code":91},"账户首页 - 存储和数据库 - KV","，点击创建，名字不太重要，记住就行了，我这里简单命名为 ",[65,94,95],{"code":95},"memos",[14,97,98,99,102,103,106,107,110,111,114,115,118,119,121,122,125,126,128,129,132],{},"接着就是创建 CloudFlare Workers，用于逻辑处理。位置在",[65,100,101],{"code":101},"账户首页 - 计算（Workers）- Workers 和 Pages","，点击创建，名字依然不太重要，我简单命名为 ",[65,104,105],{"code":105},"memos-api","。创建完成后，点击 Workers 名称进入 Workers 详情，在",[65,108,109],{"code":109},"设置 - 绑定","中添加一个绑定关系，选择绑定 ",[65,112,113],{"code":113},"KV 命名空间","，变量名称为 ",[65,116,117],{"code":117},"KV","，KV 命名空间选择刚刚创建的 KV Space 名称，我的是 ",[65,120,95],{"code":95},"。这样绑定完成后，就可以在代码中直接使用 ",[65,123,124],{"code":124},"env.KV"," 操作 ",[65,127,95],{"code":95}," 这个 KV 空间了。最后点击顶栏右侧的 ",[65,130,131],{"code":131},"编辑代码"," 按钮",[14,134,135],{},"下面就是 Code Time！",[14,137,138,139,142],{},"首先创建一个 ",[65,140,141],{"code":141},"index.html","，用来存放管理页面的 html、css 和 js",[74,144,149],{"className":145,"code":147,"language":148,"meta":80},[146],"language-html","\u003C!DOCTYPE html>\n\u003Chtml>\n\n\u003Chead>\n    \u003Ctitle>Memos 管理\u003C\u002Ftitle>\n    \u003Cmeta charset=\"UTF-8\">\n    \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    \u003Clink rel=\"stylesheet\" href=\"https:\u002F\u002Fcdnjs.cloudflare.com\u002Fajax\u002Flibs\u002Ffont-awesome\u002F6.0.0\u002Fcss\u002Fall.min.css\">\n    \u003Cstyle>\n        :root {\n            --primary-color: #2c3e50;\n            --secondary-color: #34495e;\n            --accent-color: #3498db;\n            --background-color: #f5f6fa;\n            --text-color: #2c3e50;\n            --border-color: #dcdde1;\n            --hover-color: #f1f2f6;\n        }\n\n        * {\n            margin: 0;\n            padding: 0;\n            box-sizing: border-box;\n        }\n\n        body {\n            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;\n            background-color: var(--background-color);\n            color: var(--text-color);\n            line-height: 1.6;\n        }\n\n        #auth-panel {\n            position: fixed;\n            top: 0;\n            left: 0;\n            width: 100%;\n            height: 100%;\n            background: rgba(255, 255, 255, 0.95);\n            display: flex;\n            justify-content: center;\n            align-items: center;\n            z-index: 1000;\n            backdrop-filter: blur(5px);\n        }\n\n        #auth-form {\n            background: white;\n            padding: 2rem;\n            border-radius: 10px;\n            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);\n            width: 300px;\n        }\n\n        #auth-form input {\n            width: 100%;\n            padding: 0.8rem;\n            margin-bottom: 1rem;\n            border: 1px solid var(--border-color);\n            border-radius: 5px;\n            font-size: 1rem;\n        }\n\n        .container {\n            max-width: 1400px;\n            margin: 2rem auto;\n            padding: 0 1rem;\n            display: flex;\n            gap: 2rem;\n            height: calc(100vh - 4rem);\n        }\n\n        .memo-list {\n            width: 350px;\n            background: white;\n            border-radius: 10px;\n            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n            display: flex;\n            flex-direction: column;\n        }\n\n        .memo-list-header {\n            padding: 1rem;\n            border-bottom: 1px solid var(--border-color);\n            font-weight: 600;\n            display: flex;\n            justify-content: space-between;\n            align-items: center;\n        }\n\n        .memo-items {\n            flex: 1;\n            overflow-y: auto;\n            padding: 0.5rem;\n        }\n\n        .memo-item {\n            padding: 1rem;\n            border-radius: 8px;\n            margin-bottom: 0.5rem;\n            cursor: pointer;\n            transition: all 0.2s ease;\n            border: 1px solid var(--border-color);\n            height: auto;\n            \u002F* 移除固定高度 *\u002F\n            overflow: hidden;\n            position: relative;\n            display: flex;\n            flex-direction: column;\n            gap: 0.5rem;\n        }\n\n        .memo-item:hover {\n            background-color: var(--hover-color);\n            transform: translateY(-2px);\n        }\n\n        .memo-item.active {\n            border-color: var(--accent-color);\n            background-color: var(--hover-color);\n        }\n\n        .memo-detail {\n            flex: 1;\n            background: white;\n            border-radius: 10px;\n            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n            display: flex;\n            flex-direction: column;\n        }\n\n        .memo-detail-header {\n            display: flex;\n            justify-content: space-between;\n            align-items: center;\n        }\n\n        .memo-item-header {\n            display: flex;\n            justify-content: space-between;\n            font-size: 0.8rem;\n            color: #666;\n            border-bottom: 1px solid var(--border-color);\n            padding-bottom: 0.5rem;\n        }\n\n        .memo-item-content {\n            font-size: 0.9rem;\n            line-height: 1.4;\n            max-height: 4.2em;\n            \u002F* 显示 3 行文本 *\u002F\n            overflow: hidden;\n            display: -webkit-box;\n            -webkit-line-clamp: 3;\n            -webkit-box-orient: vertical;\n        }\n\n        .memo-uid {\n            font-family: monospace;\n            color: var(--accent-color);\n        }\n\n        .memo-info {\n            font-size: 0.9rem;\n            color: #666;\n            margin-left: 10px;\n            margin-top: 10px;\n        }\n\n        .memo-content {\n            flex: 1;\n            display: flex;\n            flex-direction: column;\n            padding: 1rem;\n            gap: 1rem;\n        }\n\n        .memo-edit {\n            flex: 1;\n        }\n\n        .memo-edit textarea {\n            width: 100%;\n            height: 100%;\n            border: 1px solid var(--border-color);\n            border-radius: 5px;\n            padding: 1rem;\n            font-size: 1rem;\n            resize: vertical;\n            font-family: inherit;\n        }\n\n        .memo-preview {\n            flex: 1;\n            padding: 1rem;\n            border: 1px solid var(--border-color);\n            border-radius: 5px;\n            overflow-y: auto;\n            overflow-x: hidden;\n            background-color: var(--background-color);\n        }\n\n        .memo-preview img {\n            max-width: 100%;\n            max-height: 150px;\n            object-fit: contain;\n            display: block;\n            \u002F* 避免图片底部空隙 *\u002F\n            margin: 5px 0;\n        }\n\n        .memo-preview blockquote {\n            border-left: 2px solid #e2e2e3;\n            padding-left: 16px;\n            color: rgba(60, 60, 67, .78);\n        }\n\n        .memo-actions {\n            padding: 1rem;\n            border-top: 1px solid var(--border-color);\n            display: flex;\n            justify-content: flex-end;\n            gap: 1rem;\n        }\n\n        .pagination {\n            padding: 1rem;\n            display: flex;\n            justify-content: center;\n            align-items: center;\n            gap: 1rem;\n            border-top: 1px solid var(--border-color);\n        }\n\n        button {\n            padding: 0.5rem 1rem;\n            border: none;\n            border-radius: 5px;\n            cursor: pointer;\n            transition: all 0.2s ease;\n            font-size: 0.9rem;\n            background-color: var(--primary-color);\n            color: white;\n        }\n\n        button:hover {\n            opacity: 0.9;\n        }\n\n        button.secondary {\n            background-color: var(--secondary-color);\n        }\n\n        button.danger {\n            background-color: #e74c3c;\n        }\n\n        .create-btn {\n            padding: 0.5rem 1rem;\n            display: flex;\n            align-items: center;\n            gap: 0.5rem;\n            margin-right: 10px;\n            margin-top: 10px;\n        }\n\n        \u002F* Responsive Design *\u002F\n        @media (max-width: 768px) {\n            .container {\n                flex-direction: column;\n                height: auto;\n            }\n\n            .memo-list {\n                width: 100%;\n                height: 300px;\n            }\n\n            .memo-detail {\n                height: calc(100vh - 400px);\n            }\n\n            .memo-preview img {\n                max-height: 100px;\n            }\n        }\n\n        \u002F* Markdown Preview Styles *\u002F\n        .memo-preview h1,\n        .memo-preview h2,\n        .memo-preview h3 {\n            margin-top: 1rem;\n            margin-bottom: 0.5rem;\n        }\n\n        .memo-preview p {\n            margin-bottom: 1rem;\n        }\n\n        .memo-preview code {\n            background-color: #f8f9fa;\n            padding: 0.2rem 0.4rem;\n            border-radius: 3px;\n            font-family: monospace;\n        }\n\n        .memo-preview pre {\n            background-color: #f8f9fa;\n            padding: 1rem;\n            border-radius: 5px;\n            overflow-x: auto;\n        }\n\n        \u002F* Loading Spinner *\u002F\n        .loading {\n            display: inline-block;\n            width: 20px;\n            height: 20px;\n            border: 2px solid rgba(0, 0, 0, 0.1);\n            border-radius: 50%;\n            border-top-color: var(--accent-color);\n            animation: spin 1s ease-in-out infinite;\n        }\n\n        @keyframes spin {\n            to {\n                transform: rotate(360deg);\n            }\n        }\n    \u003C\u002Fstyle>\n\u003C\u002Fhead>\n\n\u003Cbody>\n    \u003Cdiv id=\"auth-panel\">\n        \u003Cform id=\"auth-form\">\n            \u003Ch2 style=\"margin-bottom: 1rem;\">Memos 管理\u003C\u002Fh2>\n            \u003Cinput type=\"password\" id=\"password\" placeholder=\"Enter password\" required>\n            \u003Cbutton type=\"submit\" style=\"width: 100%\">登录\u003C\u002Fbutton>\n        \u003C\u002Fform>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"container\">\n        \u003Cdiv class=\"memo-list\">\n            \u003Cdiv class=\"memo-list-header\">\n                \u003Cspan>已发布\u003C\u002Fspan>\n                \u003Cspan id=\"memo-count\">\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n            \u003Cdiv class=\"memo-items\" id=\"memo-items\">\u003C\u002Fdiv>\n            \u003Cdiv class=\"pagination\">\n                \u003Cbutton onclick=\"prevPage()\" class=\"secondary\">\n                    \u003Ci class=\"fas fa-chevron-left\">\u003C\u002Fi>\n                \u003C\u002Fbutton>\n                \u003Cspan id=\"page-info\">\u003C\u002Fspan>\n                \u003Cbutton onclick=\"nextPage()\" class=\"secondary\">\n                    \u003Ci class=\"fas fa-chevron-right\">\u003C\u002Fi>\n                \u003C\u002Fbutton>\n            \u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n\n        \u003Cdiv class=\"memo-detail\">\n            \u003Cdiv class=\"memo-detail-header\">\n                \u003Cdiv class=\"memo-info\" id=\"memo-info\">新 Memo\u003C\u002Fdiv>\n                \u003Cbutton class=\"create-btn\" onclick=\"createMemo()\">\n                    \u003Ci class=\"fas fa-plus\">\u003C\u002Fi> 发布新 Memo\n                \u003C\u002Fbutton>\n            \u003C\u002Fdiv>\n            \u003Cdiv class=\"memo-content\">\n                \u003Cdiv class=\"memo-edit\">\n                    \u003Ctextarea id=\"memo-content\" placeholder=\"Write your memo here...\">\u003C\u002Ftextarea>\n                \u003C\u002Fdiv>\n                \u003Cdiv class=\"memo-preview\" id=\"memo-preview\">\u003C\u002Fdiv>\n            \u003C\u002Fdiv>\n            \u003Cdiv class=\"memo-actions\">\n                \u003Cbutton onclick=\"saveMemo()\" id=\"save-btn\">\n                    \u003Ci class=\"fas fa-save\">\u003C\u002Fi> 保存\n                \u003C\u002Fbutton>\n                \u003Cbutton onclick=\"deleteMemo()\" class=\"danger\" id=\"delete-btn\">\n                    \u003Ci class=\"fas fa-trash\">\u003C\u002Fi> 删除\n                \u003C\u002Fbutton>\n            \u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cscript src=\"https:\u002F\u002Fcdnjs.cloudflare.com\u002Fajax\u002Flibs\u002Fmarked\u002F2.0.3\u002Fmarked.min.js\">\u003C\u002Fscript>\n    \u003C!-- JavaScript 代码与之前相同，但需要添加以下功能增强 -->\n    \u003Cscript>\n        let password = '';\n        let currentMemo = null;\n        let offset = 0;\n        const limit = 10;\n        let total = 0;\n        let currentPageMap = {};\n\n        \u002F\u002F Authentication\n        document.getElementById('auth-form').addEventListener('submit', async (e) => {\n            e.preventDefault();\n            password = document.getElementById('password').value;\n            try {\n                const response = await fetch('\u002Fapi\u002Fauth', {\n                    method: 'POST',\n                    headers: {\n                        'Content-Type': 'application\u002Fjson',\n                    },\n                    body: JSON.stringify({ password }),\n                });\n                if (response.ok) {\n                    document.getElementById('auth-panel').style.display = 'none';\n                    loadMemos();\n                } else {\n                    showNotification('密码错误', 'error');\n                }\n            } catch (error) {\n                showNotification('密码错误', 'error');\n            }\n        });\n\n        \u002F\u002F Load memos\n        async function loadMemos() {\n            try {\n                const response = await fetch(`\u002Fapi\u002Fmemos?offset=${offset}&limit=${limit}`);\n                const data = await response.json();\n                displayMemos(data.data);\n                currentPageMap = data.data.reduce((acc, item) => {\n                    acc[item.uid] = item;\n                    return acc;\n                }, {})\n                total = data.total;\n                updatePagination();\n                document.getElementById('memo-count').textContent = `${total} memos`;\n            } catch (error) {\n                showNotification('加载列表错误', 'error');\n            }\n        }\n\n        function displayMemos(memos) {\n            const container = document.getElementById('memo-items');\n            container.innerHTML = memos.map(memo => `\n        \u003Cdiv class=\"memo-item\" data-id=\"${memo.uid}\" onclick=\"selectMemo('${memo.uid}')\">\n            \u003Cdiv class=\"memo-item-header\">\n                \u003Cspan class=\"memo-uid\">${memo.uid.slice(0, 8)}...\u003C\u002Fspan>\n                \u003Cspan>${new Date(memo.createTime).toLocaleString()}\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n            \u003Cdiv class=\"memo-item-content\">\n                ${escapeHtml(memo.content)}\n            \u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n    `).join('');\n        }\n\n        async function selectMemo(uid) {\n            try {\n                const memo = currentPageMap[uid];\n                currentMemo = memo;\n                displayMemoDetail(memo);\n\n                \u002F\u002F Update selected state\n                document.querySelectorAll('.memo-item').forEach(item => {\n                    item.classList.remove('active');\n                });\n                document.querySelector(`.memo-item[data-id=\"${uid}\"]`)?.classList.add('active');\n            } catch (error) {\n                showNotification('加载 Memo 错误', 'error');\n            }\n        }\n\n        function displayMemoDetail(memo) {\n            document.getElementById('memo-info').innerHTML = memo.uid;\n            document.getElementById('memo-content').value = memo.content;\n            updatePreview();\n        }\n\n        function updatePreview() {\n            const content = document.getElementById('memo-content').value;\n            document.getElementById('memo-preview').innerHTML = marked(content);\n        }\n\n        document.getElementById('memo-content').addEventListener('input', updatePreview);\n\n        async function saveMemo() {\n            const content = document.getElementById('memo-content').value;\n            if (!content.trim()) {\n                showNotification('Memo 内容不得为空', 'error');\n                return;\n            }\n\n            try {\n                showLoading(true);\n\n                if (currentMemo) {\n                    \u002F\u002F Update existing memo\n                    await fetch(`\u002Fapi\u002Fmemos\u002F${currentMemo.uid}`, {\n                        method: 'PUT',\n                        headers: {\n                            'Authorization': password,\n                            'Content-Type': 'application\u002Fjson'\n                        },\n                        body: JSON.stringify({ content })\n                    });\n                } else {\n                    \u002F\u002F Create new memo\n                    await fetch('\u002Fapi\u002Fmemos', {\n                        method: 'POST',\n                        headers: {\n                            'Authorization': password,\n                            'Content-Type': 'application\u002Fjson'\n                        },\n                        body: JSON.stringify({ content })\n                    });\n                }\n\n                showNotification('保存 Memo 成功');\n                loadMemos();\n            } catch (error) {\n                showNotification('保存 Memo 失败', 'error');\n            } finally {\n                showLoading(false);\n            }\n        }\n\n        async function deleteMemo() {\n            if (!currentMemo) return;\n\n            if (confirm('确定要删除这条 Memo 吗？')) {\n                try {\n                    showLoading(true);\n                    await fetch(`\u002Fapi\u002Fmemos\u002F${currentMemo.uid}`, {\n                        method: 'DELETE',\n                        headers: {\n                            'Authorization': password\n                        }\n                    });\n                    showNotification('删除 Memo 成功');\n                    loadMemos();\n                    clearMemoDetail();\n                } catch (error) {\n                    showNotification('删除 Memo 失败', 'error');\n                } finally {\n                    showLoading(false);\n                }\n            }\n        }\n\n        function createMemo() {\n            currentMemo = null;\n            clearMemoDetail();\n        }\n\n        function clearMemoDetail() {\n            document.getElementById('memo-info').innerHTML = '新 Memo';\n            document.getElementById('memo-content').value = '';\n            document.getElementById('memo-preview').innerHTML = '';\n        }\n\n        function prevPage() {\n            if (offset - limit >= 0) {\n                offset -= limit;\n                loadMemos();\n            }\n        }\n\n        function nextPage() {\n            if (offset + limit \u003C total) {\n                offset += limit;\n                loadMemos();\n            }\n        }\n\n        function updatePagination() {\n            const currentPage = Math.floor(offset \u002F limit) + 1;\n            const totalPages = Math.ceil(total \u002F limit);\n            document.getElementById('page-info').textContent =\n                `Page ${currentPage} of ${totalPages}`;\n        }\n\n        function showLoading(show) {\n            const saveBtn = document.getElementById('save-btn');\n            if (show) {\n                saveBtn.innerHTML = '\u003Cdiv class=\"loading\">\u003C\u002Fdiv> 保存中...';\n                saveBtn.disabled = true;\n            } else {\n                saveBtn.innerHTML = '\u003Ci class=\"fas fa-save\">\u003C\u002Fi> 保存';\n                saveBtn.disabled = false;\n            }\n        }\n\n        function showNotification(message, type = 'success') {\n            const notification = document.createElement('div');\n            notification.className = `notification ${type}`;\n            notification.textContent = message;\n            notification.style.position = 'fixed';\n            notification.style.top = '20px';\n            notification.style.right = '20px';\n            notification.style.padding = '1rem';\n            notification.style.borderRadius = '5px';\n            notification.style.backgroundColor = type === 'success' ? '#2ecc71' : '#e74c3c';\n            notification.style.color = 'white';\n            notification.style.zIndex = '1000';\n            document.body.appendChild(notification);\n            setTimeout(() => notification.remove(), 3000);\n        }\n\n        \u002F\u002F 用于防止 XSS 攻击的辅助函数\n        function escapeHtml(html) {\n            const div = document.createElement('div');\n            div.textContent = html;\n            return div.innerHTML;\n        }\n\n        \u002F\u002F 初始化 marked 配置\n        marked.setOptions({\n            breaks: true,\n            gfm: true,\n            headerIds: false\n        });\n    \u003C\u002Fscript>\n\u003C\u002Fbody>\n\n\u003C\u002Fhtml>\n","html",[65,150,147],{"__ignoreMap":80},[14,152,153],{},"从 JS 代码中即可看出，后端包含如下两个端点",[49,155,156,162,168,174,180],{},[52,157,158,161],{},[65,159,160],{"code":160},"POST \u002Fapi\u002Fauth","：页面鉴权",[52,163,164,167],{},[65,165,166],{"code":166},"GET \u002Fapi\u002Fmemos","：获取说说详情，支持分页",[52,169,170,173],{},[65,171,172],{"code":172},"POST \u002Fapi\u002Fmemos",": 发布新说说",[52,175,176,179],{},[65,177,178],{"code":178},"PUT \u002Fapi\u002Fmemos\u002F{uid}",": 更新说说",[52,181,182,185],{},[65,183,184],{"code":184},"DELETE \u002Fapi\u002Fmemos\u002F{uid}","：删除说说",[14,187,188,189,192],{},"随后编辑 ",[65,190,191],{"code":191},"worker.js"," 实现这些端点即可",[74,194,197],{"className":195,"code":196,"language":79,"meta":80},[77],"import html from '.\u002Findex.html';\n\nconst CORRECT_PASSWORD = 'CORRECT_PASSWORD';        \u002F\u002F 设置你的密码    \u002F\u002F [!code highlight]\nconst CALLBACK_URL = 'https:\u002F\u002FCALLBACK_URL';        \u002F\u002F 设置回调 URL   \u002F\u002F [!code highlight]\nconst ALLOWED_ORIGINS = ['https:\u002F\u002Fexample.com'];    \u002F\u002F 允许请求的域名  \u002F\u002F [!code highlight]\n\n\u002F\u002F 生成随机 UID\nfunction generateUID() {\n  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n  let result = '';\n  for (let i = 0; i \u003C 22; i++) {\n    const randomIndex = Math.floor(Math.random() * chars.length);\n    result += chars[randomIndex];\n  }\n  return result;\n}\n\u002F\u002F CORS 处理\nfunction handleCORS(request) {\n  const origin = request.headers.get('Origin');\n  const allowedOrigin = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];\n\n  const corsHeaders = {\n    'Access-Control-Allow-Origin': allowedOrigin,\n    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',\n    'Access-Control-Allow-Headers': 'Content-Type, Authorization',\n    'Access-Control-Max-Age': '86400',\n  };\n  return corsHeaders;\n}\nfunction getCurrentTimeInISOFormat() {\n  const now = new Date();\n  \u002F\u002F 获取各个部分\n  const year = now.getUTCFullYear();\n  const month = String(now.getUTCMonth() + 1).padStart(2, '0'); \u002F\u002F 月份从零开始\n  const day = String(now.getUTCDate()).padStart(2, '0');\n  const hours = String(now.getUTCHours()).padStart(2, '0');\n  const minutes = String(now.getUTCMinutes()).padStart(2, '0');\n  const seconds = String(now.getUTCSeconds()).padStart(2, '0');\n  \u002F\u002F 组装成 ISO 8601 格式字符串\n  return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`;\n}\nasync function handleRequest(request, env) {\n  const url = new URL(request.url);\n  function validateAuth(request) {\n    const auth = request.headers.get('Authorization');\n    return auth === CORRECT_PASSWORD;\n  }\n  async function shouldNotify(uid) {\n    const indexStr = await env.KV.get('index');\n    if (!indexStr) return false;\n    const index = JSON.parse(indexStr);\n    return index.indexOf(uid) \u003C 10;\n  }\n  async function executeCallback() {\n    try {\n      await fetch(CALLBACK_URL);\n    } catch (error) {\n      console.error('Callback failed:', error);\n    }\n  }\n  const corsHeaders = handleCORS(request);\n  \n  \u002F\u002F 处理 CORS 预检请求\n  if (request.method === 'OPTIONS') {\n    return new Response(null, {\n      headers: handleCORS(request),\n    });\n  }\n  \u002F\u002F 管理页面\n  if (url.pathname === '\u002Fmanage') {\n    return new Response(html, {\n      headers: { 'Content-Type': 'text\u002Fhtml' },\n    });\n  }\n  \u002F\u002F 验证密码\n  if (url.pathname === '\u002Fapi\u002Fauth' && request.method === 'POST') {\n    const { password } = await request.json();\n    return new Response(\n      JSON.stringify({ success: password === CORRECT_PASSWORD }),\n      {\n        headers: {\n          'Content-Type': 'application\u002Fjson',\n          ...corsHeaders\n        },\n      }\n    );\n  }\n  \u002F\u002F API 路由处理\n  if (url.pathname.startsWith('\u002Fapi\u002Fmemos')) {\n    \u002F\u002F 获取说说列表\n    if (request.method === 'GET') {\n      const offset = parseInt(url.searchParams.get('offset')) || 0;\n      const limit = parseInt(url.searchParams.get('limit')) || 10;\n      const indexStr = await env.KV.get('index');\n      const index = indexStr ? JSON.parse(indexStr) : [];\n      const pageUids = index.slice(offset, offset + limit);\n      const posts = await Promise.all(\n        pageUids.map(uid => env.KV.get(uid).then(JSON.parse))\n      );\n      return new Response(JSON.stringify({\n        offset,\n        limit,\n        data: posts,\n        total: index.length,\n        hasMore: (offset + limit) \u003C index.length,\n      }), {\n        headers: {\n          'Content-Type': 'application\u002Fjson',\n          ...corsHeaders\n        },\n      });\n    }\n    \u002F\u002F 需要验证的操作\n    if (!validateAuth(request)) {\n      return new Response('Unauthorized', {\n        status: 401,\n        headers: corsHeaders\n      });\n    }\n    \u002F\u002F 发布新说说\n    if (request.method === 'POST') {\n      const { content } = await request.json();\n      if (!content || !content.trim()) {\n        return new Response('Content cannot be empty', {\n          status: 400,\n          headers: corsHeaders\n        });\n      }\n      const indexStr = await env.KV.get('index');\n      const index = indexStr ? JSON.parse(indexStr) : [];\n      let uid = generateUID();\n      while (true) {\n        if (!index.includes(uid)) {\n          break;\n        }\n        uid = generateUID();\n      }\n      const post = {\n        uid,\n        createTime: getCurrentTimeInISOFormat(),\n        content: content.trim()\n      };\n      index.unshift(uid);\n      await Promise.all([\n        env.KV.put('index', JSON.stringify(index)),\n        env.KV.put(uid, JSON.stringify(post))\n      ]);\n      await executeCallback();\n      return new Response(JSON.stringify(post), {\n        headers: {\n          'Content-Type': 'application\u002Fjson',\n          ...corsHeaders\n        },\n      });\n    }\n    \u002F\u002F 编辑说说\n    if (request.method === 'PUT') {\n      const uid = url.pathname.split('\u002F').pop();\n      const { content } = await request.json();\n      if (!content || !content.trim()) {\n        return new Response('Content cannot be empty', {\n          status: 400,\n          headers: corsHeaders\n        });\n      }\n      const postStr = await env.KV.get(uid);\n      if (!postStr) {\n        return new Response('Post not found', {\n          status: 404,\n          headers: corsHeaders\n        });\n      }\n      const post = JSON.parse(postStr);\n      post.content = content.trim();\n      await env.KV.put(uid, JSON.stringify(post));\n      \u002F\u002F 检查是否需要回调\n      if (await shouldNotify(uid)) {\n        await executeCallback();\n      }\n      return new Response(JSON.stringify(post), {\n        headers: {\n          'Content-Type': 'application\u002Fjson',\n          ...corsHeaders\n        },\n      });\n    }\n    \u002F\u002F 删除说说\n    if (request.method === 'DELETE') {\n      const uid = url.pathname.split('\u002F').pop();\n      const indexStr = await env.KV.get('index');\n      if (!indexStr) {\n        return new Response('Post not found', {\n          status: 404,\n          headers: corsHeaders\n        });\n      }\n      const needCallback = await shouldNotify(uid);\n      const index = JSON.parse(indexStr);\n      const newIndex = index.filter(id => id !== uid);\n      await Promise.all([\n        env.KV.put('index', JSON.stringify(newIndex)),\n        env.KV.delete(uid)\n      ]);\n      if (needCallback) {\n        await executeCallback();\n      }\n      return new Response(JSON.stringify({ success: true }), {\n        headers: {\n          'Content-Type': 'application\u002Fjson',\n          ...corsHeaders\n        },\n      });\n    }\n  }\n  return new Response('Not Found', {\n    status: 404,\n    headers: corsHeaders\n  });\n}\nexport default {\n  async fetch(request, env) {\n    try {\n      return handleRequest(request, env);\n    } catch (error) {\n      return new Response(`Internal Server Error: ${error.message}`, {\n        status: 500,\n        headers: handleCORS(request)\n      });\n    }\n  },\n};\n",[65,198,196],{"__ignoreMap":80},[14,200,201],{},"最顶上三个常量需要配置：",[49,203,204,210,216],{},[52,205,206,209],{},[65,207,208],{"code":208},"CORRECT_PASSWORD","，页面密码",[52,211,212,215],{},[65,213,214],{"code":214},"CALLBACK_URL","，发布新说说或更新\u002F删除说说后触发的回调地址",[52,217,218,221],{},[65,219,220],{"code":220},"ALLOWED_ORIGINS","，跨域处理，允许访问的域名列表，至少两个：你的博客域名和管理页面域名",[14,223,224],{},"配置完成后点击发布",[14,226,227,228,231,232,235,236,238,239,241],{},"由于墙的原因，默认的 ",[65,229,230],{"code":230},"workers.dev"," 域名很难访问，最好为 worker 配置一个新的域名。在 ",[65,233,234],{"code":234},"memos 详情页面 - 设置 - 域和路由","，添加一个自定义域，填入一个在 Cloudflare 上托管的域名即可。注意这个域名也要添加到 ",[65,237,191],{"code":191}," 的 ",[65,240,220],{"code":220}," 中",[14,243,244,245,248],{},"完成后就可以使用这个管理页面了，管理页面的 URL 为 ",[65,246,247],{"code":247},"https:\u002F\u002F{你的域名}\u002Fmanage","，进入页面需要输入密码，then enjoy！",[10,250,251],{"id":251},"前端",[14,253,254],{},"Thanks to VitePress，我们可以很方便地通过 Vue 组件的方式，编写说说前端并嵌入博客",[14,256,257],{},"首先安装 markedjs 依赖，pnpm 可使用如下命令：",[74,259,264],{"className":260,"code":262,"language":263,"meta":80},[261],"language-shell","pnpm add marked\n","shell",[65,265,262],{"__ignoreMap":80},[14,267,268,269,272,273,276,277],{},"在你的博客的主题配置文件（通常为 ",[65,270,271],{"code":271},"docs\u002F.vitepress\u002Ftheme\u002Findex.ts","，文件路径和拓展名也许会有区别）的同级目录下，新建一个 ",[65,274,275],{"code":275},"components"," 文件夹（已有则无需新建），在其中新建 ",[65,278,279],{"code":279},"memos.vue",[74,281,284],{"className":282,"code":283,"language":79,"meta":80},[77],"\u003Ctemplate>\n    \u003Cdiv class=\"memos-container\">\n        \u003Cdiv v-for=\"memo of memoList\" :key=\"memo.uid\">\n            \u003Cdiv class=\"card\">\n                \u003Cdiv class=\"header\">\n                    \u003Cspan class=\"time-text\">{{ memo.createTime }}\u003C\u002Fspan>\n                \u003C\u002Fdiv>\n\n                \u003Cdiv class=\"memo-content\" v-html=\"memo.content\" \u002F>\n            \u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n        \u003Cdiv v-if=\"hasMore\" class=\"load-more\">\n            \u003Cbutton @click=\"loadMoreMemos\" :disabled=\"isLoading\" class=\"load-more-button\">\n                \u003Cspan v-if=\"!isLoading\">加载更多\u003C\u002Fspan>\n                \u003Cspan v-else class=\"loading-spinner\">\u003C\u002Fspan>\n            \u003C\u002Fbutton>\n        \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003Cscript setup lang=\"ts\">\nimport { marked, Tokens } from \"marked\"\nimport { reactive, toRefs, onMounted } from \"vue\"\nimport memosRaw from '..\u002F..\u002F..\u002F..\u002Fmemos.json'       \u002F\u002F [!code highlight]\n\ninterface memosRes {\n    data: memo[]\n    hasMore: boolean\n}\n\ninterface image {\n    name: string\n    filename: string\n    url: string\n}\n\ninterface memo {\n    uid: string\n    createTime: string\n    content: string\n}\n\nfunction convertToLocalTime(dateString: string, timeZone: string = 'Asia\u002FShanghai'): string {\n    \u002F\u002F 创建 Date 对象\n    const date = new Date(dateString);\n\n    \u002F\u002F 提取所需的时间组件\n    const options: Intl.DateTimeFormatOptions = {\n        timeZone: timeZone,\n        year: 'numeric',\n        month: '2-digit',\n        day: '2-digit',\n        hour: '2-digit',\n        minute: '2-digit',\n        second: '2-digit',\n        hour12: false \u002F\u002F 使用 24 小时制\n    };\n\n    const formatter = new Intl.DateTimeFormat('zh-CN', options);\n    const parts = formatter.formatToParts(date);\n\n    \u002F\u002F 构建最终输出格式\n    const year = parts.find(part => part.type === 'year')?.value;\n    const month = parts.find(part => part.type === 'month')?.value;\n    const day = parts.find(part => part.type === 'day')?.value;\n    const hour = parts.find(part => part.type === 'hour')?.value;\n    const minute = parts.find(part => part.type === 'minute')?.value;\n    const second = parts.find(part => part.type === 'second')?.value;\n\n    \u002F\u002F 拼接成目标格式\n    return `${year}-${month}-${day} ${hour}:${minute}:${second}`;\n}\n\nconst PAGE_SIZE = 10;\nconst data = reactive({\n    memoList: [] as memo[],\n    offset: 10, \u002F\u002F 从文件加载了 10 条，所以初始 offset 为 10\n    hasMore: true,\n    isLoading: false\n})\nconst { memoList, hasMore, isLoading } = toRefs(data);\n\nconst renderer = new marked.Renderer();\nrenderer.image = function({href, title, text}: Tokens.Image):string {\n  return `\n    \u003Cdiv class=\"img-container\">\n        \u003Cimg class=\"imgwrp\" loading=\"lazy\" src=\"${href}\" \u002F>\n    \u003C\u002Fdiv>\n  `\n};\nmarked.use({\n    renderer: renderer,\n    breaks: true,\n    gfm: true,\n})\n\nfunction processMemos(memos: memo[]) {\n  return memos.map(memo => ({\n    ...memo,\n    content: marked.parse(memo.content) as string,\n    createTime: convertToLocalTime(memo.createTime)\n  }));\n}\n\n\u002F\u002F 初始化数据\nonMounted(() => {\n  const initialMemos = memosRaw.data as memo[];\n  data.memoList = processMemos(initialMemos);\n});\n\nasync function loadMoreMemos() {\n  if (!data.hasMore || data.isLoading) return;\n  \n  data.isLoading = true;\n  try {\n    const url = `https:\u002F\u002F{你的域名}\u002Fapi\u002Fmemos?limit=${PAGE_SIZE}&offset=${data.offset}`;   \u002F\u002F [!code highlight]\n    const response = await fetch(url);\n    const result: memosRes = await response.json();\n    \n    const processedMemos = processMemos(result.data);\n    data.memoList.push(...processedMemos);\n    data.offset += result.data.length;\n    data.hasMore = result.hasMore;\n  } catch (error) {\n    console.error('Failed to load memos:', error);\n  } finally {\n    data.isLoading = false;\n  }\n}\n\u003C\u002Fscript>\n\n\u003Cstyle lang=\"scss\">\n.card {\n    border-style: solid;\n    margin-bottom: .5rem;\n    border-width: 1px; \n    position: relative;\n    border-radius: .5rem;\n    border-color: var(--vp-c-bg);\n    padding-top: .75rem;\n    padding-bottom: .75rem;\n    padding-left: 1rem;\n    padding-right: 1rem;\n    background-color: var(--memo-bg);\n    font-family: ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", Segoe UI Symbol, \"Noto Color Emoji\";\n\n    .header {\n        display: flex;\n        justify-content: space-between;\n        align-items: center;\n\n        .time-text {\n            display: inline-block;\n            font-size: .875rem;\n            text-decoration: none;\n            color: var(--memo-time)\n        }\n    }\n\n    .memo-content {\n        margin-top: 5px;\n        font-size: 1rem;\n        word-break: break-word;\n        color: var(--memo-content);\n\n        * {\n            margin: 0;\n        }\n\n        *:not(:first-child):not([hidden]) {\n            margin-top: .5rem;\n        }\n\n        .img-container {\n            width: 40%;\n\n            .imgwrp {\n                width:100%;\n                height: 100%;\n            }\n        }\n    \n    }\n    \n}\n\n.card:hover {\n    border-color: var(--memo-card-border);\n}\n\n.load-more {\n  text-align: center;\n  margin-top: 40px;\n  margin-bottom: 40px;\n\n  .load-more-button {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    width: 120px; \u002F\u002F 固定宽度\n    height: 40px; \u002F\u002F 固定高度\n    background-color: transparent;\n    color: var(--vp-c-text-2);\n    border: 1px solid var(--vp-c-divider);\n    border-radius: 4px;\n    font-size: 14px;\n    font-weight: 500;\n    cursor: pointer;\n    transition: all 0.2s ease;\n    outline: none;\n\n    &:hover:not(:disabled) {\n      background-color: var(--vp-c-bg-soft);\n      color: var(--vp-c-text-1);\n      border-color: var(--vp-c-text-2);\n    }\n\n    &:active:not(:disabled) {\n      transform: translateY(1px);\n    }\n\n    &:disabled {\n      opacity: 0.5;\n      cursor: not-allowed;\n    }\n\n    .loading-spinner {\n      width: 14px;\n      height: 14px;\n      border: 2px solid var(--vp-c-text-3);\n      border-radius: 50%;\n      border-top-color: var(--vp-c-text-1);\n      animation: spin 0.8s linear infinite;\n    }\n  }\n}\n\n@keyframes spin {\n  to { transform: rotate(360deg); }\n}\n\u003C\u002Fstyle>\n",[65,285,283],{"__ignoreMap":80},[14,287,288,289,292],{},"注意将 ",[65,290,291],{"code":291},"{你的域名}"," 替换为 CloudFlare Worker 的域名",[14,294,295,296,299],{},"眼尖的同学可能注意到了，这个组件初始化加载的内容不是通过请求 Worker 接口获取到的，而是从一个 json 文件获取的（",[65,297,298],{"code":298},"import memosRaw from '..\u002F..\u002F..\u002F..\u002Fmemos.json'","）。只有点击加载更多，才会通过 Worker 接口获取更多内容。这是为什么呢？",[49,301,302,305],{},[52,303,304],{},"从体验上来说，进入说说页面时，如果初始数据从接口获取，那么这时页面在获取到数据之前会空白一会儿，体验不佳",[52,306,307,308,312],{},"从",[309,310,311],"mark",{},"省钱","的角度上来说，CloudFlare Worker 免费版是限制请求次数的，初始化数据静态获取可以极大地降低请求次数",[14,314,315,316,319,320,322],{},"这个 ",[65,317,318],{"code":318},"memos.json","，则是在项目编译时，从接口获取到的前十条说说。这也就是为什么，Worker 代码中会添加一个 ",[65,321,214],{"code":214},"，这个 URL 是在你发布新说说，或者删改前十条说说时重新触发编译使用的，具体 URL 可以根据你的部署平台自行搜索。如果完全动态获取说说内容的话，这里可以不用设置这么一个回调",[14,324,325,326,328,329,272,331,276,334],{},"下面的代码用于在编译时生成 ",[65,327,318],{"code":318},"，在主题配置文件（通常为 ",[65,330,271],{"code":271},[65,332,333],{"code":333},"utils",[65,335,336],{"code":336},"memos.js",[74,338,341],{"className":339,"code":340,"language":79,"meta":80},[77],"import https from 'https';\nimport { promises as fs } from 'fs';\n\nconst url = 'https:\u002F\u002F{你的域名}\u002Fapi\u002Fmemos?limit=10';\u002F\u002F [!code highlight]\n\nconst requestOptions = {\n    headers: {\n      'Accept-Encoding': '',\n    }\n};\n\n\u002F\u002F 发出 GET 请求\nhttps.get(url, requestOptions, (resp) => {\n  let data = [];\n\n  \u002F\u002F 逐步接收数据\n  resp.on('data', (chunk) => {\n    data.push(chunk);\n  });\n\n  \u002F\u002F 完成接收数据\n  resp.on('end', async () => {\n    try {\n      \u002F\u002F 将 Buffer 数组合并为一个 Buffer\n      const buffer = Buffer.concat(data);\n      const decodedData = buffer.toString('utf-8'); \u002F\u002F 假设返回的数据是 UTF-8 编码\n\n      \u002F\u002F 保存 JSON 数据到文件\n      await fs.writeFile('memos.json', decodedData);\n      console.log('JSON 数据已保存到 data.json');\n    } catch (e) {\n      console.error('解析 JSON 时出错:', e);\n    }\n  });\n\n}).on('error', (err) => {\n  console.error('获取数据时出错:', err);\n});\n",[65,342,340],{"__ignoreMap":80},[14,344,345,346,349,350,353],{},"接着编辑博客根目录下的 ",[65,347,348],{"code":348},"package.json","，在 dev 和 build 的命令前都添加 ",[65,351,352],{"code":352},"node docs\u002F.vitepress\u002Ftheme\u002Futils\u002Fmemos.js","。这里的添加位置可能因人而异，以我为例：",[74,355,360],{"className":356,"code":358,"language":359,"meta":80},[357],"language-json","{\n  ...\n  \"scripts\": {\n    \"dev\": \"node docs\u002F.vitepress\u002Ftheme\u002Futils\u002Fmemos.js && vitepress dev docs\",\n    \"build\": \"node docs\u002F.vitepress\u002Ftheme\u002Futils\u002Fmemos.js && vitepress build docs\",\n    \"serve\": \"vitepress serve docs\"\n  },\n  ...\n}\n","json",[65,361,358],{"__ignoreMap":80},[14,363,364,365,367,368,370,371,373],{},"这样在 dev 阶段和 build 阶段都会首先调用 ",[65,366,336],{"code":336},"，在博客根目录下生成 ",[65,369,318],{"code":318},"。注意根据目录层级调整 ",[65,372,279],{"code":279}," 中 import 的路径",[14,375,376],{},"这样组件和数据都准备好了，下面这个组件注册为全局组件",[14,378,379,380,382],{},"在主题配置文件（通常为 ",[65,381,271],{"code":271},"，文件路径和拓展名也许会有区别）中引入这个组件，并注册",[74,384,387],{"className":385,"code":386,"language":79,"meta":80},[77],"...\nimport Memos from '.\u002Fcomponents\u002Fmemos.vue'\n...\nexport default {\n    ...\n    enhanceApp({ app }) {\n        ...\n        app.component('Memos', Memos);\u002F\u002F [!code highlight]\n    }\n} satisfies Theme\n",[65,388,386],{"__ignoreMap":80},[14,390,391,392,395],{},"这样在博客的任何地方，都可以通过 ",[65,393,394],{"code":394},"\u003CMemos \u002F>"," 直接引入这个组件了",[14,397,398],{},"最后就是创建一个单页，专门用于放置这个组件",[400,401,402,405],"blockquote",{},[14,403,404],{},"什么？你说你从来没有在 vitepress 中使用过单页？",[14,406,407,408,411,412,415,416],{},"这样，你先在根目录下新建一个 pages 文件夹，再在 VitePress 核心配置文件中（注意不是主题配置文件，通常为 docs\u002F.vitepress\u002Fconfig.ts，文件路径和拓展名也许会有区别）中新增一个 rewrites 规则 ",[65,409,410],{"code":410},"'pages\u002F:file.md': ':file.md'","，这样 pages 下的内容都可以直接通过 ",[65,413,414],{"code":414},"\u002F文件名"," 访问了。关于 rewrites，见 ",[24,417,421],{"href":418,"rel":419},"https:\u002F\u002Fvitepress.dev\u002Fguide\u002Frouting#route-rewrites",[420],"nofollow","官方文档",[14,423,424],{},"pages 文件夹下新建 balabala.md，内容为",[74,426,431],{"className":427,"code":429,"language":430,"meta":80},[428],"language-markdown","---\ntitle: 碎碎念\nhidden: true\ncomment: false\nsidebar: false\naside: false\nreadingTime: false\nshowMeta: false\n---\n\n\u003CMemos \u002F>\n","markdown",[65,432,429],{"__ignoreMap":80},[14,434,435],{},"完事收工",{"title":80,"searchDepth":437,"depth":437,"links":438},4,[439,441,445],{"id":12,"depth":440,"text":12},3,{"id":39,"depth":440,"text":40,"children":442},[443,444],{"id":44,"depth":437,"text":44},{"id":85,"depth":437,"text":85},{"id":251,"depth":440,"text":251},[447],"折腾","2025-01-29 21:58:00","在构建动态博客时，添加说说功能能显著提升用户体验。相比静态博客的繁琐流程，这种功能允许用户随时随地分享短小的想法，降低了发文的心理负担。通过利用 CloudFlare Workers 实现后端逻辑，并结合 KV 存储，开发者能够轻松管理说说内容。前端则通过 VitePress 框架和 Vue 组件的嵌入，快捷地展示这些动态信息，为博客增添了生动的交互性。",false,"md",null,{"slots":454},{},true,"\u002Ffiddling\u002Fvitepress-memos-component",{"text":458,"minutes":459,"time":460,"words":461},"23 min read",22.65,1359000,4530,{"title":5,"description":449},{"loc":456},"posts\u002Ffiddling\u002Fvitepress-memos-component",[447,466,467,468],"VitePress","Memos","CloudFlare","tech","dtpMfKFJdEgadzxPrF6doVxTUcrqmrgnLmeg4kewVF0",[472,477],{"title":473,"path":474,"stem":475,"date":476,"type":469,"children":-1},"实现 OPNsense 透明代理+分流","\u002Ffiddling\u002Fopnsense-transparent-proxy","posts\u002Ffiddling\u002Fopnsense-transparent-proxy","2025-01-16 23:09:00",{"title":478,"path":479,"stem":480,"date":481,"type":469,"children":-1},"VPS 套 Warp 分流指定出口 IPv6","\u002Ffiddling\u002Fvps-warp-ipv6","posts\u002Ffiddling\u002Fvps-warp-ipv6","2025-03-15 16:24:00",1787554444443]