JavaScript 已禁用

为了获得更好的体验,请启用JavaScript,或点击下方链接跳转:

跳转至百度
Skip to content

密码组件

此组件是一个密码组件 PasswordProtect ,支持根据路径进行密码保护,密码存在于本地 localStorage 中(存储数据经过了加密)

PasswordProtect.vue

vue
<template>
  <div class="password-protect-container">
    <div class="password-card">
      <!-- 返回按钮 -->
      <button class="back-btn" @click="handleBack" aria-label="返回上一页">
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M19 12H5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
          <path
            d="M12 19L5 12L12 5"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
          />
        </svg>
      </button>

      <!-- 标题区域 -->
      <div class="card-header">
        <div class="lock-icon">🔒</div>
        <h2>需要密码访问</h2>
        <p class="subtitle">该内容受保护,请输入正确密码</p>
      </div>

      <!-- 输入区域 -->
      <div class="input-group">
        <input
          v-model="input"
          type="password"
          @keyup.enter="submit"
          @input="handleInput"
          placeholder="请输入密码"
          :class="{ invalid: error }"
          aria-label="密码输入框"
        />
        <p v-if="error" class="error-message">{{ error }}</p>
      </div>

      <!-- 按钮区域 -->
      <div class="button-group">
        <button class="submit-btn" @click="submit" :disabled="!input.trim()">确认访问</button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vitepress";

const router = useRouter();
const props = defineProps<{
  correctPassword: string;
  pageId: string;
}>();

// eslint-disable-next-line func-call-spacing
const emit = defineEmits<{
  (e: "verified", success: boolean): void;
}>();

const input = ref("");
const error = ref("");

// 处理输入 - 过滤空格
const handleInput = () => {
  input.value = input.value.replace(/\s+/g, ""); // 移除所有空格
  if (error.value && input.value) {
    error.value = ""; // 输入内容时清除错误提示
  }
};

// 提交验证
const submit = () => {
  const trimmedInput = input.value.trim();

  if (!trimmedInput) {
    error.value = "请输入密码";
    return;
  }

  if (trimmedInput === props.correctPassword) {
    emit("verified", true);
  } else {
    error.value = "密码错误,请重新输入";
    input.value = "";
  }
};

// 返回上一页
const handleBack = () => {
  // 方案1:优先使用浏览器原生历史记录(最稳定,推荐)
  if (window.history.length > 1) {
    window.history.back(); // 退回上一页,不刷新
  } else {
    // 方案2:若没有历史记录,跳转到首页(避免卡在当前页)
    router.go("/");
  }

  // 备用方案:若需严格使用VitePress路由,可替换为以下代码
  // try {
  //   // 部分版本支持 router.go(-1)(历史记录后退1步)
  //   router.go(-1);
  // } catch (e) {
  //   router.go("/"); // 失败时兜底跳首页
  // }
};
</script>

<style scoped>
.password-protect-container {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  padding: 1rem;
  box-sizing: border-box;
  position: relative;
  background-color: var(--vp-c-bg);
  --error-color: orangered;
}

/* 卡片容器 */
.password-card {
  position: relative;
  z-index: 2;
  width: 100%;
  max-width: 420px;
  background: var(--vp-c-bg-soft);
  border-radius: 12px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
  padding: 2.5rem 2rem;
  border: 1px solid var(--vp-c-divider);
  transition: transform 0.3s ease;
}

/*.password-card:hover {
  transform: translateY(-5px);
  box-shadow: 0 15px 35px rgba(0, 0, 0, 0.12);
}*/

/* 返回按钮 */
.back-btn {
  position: absolute;
  top: 1rem;
  right: 1rem;
  background: transparent;
  border: none;
  color: var(--vp-c-text-2);
  cursor: pointer;
  padding: 0.5rem;
  border-radius: 6px;
  transition: all 0.2s ease;
}

.back-btn:hover {
  color: var(--vp-c-brand);
  background: var(--vp-c-bg);
}

/* 标题区域 */
.card-header {
  text-align: center;
  margin-bottom: 2rem;
}

.lock-icon {
  font-size: 3rem;
  margin-bottom: 1rem;
  opacity: 0.8;
}

.card-header h2 {
  margin: 0 0 0.5rem 0;
  color: var(--vp-c-text-1);
  font-size: 1.5rem;
  font-weight: 600;
}

.subtitle {
  margin: 0;
  color: var(--vp-c-text-2);
  font-size: 0.95rem;
  line-height: 1.5;
}

/* 输入区域 */
.input-group {
  margin-bottom: 1.5rem;
}

.input-group input {
  width: 100%;
  padding: 0.9rem 1rem;
  font-size: 1rem;
  border: 1px solid var(--vp-c-divider);
  border-radius: 8px;
  background: var(--vp-c-bg);
  color: var(--vp-c-text-1);
  box-sizing: border-box;
  transition: all 0.2s ease;
}

.input-group input::placeholder {
  color: var(--vp-c-text-3);
}

.input-group input:focus {
  outline: none;
  border-color: var(--vp-c-brand);
  box-shadow: 0 0 0 3px rgba(55, 118, 203, 0.1);
}

.input-group input.invalid {
  border-color: var(--error-color);
}

.error-message {
  margin: 0.5rem 0 0 0;
  color: var(--error-color);
  font-size: 0.85rem;
  text-align: left;
  padding-left: 0.25rem;
  animation: shake 0.5s ease;
}

/* 按钮区域 */
.button-group {
  margin-top: 1rem;
}

.submit-btn {
  width: 100%;
  padding: 0.9rem 1rem;
  font-size: 1rem;
  font-weight: 500;
  border: none;
  border-radius: 8px;
  background: var(--vp-c-brand);
  color: white;
  cursor: pointer;
  transition: all 0.2s ease;
}

.submit-btn:hover {
  transform: translateY(-2px);
}

.submit-btn:hover:not(:disabled) {
  background: dodgerblue;
}

.submit-btn:active:not(:disabled) {
  transform: translateY(0);
}

.submit-btn:disabled {
  background: dodgerblue;
  cursor: not-allowed;
  opacity: 0.7;
}

/* 错误动画 */
@keyframes shake {
  0%,
  100% {
    transform: translateX(0);
  }
  25% {
    transform: translateX(-5px);
  }
  75% {
    transform: translateX(5px);
  }
}

/* 响应式调整 */
@media (max-width: 480px) {
  .password-card {
    padding: 2rem 1.5rem;
  }

  .card-header h2 {
    font-size: 1.3rem;
  }

  .lock-icon {
    font-size: 2.5rem;
  }
}
</style>

util.ts

可以配置默认的密码保护规则

ts
import { ref, watch } from "vue";
import { useData, useRouter } from "vitepress";
import { isClient } from "vitepress-theme-teek";

// ========================= 核心配置(务必修改!)=========================
const ENCRYPTION_KEY = "your-32-char-key-here-12345678";
const SALT = new TextEncoder().encode("vp-protect-salt-2024");
const PBKDF2_ITERATIONS = 1000;
const LOCAL_STORAGE_KEY: string = "tk:vpVerifiedPages";

// 默认保护规则(支持前缀匹配一次性验证)
export const DEFAULT_PROTECTED_ROUTES: ProtectedRoute[] = [
  { path: "/private/*", password: "secret123" },
  { path: "/archives", password: "123" }
];

// ========================= 类型定义 =========================
export interface ProtectedRoute {
  path: string;
  password: string;
}

// ========================= 工具函数:Base64编解码 =========================
const uint8ToBase64 = (array: Uint8Array): string => {
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
};

const base64ToUint8 = (str: string): Uint8Array => {
  str = str.replace(/-/g, "+").replace(/_/g, "/");
  const pad = str.length % 4;
  if (pad) str += "=".repeat(4 - pad);

  const binary = atob(str);
  const array = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    array[i] = binary.charCodeAt(i);
  }
  return array;
};

// ========================= 加密核心函数 =========================
const generateVerifyKey = async (key: string): Promise<string> => {
  const encoder = new TextEncoder();
  const data = encoder.encode(`${key}::${ENCRYPTION_KEY}`);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  return uint8ToBase64(new Uint8Array(hashBuffer));
};

const getEncryptionKey = async (): Promise<CryptoKey> => {
  const encoder = new TextEncoder();
  const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(ENCRYPTION_KEY), { name: "PBKDF2" }, false, [
    "deriveKey"
  ]);

  return crypto.subtle.deriveKey(
    { name: "PBKDF2", salt: SALT, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
    keyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
};

const encryptData = async (data: string[]): Promise<string> => {
  try {
    const key = await getEncryptionKey();
    const encoder = new TextEncoder();
    const jsonStr = JSON.stringify(data);
    const dataBuffer = encoder.encode(jsonStr);
    const iv = crypto.getRandomValues(new Uint8Array(12));

    const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, dataBuffer);
    const encryptedArray = new Uint8Array(encrypted);
    const authTag = encryptedArray.slice(-16);
    const ciphertext = encryptedArray.slice(0, -16);

    return `${uint8ToBase64(iv)}|${uint8ToBase64(ciphertext)}|${uint8ToBase64(authTag)}`;
  } catch (e) {
    console.error("加密失败:", e);
    throw new Error("验证状态存储失败");
  }
};

const decryptData = async (encryptedStr: string): Promise<string[]> => {
  try {
    const [ivBase64, ciphertextBase64, authTagBase64] = encryptedStr.split("|");
    if (!ivBase64 || !ciphertextBase64 || !authTagBase64) {
      throw new Error("数据格式无效");
    }

    const iv = base64ToUint8(ivBase64);
    const ciphertext = base64ToUint8(ciphertextBase64);
    const authTag = base64ToUint8(authTagBase64);
    // 组合密文和认证标签
    const encrypted = new Uint8Array([...ciphertext, ...authTag]);

    const key = await getEncryptionKey();
    // eslint-disable-next-line no-undef
    const decryptedBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv as BufferSource }, key, encrypted);
    const decryptedStr = new TextDecoder().decode(decryptedBuffer);
    return JSON.parse(decryptedStr) as string[];
  } catch (e) {
    console.warn("解密失败:", e);
    localStorage.removeItem(LOCAL_STORAGE_KEY);
    return [];
  }
};

// ========================= 核心逻辑:按路径前缀管理验证状态 =========================
/**
 * 1. 匹配当前路径对应的保护规则(返回规则+前缀标识)
 * 例:/front/docs → 匹配 /front/* 规则,返回 { rule, prefix: "/front" }
 */
const matchProtectedRule = (
  currentPath: string,
  protectedRoutes: ProtectedRoute[]
): { rule: ProtectedRoute | null; prefix: string | null } => {
  for (const rule of protectedRoutes) {
    // 处理前缀匹配(如 /front/*)
    if (rule.path.endsWith("/*")) {
      const prefix = rule.path.slice(0, -1); // 提取前缀:/front/* → /front
      // 验证当前路径是否以该前缀开头(且不是前缀本身,避免匹配 /front)
      if (currentPath.startsWith(prefix) && currentPath !== prefix) {
        return { rule, prefix };
      }
    }
    // 处理精确匹配(如 /archives)
    else if (currentPath === rule.path) {
      return { rule, prefix: rule.path }; // 精确路径的前缀就是自身
    }
  }
  return { rule: null, prefix: null };
};

/**
 * 2. 获取已验证的前缀/路径集合
 */
const getVerifiedPrefixes = async (): Promise<Set<string>> => {
  if (!isClient) return new Set();

  try {
    const encryptedData = localStorage.getItem(LOCAL_STORAGE_KEY);
    if (!encryptedData) return new Set();

    const verifiedHashes = await decryptData(encryptedData);
    return new Set(verifiedHashes);
  } catch (e) {
    console.warn("获取验证状态失败:", e);
    localStorage.removeItem(LOCAL_STORAGE_KEY);
    return new Set();
  }
};

/**
 * 3. 标记前缀/路径为已验证(一次性验证核心:存储前缀而非单个页面)
 */
const markPrefixAsVerified = async (prefix: string): Promise<void> => {
  if (!isClient) return;

  try {
    // 生成前缀的加密哈希(避免手动篡改)
    const prefixHash = await generateVerifyKey(prefix);
    const verifiedPrefixes = await getVerifiedPrefixes();
    verifiedPrefixes.add(prefixHash);

    // 加密存储前缀哈希集合
    const encryptedData = await encryptData(Array.from(verifiedPrefixes));
    localStorage.setItem(LOCAL_STORAGE_KEY, encryptedData);
  } catch (e) {
    console.warn("标记验证状态失败:", e);
    throw new Error("验证状态存储失败,请重试");
  }
};

/**
 * 4. 检查当前页面是否需要密码(按前缀验证)
 */
const shouldShowPassword = async (
  currentPath: string,
  frontmatterPassword: string | undefined,
  protectedRoutes: ProtectedRoute[] = DEFAULT_PROTECTED_ROUTES
): Promise<{ show: boolean; password: string | null; verifyPrefix: string | null }> => {
  // 优先处理frontmatter密码(单个页面独立验证)
  if (frontmatterPassword) {
    const verifyKey = currentPath; // 精确页面路径
    const verifiedPrefixes = await getVerifiedPrefixes();
    const verifyKeyHash = await generateVerifyKey(verifyKey);
    const isVerified = verifiedPrefixes.has(verifyKeyHash);

    return {
      show: !isVerified,
      password: frontmatterPassword,
      verifyPrefix: verifyKey // 存储的是单个页面路径
    };
  }

  // 处理路由规则(前缀/精确匹配)
  const { rule, prefix } = matchProtectedRule(currentPath, protectedRoutes);
  if (!rule || !prefix) {
    return { show: false, password: null, verifyPrefix: null };
  }

  // 检查该前缀是否已验证
  const verifiedPrefixes = await getVerifiedPrefixes();
  const prefixHash = await generateVerifyKey(prefix);
  const isVerified = verifiedPrefixes.has(prefixHash);

  return {
    show: !isVerified,
    password: rule.password,
    verifyPrefix: prefix // 存储的是前缀(如 /front)
  };
};

// ========================= 核心Hook =========================
export function usePasswordProtection(customRoutes?: ProtectedRoute[]) {
  const { frontmatter } = useData();
  const router = useRouter();

  const showPassword = ref(false);
  const currentPassword = ref("");
  const currentVerifyPrefix = ref<string | null>(null); // 改为存储「验证前缀」而非页面ID
  const isChecking = ref(false);

  // 检查页面保护状态
  const checkProtection = async (path: string) => {
    if (!isClient) return;

    isChecking.value = true;
    try {
      const fmPassword = frontmatter.value?.password as string | undefined;
      const routes = customRoutes || DEFAULT_PROTECTED_ROUTES;
      const result = await shouldShowPassword(path, fmPassword, routes);

      showPassword.value = result.show;
      currentPassword.value = result.password || "";
      currentVerifyPrefix.value = result.verifyPrefix; // 记录需要验证的前缀
    } catch (e) {
      console.error("检查页面保护状态失败:", e);
    } finally {
      isChecking.value = false;
    }
  };

  // 监听路由变化(切换/front/*下页面时自动检查)
  watch(
    () => router.route.path,
    async newPath => {
      if (isClient) {
        await checkProtection(newPath);
      }
    },
    { immediate: true }
  );

  // 处理验证成功(标记前缀为已验证)
  const handleVerified = async (success: boolean) => {
    if (success && currentVerifyPrefix.value && !isChecking.value) {
      try {
        await markPrefixAsVerified(currentVerifyPrefix.value);
        showPassword.value = false;
      } catch (e) {
        alert("验证成功,但状态存储失败,请重新尝试");
      }
    }
  };

  return {
    showPassword,
    currentPassword,
    currentVerifyPrefix,
    isChecking,
    handleVerified
  };
}

配置

docs/.vitepress/theme/index.ts中进行配置,使用密码组件

ts
  Layout: defineComponent({
    name: "LayoutProvider",
    setup() {
      const props: { class?: string } = {};
      const { frontmatter } = useData();

      // 根据元数据动态应用 CSS 类,实现页面级样式定制
      if (frontmatter.value?.layoutClass) {
        props.class = frontmatter.value.layoutClass;
      }

      const { showPassword, currentPassword, currentVerifyPrefix, handleVerified } = usePasswordProtection();

      // 渲染函数
      return () => {
        if (showPassword.value) {
          return h(PasswordProtect, {
            correctPassword: currentPassword.value,
            pageId: currentVerifyPrefix.value,
            onVerified: handleVerified
          });
        }

        // 正常渲染 Teek 布局
        return h(TeekLayoutProvider, props);
      };
    }
  }),

作者:威威

版权:此文章版权归 威威 所有,如有转载,请注明出处!

链接:可点击右上角分享此页面复制文章链接

最后更新于:

最近更新