文章密碼保護
此文章需要密碼才能訪問,請輸入正確的密碼
  密碼錯誤,還剩 3 次機會 
 3444 字
  19 分鐘
  🔐 文章加密功能開發紀錄 
  
 🔐 文章加密功能開發紀錄
這是一個完整的文章密碼保護功能開發紀錄。通過這個功能,你可以為任何文章添加密碼保護,只有輸入正確密碼的讀者才能訪問內容。
🎯 功能特點
- ✅ 密碼保護:文章需要密碼才能訪問
- ✅ 美觀界面:現代化設計,支持深色/淺色主題
- ✅ 錯誤處理:三次錯誤後自動返回首頁
- ✅ 動態提示:實時顯示剩餘嘗試次數
- ✅ 會話記憶:同一次瀏覽器會話中不需要重複輸入
- ✅ 多種取消方式:按鈕、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 帳號登入參與討論
  
 .png)