📝 零成本给 Zensical 博客添加联系表单功能¶
作为一个静态博客生成器,Zensical 以简洁的文档风格和极速的加载体验深受开发者喜爱。但静态博客最大的痛点在于缺乏后端交互。
想让访客留言、反馈 Bug 或申请友链,通常只能用 mailto:(体验差)、第三方表单(样式突兀、有广告)或者评论系统(太重)。
今天,我们来通过 Cloudflare Workers (无服务器计算) 和 Resend (现代化邮件 API),为博客搭建一个完全免费、自定义样式、且自带防跨域保护的联系表单。
✨ 效果预览¶
不同于普通的嵌入式表单,我们这个方案具备以下亮点:
- 原生体验:UI 深度集成 Zensical 样式,支持明暗主题自动切换。
- 功能丰富:支持下拉选择“友链/反馈/一般留言”,后端自动映射中文标题。
- 细节满满:响应式网格布局、随机名言页脚、发送状态实时反馈。
- 安全防暴:基于 Cloudflare 的跨域白名单机制。
你可以在这里查看部署后的示例:示例页面
🛠️ 准备工作¶
- Resend 账号:注册 Resend.com,添加你的域名并完成 DNS 验证。获取 API Key。
- Cloudflare 账号:用于部署后端 Worker。
第一步:准备 Resend API¶
Resend 是目前开发者体验最好的邮件发送服务之一,免费额度(每月 3000 封)对个人博客绰绰有余。
- 注册 Resend.com。
- 在 Domains 中添加你的域名(例如
example.com),并按指引去 DNS 服务商处添加 TXT/MX 记录进行验证。 - 在 API Keys 中创建一个新的 Key,权限选择 "Sending access" 即可。
- 复制保存好这个 Key (
re_12345...),稍后要用。
Example
在 Add API Key 时,需要选择域名进行添加。如果你想配置发件邮箱名为 bot@example.com,此步添加的域名应为example.com,而不是bot.example.com。
第二步:部署 Cloudflare Worker¶
我们需要一个后端来保护 Resend API Key 不暴露在前端代码中。
- 登录 Cloudflare Dashboard,进入 Workers & Pages。
- 点击 Create Application -> Create Worker,命名为
blog-contact-form(此处可以自定义命名),点击部署。 - 点击 Edit code,将默认代码清空,粘贴以下代码:
export default {
async fetch(request, env) {
// ============================================================
// 1. 安全配置:允许的来源域名白名单
// ============================================================
// ⚠️ 请务必修改为你自己的博客域名
const ALLOWED_ORIGINS = [
"https://example.com",
"https://www.example.com",
"http://127.0.0.1:8000", // 本地预览地址
"http://localhost:8000"
];
const origin = request.headers.get("Origin");
const isAllowed = ALLOWED_ORIGINS.includes(origin);
// 构造 CORS 头
const corsHeaders = {
"Access-Control-Allow-Origin": isAllowed ? origin : "null",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
// ============================================================
// 2. 处理预检请求 (OPTIONS)
// ============================================================
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405, headers: corsHeaders });
}
// 简单的来源校验
if (origin && !isAllowed) {
return new Response(JSON.stringify({ success: false, error: "Domain not allowed" }), {
status: 403,
headers: { ...corsHeaders, "Content-Type": "application/json" }
});
}
try {
// ============================================================
// 3. 解析数据与字段映射
// ============================================================
const formData = await request.json();
const { name, email, message, subject } = formData;
if (!name || !email || !message) {
throw new Error("请填写所有必填字段");
}
// 映射前端传来的 value 到可读中文
const subjectMap = {
"friend_link": "🔗 申请友链",
"article_feedback": "📝 文章反馈",
"general": "👋 一般留言"
};
const readableSubject = subjectMap[subject] || "📩 未分类留言";
// ============================================================
// 4. 准备发送给 Resend 的邮件内容
// ============================================================
// ⚠️ 修改这里的 from 和 to
// from: 必须是你在 Resend 验证过的域名邮箱,如 bot@yourdomain.com
// to: 你想接收通知的个人邮箱
const sendData = {
from: "Blog Form <bot@example.com>",
to: ["your-personal-email@qq.com"],
subject: `[${readableSubject}] 来自 ${name}`,
html: `
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 5px;">
<h2 style="color: #333;">${readableSubject}</h2>
<p><strong>访客姓名:</strong> ${name}</p>
<p><strong>访客邮箱:</strong> <a href="mailto:${email}">${email}</a></p>
<p><strong>提交来源:</strong> ${origin || 'Direct/Unknown'}</p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;" />
<h3 style="color: #555;">留言内容:</h3>
<div style="background-color: #f9f9f9; padding: 15px; border-radius: 4px; white-space: pre-wrap; line-height: 1.6;">${message}</div>
</div>
`,
reply_to: email
};
// ============================================================
// 5. 调用 Resend API
// ============================================================
const resendResponse = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(sendData),
});
const responseData = await resendResponse.json();
if (!resendResponse.ok) {
throw new Error(responseData.message || "Resend API Error");
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (error) {
return new Response(JSON.stringify({ success: false, error: error.message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
},
};
4.重要配置
- 在代码中找到
ALLOWED_ORIGINS,把example.com改成你自己的域名。 - 在代码中找到
from字段,必须改成你在 Resend 验证过的域名地址。 - 保存并部署 (Save and deploy)。
5.设置环境变量
- 回到 Worker 的 Settings -> Variables and Secrets。
- 点击 Add,Type类型选择
Text,变量名填写RESEND_API_KEY,值填入第一步获取的 Resend Key。 - 点击右下角的 Deploy 部署保存。
重要提醒
请为部署的 Cloudflare Worker 项目绑定自定义域名,默认域名国内访问会被阻断,导致无法向 Worker 发送请求。
第三步:在 Zensical 中创建表单联系页面¶
现在后端已经就绪,我们在 Zensical 中创建一个 contact.md 页面(示例,路径与名称可以自命名),并将该页面添加至 nav 导航配置中。我们会利用 Zensical 原生的主题色,让表单看起来更和谐。
在 docs/contact.md 中写入:
---
title: 联系表单
tags:
- 帮助支持
icon: material/card-account-mail
hide:
- tags
---
# 联系表单
???+ tip "在留言前请阅读"
欢迎与我联系!为了提高沟通效率,请参考以下说明:
=== "🔗 申请友链"
如果您想申请交换友链,请在留言中包含以下信息:
```yaml title="友链格式示例/本站信息"
站点名称: 你的站点名称
站点地址 (URL): 你的站点链接
站点描述: 你的站点描述
图标地址 (Avatar/Logo): 你的站点头像链接
```
=== "📝 文章反馈"
如果您发现文章有错误或有建议,请注明:
* **文章标题** 或 **链接**
* 具体的问题描述或修正建议
=== "👋 商务/其他"
欢迎任何形式的友好交流或合作咨询。
<!-- 表单容器 -->
<div class="md-typeset form-container">
<form id="contactForm">
<div class="form-grid">
<!-- 主题:全宽 -->
<div class="grid-item full-width">
<label for="subject">主题</label>
<div class="select-wrapper">
<select id="subject" name="subject" required>
<option value="general" selected>👋 一般留言 / 交流</option>
<option value="friend_link">🔗 申请友链</option>
<option value="article_feedback">📝 文章反馈 / 捉虫</option>
</select>
</div>
</div>
<!-- 姓名 -->
<div class="grid-item">
<label for="name">称呼</label>
<input type="text" id="name" name="name" placeholder="我该如何称呼您?" required>
</div>
<!-- 邮箱 -->
<div class="grid-item">
<label for="email">邮箱</label>
<input type="email" id="email" name="email" placeholder="接收回复用(不会公开)" required>
</div>
<!-- 内容:全宽 -->
<div class="grid-item full-width">
<label for="message">内容</label>
<textarea id="message" name="message" rows="5" placeholder="请在此输入留言..." required></textarea>
</div>
</div>
<!-- 底部栏 -->
<div class="form-footer">
<div class="footer-quote" id="randomQuote">
保持热爱,奔赴山海。
</div>
<div class="footer-actions">
<span id="statusMsg" class="status-text"></span>
<button type="submit" id="submitBtn" class="md-button md-button--primary compact-btn">
<span>发送</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
</div>
</form>
</div>
<style>
/* 1. 表单容器 */
.form-container {
width: 100%;
/* 使用负边距抵消主题默认的段落间距 */
margin-top: -0.3rem;
padding-top: 0;
padding-bottom: 0.5rem;
}
/* 2. 网格布局 */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem 0.8rem;
margin-bottom: 0.4rem;
}
.full-width { grid-column: span 2; }
/* 3. 元素样式 */
.grid-item label {
display: block;
font-size: 0.85rem;
font-weight: 700;
margin-bottom: 0.2rem;
color: var(--md-default-fg-color--light);
}
.grid-item input,
.grid-item textarea,
.grid-item select {
width: 100%;
box-sizing: border-box;
padding: 7px 10px;
font-size: 0.9rem;
border: 1px solid var(--md-default-fg-color--lighter);
border-radius: 4px;
background: var(--md-default-bg-color);
color: var(--md-default-fg-color);
transition: all 0.2s;
}
.grid-item input:focus,
.grid-item textarea:focus,
.grid-item select:focus {
border-color: var(--md-primary-fg-color);
box-shadow: 0 0 0 3px var(--md-primary-fg-color--transparent);
outline: none;
}
/* 4. 底部栏 */
.form-footer {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--md-default-fg-color--lighter);
padding-top: 0.6rem;
gap: 1rem;
}
.footer-quote {
font-size: 0.8rem;
color: var(--md-default-fg-color--light);
font-style: italic;
opacity: 0.8;
max-width: 60%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.footer-actions {
display: flex;
align-items: center;
gap: 0.8rem;
flex-shrink: 0;
}
/* 5. 发送按钮 */
.compact-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 12px;
font-size: 0.8rem;
font-weight: bold;
border-radius: 50px;
height: auto;
min-height: 28px;
line-height: 1.2;
}
.compact-btn svg {
width: 17px;
height: 17px;
transform: translateY(1.5px);
fill: currentColor;
}
.compact-btn:active { transform: scale(0.96); }
/* 6. 状态信息 */
.status-text {
font-size: 0.75rem;
opacity: 0;
transition: opacity 0.3s;
}
.status-text.visible { opacity: 1; }
.status-success { color: var(--md-code-hl-string-color); }
.status-error { color: var(--md-code-hl-function-color); }
/* 7. 移动端适配 */
@media screen and (max-width: 600px) {
.form-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.full-width { grid-column: span 1; }
.form-footer {
flex-direction: column-reverse;
align-items: flex-end;
padding-top: 0.5rem;
}
.footer-quote {
max-width: 100%;
text-align: right;
}
}
</style>
<script>
const quotes = [
"保持热爱,奔赴山海。",
"Code is poetry.",
"Stay hungry, stay foolish.",
"凡是过往,皆为序章。",
"Talk is cheap. Show me the code.",
"星光不问赶路人。",
"知行合一。",
"Less is more."
];
function loadQuote() {
const quoteEl = document.getElementById('randomQuote');
if(quoteEl) {
const randomQuote = quotes[Math.floor(Math.random() * quotes.length)];
quoteEl.innerText = randomQuote;
}
}
loadQuote();
document.getElementById('contactForm').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = document.getElementById('submitBtn');
const msg = document.getElementById('statusMsg');
const originalBtnContent = btn.innerHTML;
const WORKER_URL = "https://form.9420000.xyz";
const formData = {
subject: document.getElementById('subject').value,
name: document.getElementById('name').value,
email: document.getElementById('email').value,
message: document.getElementById('message').value
};
btn.disabled = true;
btn.innerHTML = "<span>...</span>";
msg.innerText = "";
msg.className = "status-text";
try {
const response = await fetch(WORKER_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData)
});
const result = await response.json();
if (response.ok && result.success) {
msg.innerText = "✅ 已发送";
msg.className = "status-text visible status-success";
document.getElementById('contactForm').reset();
loadQuote();
} else {
throw new Error(result.error || "未知错误");
}
} catch (error) {
console.error(error);
msg.innerText = "❌ 发送失败";
msg.className = "status-text visible status-error";
} finally {
btn.disabled = false;
btn.innerHTML = originalBtnContent;
setTimeout(() => { msg.classList.remove('visible'); }, 5000);
}
});
</script>
第四步:测试与验证¶
- 运行
zensical serve在本地启动博客。 - 进入
contact.md文件被配置的路径,打开页面填写测试信息。 - 点击发送。
- 查看你的 Cloudflare Worker 日志(如果出错的话)、Resend Emails界面(检查是否有发信请求)或者直接查看你的收件箱。
🎉 成功效果:
你会收到一封格式精美的邮件,标题清楚地标明了 [🔗 申请友链] 来自 张三,且由于我们在代码中设置了 reply_to,你直接在邮件客户端点击“回复”,收件人就会自动变成访客填写的邮箱。
例如:

总结¶
通过这个方案,我们实现了:
- 安全性:Resend Key 不泄露,且只有指定域名的请求会被处理。
- 用户体验:前端无跳转,样式与 Zensical 主题统一。
- 可维护性:邮件模板和逻辑都在 Cloudflare Worker 中,修改无需重新部署博客。
希望本文对您有帮助!🚀
评论