00:00:00
密码组件
此组件是一个密码组件 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);
};
}
}),