功能概述
为 Quartz 博客新增了文章密码保护功能,支持对单篇文章设置访问密码。访客需要输入正确密码才能查看受保护的文章内容。
使用方法
在文章的 frontmatter 中添加 password 字段即可:
---
title: 你的文章标题
password: "your-password-here"
date: 2025-11-25
tags:
- 标签
---功能特性
🎨 用户界面
- 美观的密码输入界面,带 🔒 锁图标
- 输入错误时有抖动动画反馈
- 自动适配亮暗主题(支持 Dark Mode)
- 响应式设计,移动端友好
💾 会话记忆
- 使用
localStorage记住已解锁的文章 - 同一浏览器再次访问无需重新输入密码
- 切换浏览器或设备需要重新输入
🔐 安全性说明
重要提醒: 这是一个前端密码保护方案,安全等级为”礼貌性拦截”。
适用场景:
- ✅ 个人博客的半公开文章
- ✅ 不想被搜索引擎收录的内容
- ✅ 朋友圈内分享的笔记
- ✅ 个人想法的内容分级访问
不适用场景:
- ❌ 商业机密文档
- ❌ 真正的敏感信息(财务、密码等)
- ❌ 需要防止技术人员获取的内容
技术限制:
- 密码使用 Base64 编码存储在前端
- 懂技术的人可以通过浏览器开发者工具破解
- frontmatter 中的密码是明文,不要使用重要密码
技术实现
架构设计
quartz/components/
├── PasswordProtection.tsx # 主组件
├── scripts/
│ └── password.inline.ts # 客户端验证脚本
└── styles/
└── password.scss # 样式文件
核心组件 - PasswordProtection.tsx
文件路径: quartz/components/PasswordProtection.tsx
import passwordScript from "./scripts/password.inline"
import styles from "./styles/password.scss"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
interface PasswordProtectionOptions {
hint?: string
buttonText?: string
errorText?: string
}
const defaultOptions: PasswordProtectionOptions = {
hint: "此文章已加密,请输入密码查看",
buttonText: "确认",
errorText: "密码错误,请重试",
}
export default ((userOpts?: Partial<PasswordProtectionOptions>) => {
const opts = { ...defaultOptions, ...userOpts }
const PasswordProtection: QuartzComponent = ({
displayClass,
fileData,
}: QuartzComponentProps) => {
const password = fileData.frontmatter?.password as string | undefined
// 如果没有设置密码,不渲染组件
if (!password) {
return null
}
// 使用 Base64 编码密码
const hashedPassword = btoa(password)
return (
<div
class={classNames(displayClass, "password-protection")}
data-password-hash={hashedPassword}
data-slug={fileData.slug}
>
<div class="password-overlay">
<div class="password-container">
<div class="lock-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<p class="password-hint">{opts.hint}</p>
<div class="password-input-group">
<input type="password" class="password-input" placeholder="请输入密码" autocomplete="off" />
<button type="button" class="password-submit">{opts.buttonText}</button>
</div>
<p class="password-error" style="display: none;">{opts.errorText}</p>
</div>
</div>
</div>
)
}
// 注册客户端脚本和样式
PasswordProtection.beforeDOMLoaded = passwordScript
PasswordProtection.css = styles
return PasswordProtection
}) satisfies QuartzComponentConstructor<Partial<PasswordProtectionOptions>>关键设计点:
- 条件渲染: 只有文章设置了
password字段才渲染密码输入界面 - Base64 编码: 将密码编码后存储在
data-password-hash属性中 - 数据属性: 使用
data-slug记录文章标识,用于 localStorage 管理 - 生命周期钩子: 使用
beforeDOMLoaded确保脚本在 DOM 加载前执行,防止内容闪现
客户端验证脚本 - password.inline.ts
文件路径: quartz/components/scripts/password.inline.ts
// localStorage 管理函数
function getUnlockedPages(): Set<string> {
try {
const stored = localStorage.getItem("unlocked-pages")
return stored ? new Set(JSON.parse(stored)) : new Set()
} catch {
return new Set()
}
}
function saveUnlockedPages(pages: Set<string>) {
localStorage.setItem("unlocked-pages", JSON.stringify([...pages]))
}
// 检查并应用密码保护
function checkAndApplyProtection() {
const protectionElement = document.querySelector(".password-protection") as HTMLElement | null
if (!protectionElement) return
const passwordHash = protectionElement.dataset.passwordHash
const slug = protectionElement.dataset.slug
if (!passwordHash || !slug) return
// 检查是否已解锁
const unlockedPages = getUnlockedPages()
if (unlockedPages.has(slug)) {
// 已解锁,显示内容
protectionElement.style.display = "none"
const article = document.querySelector("article")
if (article) {
article.classList.remove("password-hidden")
}
return
}
// 未解锁,显示密码输入界面
protectionElement.style.display = "block"
const article = document.querySelector("article")
if (article) {
article.classList.add("password-hidden")
}
}
// 页面加载时立即执行
checkAndApplyProtection()
// SPA 路由导航时重新检查
document.addEventListener("nav", () => {
checkAndApplyProtection()
const protectionElement = document.querySelector(".password-protection") as HTMLElement | null
if (!protectionElement) return
const passwordHash = protectionElement.dataset.passwordHash
const slug = protectionElement.dataset.slug
const input = protectionElement.querySelector(".password-input") as HTMLInputElement | null
const submitBtn = protectionElement.querySelector(".password-submit") as HTMLButtonElement | null
const errorMsg = protectionElement.querySelector(".password-error") as HTMLElement | null
if (!passwordHash || !slug || !input || !submitBtn) return
// 密码验证逻辑
const handleSubmit = () => {
const enteredPassword = input.value
const enteredHash = btoa(enteredPassword)
if (enteredHash === passwordHash) {
// 密码正确
const unlockedPages = getUnlockedPages()
unlockedPages.add(slug)
saveUnlockedPages(unlockedPages)
// 显示内容
protectionElement.style.display = "none"
const article = document.querySelector("article")
if (article) {
article.classList.remove("password-hidden")
}
// 清理输入
input.value = ""
if (errorMsg) {
errorMsg.style.display = "none"
}
} else {
// 密码错误
if (errorMsg) {
errorMsg.style.display = "block"
}
input.classList.add("shake")
setTimeout(() => input.classList.remove("shake"), 500)
}
}
// 绑定事件
submitBtn.addEventListener("click", handleSubmit)
window.addCleanup(() => submitBtn.removeEventListener("click", handleSubmit))
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
handleSubmit()
}
}
input.addEventListener("keydown", handleKeydown)
window.addCleanup(() => input.removeEventListener("keydown", handleKeydown))
})关键逻辑:
- 初始检查: 页面加载时立即检查是否已解锁
- Base64 对比: 用户输入的密码也进行 Base64 编码后与存储的 hash 对比
- localStorage 持久化: 解锁成功后保存到 localStorage
- SPA 兼容: 监听
nav事件,确保 SPA 路由切换时正常工作 - 事件清理: 使用
window.addCleanup防止内存泄漏
样式设计 - password.scss
文件路径: quartz/components/styles/password.scss
.password-protection {
display: none;
position: relative;
}
.password-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--light);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
}
.password-container {
background: var(--lightgray);
border-radius: 12px;
padding: 2.5rem;
max-width: 400px;
width: 90%;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.password-input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--gray);
border-radius: 6px;
font-size: 1rem;
background: var(--light);
color: var(--dark);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
&:focus {
border-color: var(--secondary);
box-shadow: 0 0 0 3px rgba(var(--secondary-rgb), 0.1);
}
&.shake {
animation: shake 0.5s ease-in-out;
}
}
// 隐藏受保护的内容
article.password-hidden {
visibility: hidden;
height: 0;
overflow: hidden;
padding: 0 !important;
margin: 0 !important;
}
// 错误抖动动画
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
// 暗黑模式适配
:root[saved-theme="dark"] {
.password-overlay {
background: var(--dark);
}
.password-container {
background: var(--darkgray);
}
.password-input {
background: var(--dark);
border-color: var(--gray);
}
}设计特点:
- 固定遮罩层: 使用
position: fixed覆盖整个页面 - 居中布局: Flexbox 实现完美居中
- 响应式:
max-width: 400px和width: 90%适配各种屏幕 - 动画反馈: 错误时的抖动动画提升用户体验
- 主题适配: 使用 CSS 变量,自动适配亮暗主题
组件注册
1. 导出组件 (quartz/components/index.ts:28,58)
import PasswordProtection from "./PasswordProtection"
export {
// ... 其他组件
PasswordProtection,
}2. 注册到布局 (quartz.layout.ts:39)
export const defaultContentPageLayout: PageLayout = {
beforeBody: [
Component.PasswordProtection(), // 必须放在第一位
Component.CoverImage(),
Component.Breadcrumbs(),
// ... 其他组件
],
// ...
}注意: PasswordProtection 必须放在 beforeBody 数组的第一位,确保在其他内容渲染前执行。
自定义配置
可以在 quartz.layout.ts 中自定义提示文字:
Component.PasswordProtection({
hint: "自定义提示文字",
buttonText: "确认",
errorText: "密码错误,请重试"
})清除解锁记忆
如果需要重新测试密码功能:
- 打开浏览器开发者工具 (F12)
- 进入 Application/应用程序 标签
- 找到 Local Storage
- 删除
unlocked-pages键
或在控制台执行:
localStorage.removeItem('unlocked-pages')
location.reload()技术选型说明
为什么使用前端验证?
由于博客部署在 Cloudflare Pages(静态托管),无法使用后端服务器进行真正的密码验证。前端验证是静态网站的唯一可行方案。
为什么使用 Base64 编码?
- 简单高效: Base64 编码/解码速度快,不影响页面加载
- 浏览器原生支持: 使用
btoa()和atob()函数,无需引入第三方库 - 足够的”礼貌性”: 能阻止 99% 的普通访客,满足个人博客需求
为什么不使用更强的加密?
- 静态站点限制: 无法使用后端验证,前端加密本质上都可被破解
- 性能考虑: 复杂加密算法会影响页面加载速度
- 适用场景定位: 个人博客不需要商业级别的安全防护
升级可能性
如果未来需要更高的安全性,可以考虑:
方案 A: AES 内容加密
- 文章内容在构建时用 AES 加密
- 密码不存储在页面中
- 用户输入密码后浏览器解密内容
- 安全性: 中等(能防 95% 的技术人员)
方案 B: Cloudflare Workers
- 利用边缘计算进行服务端验证
- 密码不存储在前端
- 安全性: 高(接近真正的后端验证)
- 成本: 需要学习和配置 Workers
示例文件
测试文章: content/测试密码保护.md
---
title: 测试密码保护功能
password: "test123"
date: 2025-11-25
tags:
- 测试
---
这是一篇受密码保护的文章。访问 /测试密码保护,输入密码 test123 即可查看内容。
相关文件
新增文件
quartz/components/PasswordProtection.tsx- 主组件(84 行)quartz/components/scripts/password.inline.ts- 客户端脚本(95 行)quartz/components/styles/password.scss- 样式文件(134 行)
修改文件
quartz/components/index.ts- 添加组件导出(+2 行)quartz.layout.ts- 注册组件到布局(+1 行)
文档文件
content/测试密码保护.md- 测试文章博客密码保护功能说明.md- 功能使用文档
提交信息
实现博客文章密码保护功能
- 新增 PasswordProtection 组件支持单篇文章密码保护
- 使用 Base64 编码进行前端密码验证
- localStorage 记忆已解锁文章
- 支持亮暗主题自动切换
- 密码错误时显示抖动动画反馈
总结
这个密码保护功能为 Quartz 博客提供了内容分级访问能力,适合个人博客、知识库等场景。虽然安全等级不高,但足以满足”礼貌性拦截”的需求,让不懂技术的访客知道”这是私密内容”。
对于真正需要保密的内容,建议不要发布到公开博客,或使用 Notion/飞书等有真正权限控制的平台。