辛德拉 / Coser月潋寒觞
文章密碼保護
此文章需要密碼才能訪問,請輸入正確的密碼
密碼錯誤,還剩 3 次機會
3444 字
17 分鐘
🔐 文章加密功能開發紀錄

🔐 文章加密功能開發紀錄
這是一個完整的文章密碼保護功能開發紀錄。通過這個功能,你可以為任何文章添加密碼保護,只有輸入正確密碼的讀者才能訪問內容。
🎯 功能特點
- ✅ 密碼保護:文章需要密碼才能訪問
- ✅ 美觀界面:現代化設計,支持深色/淺色主題
- ✅ 錯誤處理:三次錯誤後自動返回首頁
- ✅ 動態提示:實時顯示剩餘嘗試次數
- ✅ 會話記憶:同一次瀏覽器會話中不需要重複輸入
- ✅ 多種取消方式:按鈕、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("/"), }),});
🚧 開發過程與問題解決
階段一:基礎功能實現
- 創建密碼保護組件:實現基本的密碼輸入和驗證
- 集成到文章頁面:在動態頁面模板中條件性渲染
- 添加類型定義:更新 TypeScript 類型以支持新字段
階段二:UI/UX 優化
-
模態框定位問題:
- 問題:密碼輸入框出現在文章中間
- 解決:調整 CSS 定位到頁面頂部
.fixed inset-0 z-[9999] flex items-start justify-center p-4 pt-20 -
按鈕樣式優化:
- 問題:確認和取消按鈕不夠明顯
- 解決:添加現代化按鈕樣式,包含圖標和懸停效果
-
錯誤處理改進:
- 問題:錯誤提示不夠清晰
- 解決:添加動態錯誤計數和剩餘嘗試次數顯示
階段三:遮罩系統實現
-
遮罩範圍控制:
- 問題:遮罩覆蓋整個頁面,影響導航
- 解決:實現精確的文章內容遮罩
// 動態創建遮罩並附加到文章容器this.mask = document.createElement('div');postContainer.appendChild(this.mask); -
z-index 層級管理:
- 問題:遮罩與其他元素層級衝突
- 解決:設置正確的 z-index 值
z-index: 9990 !important; /* 遮罩 */z-index: 9999 !important; /* 模態框 */ -
動態遮罩管理:
- 問題:靜態 HTML 遮罩在刷新時出現定位問題
- 解決:完全動態創建和管理遮罩元素
// 移除靜態 HTML,改為動態創建this.mask = null; // 初始化為 nullcreateAndShowMask() // 動態創建方法
階段四:認證狀態管理
-
存儲機制選擇:
- 考慮:localStorage vs sessionStorage
- 決定:使用 sessionStorage,關閉瀏覽器後需要重新驗證
sessionStorage.setItem(key, 'true'); -
頁面級別隔離:
- 實現:每個頁面有獨立的認證狀態
const key = `auth_${window.location.pathname}`;
階段五:問題調試與修復
-
刷新後遮罩重現問題:
- 問題:F5 刷新後遮罩覆蓋錯誤區域
- 解決:完全重寫遮罩創建邏輯,確保正確的 DOM 位置
-
密碼驗證後模塊不消失問題:
- 問題:輸入正確密碼後密碼保護模塊沒有消失
- 解決:使用多重隱藏機制和強制樣式重新計算
// 多重隱藏方式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'; -
初始化時機問題:
- 問題:頁面載入時組件初始化失敗
- 解決:添加多重保險的初始化機制
// 多重保險:DOMContentLoaded 和 window.loaddocument.addEventListener('DOMContentLoaded', initPasswordProtection);window.addEventListener('load', initPasswordProtection);initPasswordProtection(); // 立即嘗試
🔒 安全機制詳解
存儲機制
- 存儲位置:
sessionStorage
(會話存儲) - 存儲格式:
auth_${頁面路徑} = 'true'
- 存儲範圍:同一個瀏覽器會話期間
- 安全特點:關閉瀏覽器後自動清除
驗證流程
- 頁面加載:檢查 sessionStorage 中是否有驗證記錄
- 密碼輸入:讀者輸入密碼並提交
- 密碼驗證:客戶端比對密碼是否正確
- 狀態更新:正確則設置驗證狀態並隱藏保護層
- 錯誤處理:錯誤則顯示錯誤信息,三次錯誤後自動返回
安全特點
- ✅ 客戶端驗證:密碼不會發送到服務器
- ✅ 頁面級別保護:每個頁面有獨立的驗證狀態
- ✅ 會話級別存儲:關閉瀏覽器後需要重新驗證
- ✅ 錯誤次數限制:防止暴力破解
- ✅ 精確遮罩控制:只保護文章內容,不影響導航
⚠️ 注意事項與最佳實踐
1. 技術限制
- 靜態網站限制:由於是靜態網站,密碼存儲在客戶端
- 安全性考慮:不適合存儲高敏感度內容
- 讀者體驗:每次關閉瀏覽器後需要重新輸入密碼
2. 實現要點
- JavaScript 語法:在 Astro 的
<script>
標籤中使用純 JavaScript - 組件導入:確保正確導入
PasswordProtection
組件 - 類型定義:更新相關的 TypeScript 類型定義
- 內容配置:更新 Astro 內容集合的 schema
3. 樣式適配
- 主題支持:確保支持深色/淺色主題
- 響應式設計:在不同設備上正常顯示
- 品牌一致性:使用網站的 CSS 變量保持風格一致
4. 讀者體驗
- 錯誤提示:提供清晰的錯誤信息和剩餘嘗試次數
- 多種取消方式:支持按鈕、鍵盤、背景點擊
- 視覺反饋:成功和失敗都有明確的視覺提示
🚀 部署注意事項
- 構建測試:確保在生產環境中正常運行
- 緩存清理:部署後清除瀏覽器緩存測試功能
- 跨瀏覽器測試:在不同瀏覽器中測試功能
- 移動端測試:確保在移動設備上正常使用
📚 擴展功能建議
可選的增強功能
- localStorage 支持:改為持久化存儲
- 密碼強度檢查:添加密碼複雜度要求
- 多語言支持:支持國際化
- 自定義樣式:允許自定義界面樣式
- 統計功能:記錄訪問統計(需要後端支持)
🎯 總結
這個文章加密功能實現了以下核心目標:
- 簡潔易用:只需在 frontmatter 中添加兩個字段
- 美觀實用:現代化 UI 設計,支持主題切換
- 安全可靠:客戶端驗證,會話級別存儲
- 讀者友好:多種交互方式,清晰的錯誤提示
- 技術先進:使用 Astro 組件系統,TypeScript 類型安全
- 問題解決:完整解決了遮罩顯示、模塊隱藏等關鍵問題
通過這個完整的開發紀錄,你可以輕鬆在自己的 Astro 網站中實現類似的文章加密功能。所有技術細節都經過實際測試,確保可行。
開發日期:2025年7月31日
開發者:Illumi糖糖 + 本地部屬AI(Ollama)
技術棧:Astro + TypeScript + Tailwind CSS
測試密碼:1122
參與討論
使用 GitHub 帳號登入參與討論