文章密碼保護

此文章需要密碼才能訪問,請輸入正確的密碼

3444 字
17 分鐘
🔐 文章加密功能開發紀錄
Cover image for 🔐 文章加密功能開發紀錄

#🔐 文章加密功能開發紀錄

這是一個完整的文章密碼保護功能開發紀錄。通過這個功能,你可以為任何文章添加密碼保護,只有輸入正確密碼的讀者才能訪問內容。

#🎯 功能特點

  • 密碼保護:文章需要密碼才能訪問
  • 美觀界面:現代化設計,支持深色/淺色主題
  • 錯誤處理:三次錯誤後自動返回首頁
  • 動態提示:實時顯示剩餘嘗試次數
  • 會話記憶:同一次瀏覽器會話中不需要重複輸入
  • 多種取消方式:按鈕、ESC鍵、點擊背景
  • 精確遮罩:只模糊文章內容,不影響導航元素
  • 響應式設計:在各種設備上正常顯示

#📝 使用方法

#1. 在文章的 frontmatter 中添加:

---
title: 文章標題
# ... 其他字段 ...
password: "密碼" # 設置密碼
redirectUrl: "/" # 可選,密碼錯誤時跳轉的頁面
---

#2. 訪問文章時會自動彈出密碼輸入框

#3. 輸入正確密碼後可以正常閱讀文章

#🔧 技術實現詳解

#核心組件:PasswordProtection.astro

#1. 組件結構

---
import { Icon } from "astro-icon/components";
interface Props {
password: string;
redirectUrl?: string;
title?: string;
description?: string;
}
const {
password,
redirectUrl = "/",
title = "密碼保護",
description = "此文章需要密碼才能訪問",
} = Astro.props;
---

#2. HTML 結構

<!-- 密碼保護遮罩 -->
<div id="password-protection" class="fixed inset-0 z-[9999] flex items-start justify-center p-4 pt-20" style="display: flex !important;">
<!-- 密碼輸入模塊 -->
<div class="bg-[var(--card-bg)] border border-[var(--line-divider)] rounded-2xl p-8 max-w-md w-full shadow-2xl relative z-10">
<!-- 標題 -->
<div class="text-center mb-6">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--primary)]/10 flex items-center justify-center">
<Icon name="material-symbols:lock-outline" class="text-2xl text-[var(--primary)]" />
</div>
<h2 class="text-xl font-bold text-black/90 dark:text-white/90 mb-2">{title}</h2>
<p class="text-sm text-black/60 dark:text-white/60">{description}</p>
</div>
<!-- 密碼輸入表單 -->
<form id="password-form" class="space-y-6">
<div>
<input
type="password"
id="password-input"
placeholder="輸入密碼..."
class="w-full p-4 border border-[var(--line-divider)] rounded-xl bg-[var(--card-bg)] text-black/90 dark:text-white/90 placeholder:text-black/50 dark:placeholder:text-white/50 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-[var(--primary)] transition-all text-base"
required
autocomplete="current-password"
>
</div>
<div class="flex gap-4">
<button
type="submit"
class="flex-1 bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-white font-semibold px-8 py-4 rounded-xl transition-all hover:scale-105 text-base flex items-center justify-center gap-3 shadow-lg hover:shadow-xl"
>
<Icon name="material-symbols:login" class="text-xl" />
確認進入
</button>
<button
type="button"
id="cancel-btn"
class="flex-1 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold px-8 py-4 rounded-xl transition-all hover:scale-105 text-base flex items-center justify-center gap-3 shadow-lg hover:shadow-xl"
>
<Icon name="material-symbols:close" class="text-xl" />
返回首頁
</button>
</div>
</form>
<!-- 錯誤提示 -->
<div id="error-message" class="hidden mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<div class="flex items-center gap-2 text-red-600 dark:text-red-400 text-sm">
<Icon name="material-symbols:error-outline" class="text-lg" />
<span>密碼錯誤,還剩 3 次機會</span>
</div>
</div>
</div>
</div>

#3. CSS 樣式

<style>
/* 確保文章容器有相對定位 */
#post-container {
position: relative !important;
}
/* 確保密碼保護容器始終可見 */
#password-protection {
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
z-index: 9999 !important;
}
/* 當隱藏時,覆蓋所有樣式 */
#password-protection.hidden {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
z-index: -1 !important;
pointer-events: none !important;
}
/* 確保遮罩正確顯示 */
#post-mask {
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
background: rgba(0, 0, 0, 0.5) !important;
backdrop-filter: blur(4px) !important;
border-radius: 1rem !important;
z-index: 9990 !important;
pointer-events: none !important;
display: block !important;
opacity: 1 !important;
visibility: visible !important;
}
</style>

#4. JavaScript 實現(完整版)

<script define:vars={{ password, redirectUrl }}>
class PasswordProtection {
constructor() {
this.password = password;
this.redirectUrl = redirectUrl;
this.container = document.getElementById('password-protection');
this.mask = null;
this.form = document.getElementById('password-form');
this.input = document.getElementById('password-input');
this.errorMessage = document.getElementById('error-message');
this.cancelBtn = document.getElementById('cancel-btn');
this.errorCount = 0;
this.maxErrors = 3;
this.isInitialized = false;
this.init();
}
init() {
// 確保只初始化一次
if (this.isInitialized) return;
this.isInitialized = true;
// 檢查是否已經通過驗證
if (this.isAuthenticated()) {
this.hideProtection();
return;
}
// 顯示保護並創建遮罩
this.showProtection();
this.createAndShowMask();
this.bindEvents();
// 確保輸入框獲得焦點
setTimeout(() => {
if (this.input) {
this.input.focus();
}
}, 100);
}
showProtection() {
// 確保密碼保護容器可見
if (this.container) {
this.container.style.display = 'flex';
this.container.style.opacity = '1';
this.container.style.visibility = 'visible';
this.container.style.zIndex = '9999';
}
}
createAndShowMask() {
const postContainer = document.getElementById('post-container');
if (!postContainer) {
console.warn('找不到 post-container 元素');
return;
}
// 確保文章容器有相對定位
postContainer.style.position = 'relative';
// 移除現有的遮罩(如果存在)
const existingMask = document.getElementById('post-mask');
if (existingMask) {
existingMask.remove();
}
// 創建新的遮罩
this.mask = document.createElement('div');
this.mask.id = 'post-mask';
this.mask.style.cssText = `
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
background: rgba(0, 0, 0, 0.5) !important;
backdrop-filter: blur(4px) !important;
border-radius: 1rem !important;
z-index: 9990 !important;
pointer-events: none !important;
display: block !important;
opacity: 1 !important;
visibility: visible !important;
`;
// 將遮罩添加到文章容器
postContainer.appendChild(this.mask);
// 確保遮罩立即顯示
setTimeout(() => {
if (this.mask) {
this.mask.style.opacity = '1';
this.mask.style.visibility = 'visible';
}
}, 10);
}
bindEvents() {
if (this.form) {
this.form.addEventListener('submit', (e) => {
e.preventDefault();
this.handleSubmit();
});
}
if (this.cancelBtn) {
this.cancelBtn.addEventListener('click', () => {
this.handleCancel();
});
}
// 按 ESC 鍵取消
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.handleCancel();
}
});
// 點擊背景取消
if (this.container) {
this.container.addEventListener('click', (e) => {
if (e.target === this.container) {
this.handleCancel();
}
});
}
}
handleSubmit() {
if (!this.input) return;
const inputPassword = this.input.value.trim();
console.log('提交密碼驗證:', inputPassword === this.password ? '正確' : '錯誤');
if (inputPassword === this.password) {
console.log('密碼正確,開始隱藏保護');
this.setAuthenticated();
this.hideProtection();
this.showSuccessMessage();
} else {
console.log('密碼錯誤,顯示錯誤信息');
this.errorCount++;
this.showError();
this.input.value = '';
this.input.focus();
// 檢查是否達到最大錯誤次數
if (this.errorCount >= this.maxErrors) {
this.showMaxErrorWarning();
setTimeout(() => {
this.handleCancel();
}, 2000);
}
}
}
handleCancel() {
window.location.href = this.redirectUrl;
}
showError() {
if (this.errorMessage) {
this.errorMessage.classList.remove('hidden');
}
if (this.input) {
this.input.classList.add('border-red-500', 'focus:border-red-500', 'focus:ring-red-500');
}
// 更新錯誤信息
if (this.errorMessage) {
const errorText = this.errorMessage.querySelector('span');
if (errorText) {
const remainingAttempts = this.maxErrors - this.errorCount;
errorText.textContent = `密碼錯誤,還剩 ${remainingAttempts} 次機會`;
}
}
// 3秒後隱藏錯誤信息
setTimeout(() => {
if (this.errorMessage) {
this.errorMessage.classList.add('hidden');
}
if (this.input) {
this.input.classList.remove('border-red-500', 'focus:border-red-500', 'focus:ring-red-500');
}
}, 3000);
}
showMaxErrorWarning() {
// 創建最大錯誤警告
const warningDiv = document.createElement('div');
warningDiv.className = 'fixed top-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 flex items-center gap-2';
warningDiv.innerHTML = `
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
<span>錯誤次數過多,即將返回首頁...</span>
`;
document.body.appendChild(warningDiv);
// 2秒後移除警告
setTimeout(() => {
warningDiv.remove();
}, 2000);
}
hideProtection() {
console.log('開始隱藏密碼保護');
// 隱藏密碼保護容器
if (this.container) {
console.log('隱藏密碼保護容器');
// 使用多重方式確保隱藏
this.container.classList.add('hidden');
this.container.style.display = 'none';
this.container.style.opacity = '0';
this.container.style.visibility = 'hidden';
this.container.style.zIndex = '-1';
this.container.style.pointerEvents = 'none';
// 強制重新計算樣式
this.container.offsetHeight;
console.log('容器樣式已設置:', {
display: this.container.style.display,
opacity: this.container.style.opacity,
visibility: this.container.style.visibility,
zIndex: this.container.style.zIndex
});
} else {
console.warn('找不到密碼保護容器');
}
// 移除遮罩
if (this.mask) {
console.log('移除遮罩');
const postContainer = document.getElementById('post-container');
if (postContainer && postContainer.contains(this.mask)) {
postContainer.removeChild(this.mask);
}
this.mask = null;
}
// 清理任何現有的遮罩元素
const existingMask = document.getElementById('post-mask');
if (existingMask) {
console.log('清理現有遮罩');
existingMask.remove();
}
// 確保容器完全隱藏
setTimeout(() => {
if (this.container) {
console.log('最終確認隱藏');
this.container.style.display = 'none';
this.container.style.opacity = '0';
this.container.style.visibility = 'hidden';
this.container.style.zIndex = '-1';
this.container.style.pointerEvents = 'none';
}
}, 10);
}
showSuccessMessage() {
// 創建成功提示
const successDiv = document.createElement('div');
successDiv.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 flex items-center gap-2';
successDiv.innerHTML = `
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>密碼驗證成功!</span>
`;
document.body.appendChild(successDiv);
// 3秒後移除提示
setTimeout(() => {
successDiv.remove();
}, 3000);
}
isAuthenticated() {
const key = `auth_${window.location.pathname}`;
const authStatus = sessionStorage.getItem(key);
console.log('檢查認證狀態:', key, authStatus);
return authStatus === 'true';
}
setAuthenticated() {
const key = `auth_${window.location.pathname}`;
sessionStorage.setItem(key, 'true');
console.log('設置認證狀態:', key, 'true');
}
}
// 等待 DOM 完全載入後初始化
function initPasswordProtection() {
// 確保所有元素都已載入
if (document.getElementById('password-protection') && document.getElementById('post-container')) {
new PasswordProtection();
} else {
// 如果元素還沒載入,稍後再試
setTimeout(initPasswordProtection, 100);
}
}
// 多重保險:DOMContentLoaded 和 window.load
document.addEventListener('DOMContentLoaded', initPasswordProtection);
window.addEventListener('load', initPasswordProtection);
// 立即嘗試初始化
initPasswordProtection();
</script>

#5. 頁面模板集成

src/pages/posts/[...slug].astro 中集成:

---
import path from "node:path";
import Markdown from "@components/misc/Markdown.astro";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { getSortedPosts } from "@utils/content-utils";
import { getDir, getPostUrlBySlug } from "@utils/url-utils";
import { Icon } from "astro-icon/components";
import ImageWrapper from "../../components/misc/ImageWrapper.astro";
import PasswordProtection from "../../components/PasswordProtection.astro";
import PostMetadata from "../../components/PostMeta.astro";
export async function getStaticPaths() {
const blogEntries = await getSortedPosts();
return blogEntries.map((entry) => ({
params: { slug: entry.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content, headings } = await entry.render();
const { remarkPluginFrontmatter } = await entry.render();
// 檢查是否需要密碼保護
const isPasswordProtected = entry.data.password;
const password = entry.data.password || "";
const redirectUrl = entry.data.redirectUrl || "/";
---
<MainGridLayout
title={entry.data.title}
description={entry.data.description || entry.data.title}
setOGTypeArticle={true}
headings={headings}
>
{isPasswordProtected && (
<PasswordProtection
password={password}
redirectUrl={redirectUrl}
title="文章密碼保護"
description="此文章需要密碼才能訪問,請輸入正確的密碼"
/>
)}
<!-- 文章內容 -->
<div id="post-container" class="card-base z-10 px-6 md:px-9 pt-6 pb-4 relative w-full mb-6">
<!-- ... 其他文章內容 ... -->
</div>
</MainGridLayout>

#6. 類型定義更新

src/types/config.ts 中添加:

export interface BlogPostData {
// ... 其他字段 ...
password?: string;
redirectUrl?: string;
}

#7. 內容配置更新

src/content/config.ts 中添加:

import { defineCollection, z } from "astro:content";
export const postsCollection = defineCollection({
schema: z.object({
// ... 其他字段 ...
password: z.string().optional(),
redirectUrl: z.string().optional().default("/"),
}),
});

#🚧 開發過程與問題解決

#階段一:基礎功能實現

  1. 創建密碼保護組件:實現基本的密碼輸入和驗證
  2. 集成到文章頁面:在動態頁面模板中條件性渲染
  3. 添加類型定義:更新 TypeScript 類型以支持新字段

#階段二:UI/UX 優化

  1. 模態框定位問題

    • 問題:密碼輸入框出現在文章中間
    • 解決:調整 CSS 定位到頁面頂部
    .fixed inset-0 z-[9999] flex items-start justify-center p-4 pt-20
  2. 按鈕樣式優化

    • 問題:確認和取消按鈕不夠明顯
    • 解決:添加現代化按鈕樣式,包含圖標和懸停效果
  3. 錯誤處理改進

    • 問題:錯誤提示不夠清晰
    • 解決:添加動態錯誤計數和剩餘嘗試次數顯示

#階段三:遮罩系統實現

  1. 遮罩範圍控制

    • 問題:遮罩覆蓋整個頁面,影響導航
    • 解決:實現精確的文章內容遮罩
    // 動態創建遮罩並附加到文章容器
    this.mask = document.createElement('div');
    postContainer.appendChild(this.mask);
  2. z-index 層級管理

    • 問題:遮罩與其他元素層級衝突
    • 解決:設置正確的 z-index 值
    z-index: 9990 !important; /* 遮罩 */
    z-index: 9999 !important; /* 模態框 */
  3. 動態遮罩管理

    • 問題:靜態 HTML 遮罩在刷新時出現定位問題
    • 解決:完全動態創建和管理遮罩元素
    // 移除靜態 HTML,改為動態創建
    this.mask = null; // 初始化為 null
    createAndShowMask() // 動態創建方法

#階段四:認證狀態管理

  1. 存儲機制選擇

    • 考慮:localStorage vs sessionStorage
    • 決定:使用 sessionStorage,關閉瀏覽器後需要重新驗證
    sessionStorage.setItem(key, 'true');
  2. 頁面級別隔離

    • 實現:每個頁面有獨立的認證狀態
    const key = `auth_${window.location.pathname}`;

#階段五:問題調試與修復

  1. 刷新後遮罩重現問題

    • 問題:F5 刷新後遮罩覆蓋錯誤區域
    • 解決:完全重寫遮罩創建邏輯,確保正確的 DOM 位置
  2. 密碼驗證後模塊不消失問題

    • 問題:輸入正確密碼後密碼保護模塊沒有消失
    • 解決:使用多重隱藏機制和強制樣式重新計算
    // 多重隱藏方式
    this.container.classList.add('hidden');
    this.container.style.display = 'none';
    this.container.style.opacity = '0';
    this.container.style.visibility = 'hidden';
    this.container.style.zIndex = '-1';
    this.container.style.pointerEvents = 'none';
  3. 初始化時機問題

    • 問題:頁面載入時組件初始化失敗
    • 解決:添加多重保險的初始化機制
    // 多重保險:DOMContentLoaded 和 window.load
    document.addEventListener('DOMContentLoaded', initPasswordProtection);
    window.addEventListener('load', initPasswordProtection);
    initPasswordProtection(); // 立即嘗試

#🔒 安全機制詳解

#存儲機制

  • 存儲位置sessionStorage(會話存儲)
  • 存儲格式auth_${頁面路徑} = 'true'
  • 存儲範圍:同一個瀏覽器會話期間
  • 安全特點:關閉瀏覽器後自動清除

#驗證流程

  1. 頁面加載:檢查 sessionStorage 中是否有驗證記錄
  2. 密碼輸入:讀者輸入密碼並提交
  3. 密碼驗證:客戶端比對密碼是否正確
  4. 狀態更新:正確則設置驗證狀態並隱藏保護層
  5. 錯誤處理:錯誤則顯示錯誤信息,三次錯誤後自動返回

#安全特點

  • 客戶端驗證:密碼不會發送到服務器
  • 頁面級別保護:每個頁面有獨立的驗證狀態
  • 會話級別存儲:關閉瀏覽器後需要重新驗證
  • 錯誤次數限制:防止暴力破解
  • 精確遮罩控制:只保護文章內容,不影響導航

#⚠️ 注意事項與最佳實踐

#1. 技術限制

  • 靜態網站限制:由於是靜態網站,密碼存儲在客戶端
  • 安全性考慮:不適合存儲高敏感度內容
  • 讀者體驗:每次關閉瀏覽器後需要重新輸入密碼

#2. 實現要點

  • JavaScript 語法:在 Astro 的 <script> 標籤中使用純 JavaScript
  • 組件導入:確保正確導入 PasswordProtection 組件
  • 類型定義:更新相關的 TypeScript 類型定義
  • 內容配置:更新 Astro 內容集合的 schema

#3. 樣式適配

  • 主題支持:確保支持深色/淺色主題
  • 響應式設計:在不同設備上正常顯示
  • 品牌一致性:使用網站的 CSS 變量保持風格一致

#4. 讀者體驗

  • 錯誤提示:提供清晰的錯誤信息和剩餘嘗試次數
  • 多種取消方式:支持按鈕、鍵盤、背景點擊
  • 視覺反饋:成功和失敗都有明確的視覺提示

#🚀 部署注意事項

  1. 構建測試:確保在生產環境中正常運行
  2. 緩存清理:部署後清除瀏覽器緩存測試功能
  3. 跨瀏覽器測試:在不同瀏覽器中測試功能
  4. 移動端測試:確保在移動設備上正常使用

#📚 擴展功能建議

#可選的增強功能

  • localStorage 支持:改為持久化存儲
  • 密碼強度檢查:添加密碼複雜度要求
  • 多語言支持:支持國際化
  • 自定義樣式:允許自定義界面樣式
  • 統計功能:記錄訪問統計(需要後端支持)

#🎯 總結

這個文章加密功能實現了以下核心目標:

  1. 簡潔易用:只需在 frontmatter 中添加兩個字段
  2. 美觀實用:現代化 UI 設計,支持主題切換
  3. 安全可靠:客戶端驗證,會話級別存儲
  4. 讀者友好:多種交互方式,清晰的錯誤提示
  5. 技術先進:使用 Astro 組件系統,TypeScript 類型安全
  6. 問題解決:完整解決了遮罩顯示、模塊隱藏等關鍵問題

通過這個完整的開發紀錄,你可以輕鬆在自己的 Astro 網站中實現類似的文章加密功能。所有技術細節都經過實際測試,確保可行。


開發日期:2025年7月31日
開發者:Illumi糖糖 + 本地部屬AI(Ollama)
技術棧:Astro + TypeScript + Tailwind CSS
測試密碼:1122

💬 參與討論
使用 GitHub 帳號登入參與討論