Skip to content

Commit de611b9

Browse files
committed
feat: 新增隐藏指定文章组件
1 parent 96d1d11 commit de611b9

File tree

8 files changed

+592
-2
lines changed

8 files changed

+592
-2
lines changed

docs/.vuepress/client.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import { defineClientConfig } from "vuepress/client";
22
import { h } from "vue";
33
import LayoutToggle from "./components/LayoutToggle.vue";
4+
import UnlockContent from "./components/unlock/UnlockContent.vue";
5+
import GlobalUnlock from "./components/unlock/GlobalUnlock.vue";
46

57
export default defineClientConfig({
8+
enhance({ app }) {
9+
// 注册手动解锁组件
10+
app.component("UnlockContent", UnlockContent);
11+
},
612
rootComponents: [
7-
// 将切换按钮添加为根组件,会在所有页面显示
13+
// 全局切换按钮
814
() => h(LayoutToggle),
15+
// 全局扫码解锁控制器
16+
() => h(GlobalUnlock),
917
],
1018
});
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
<template>
2+
<div v-if="isLockedPage && !isUnlocked" class="global-lock-root">
3+
<Teleport to="body">
4+
<div class="lock-overlay-container">
5+
<div class="lock-card">
6+
<div class="lock-header">
7+
<span class="lock-icon">🔒</span>
8+
<h3 class="lock-title">继续阅读全文</h3>
9+
</div>
10+
<p class="lock-reason">
11+
抱歉,由于近期遭受大规模爬虫攻击,为保障正常阅读体验,本站部分内容已开启一次性验证。验证后全站自动解锁。
12+
</p>
13+
<div class="qr-container">
14+
<img :src="config.qrCodeUrl" alt="公众号二维码" class="qr-image" />
15+
<p class="qr-tip">
16+
扫码/微信搜索关注
17+
<span class="highlight">JavaGuide</span> 官方公众号
18+
</p>
19+
<p class="qr-tip">
20+
回复 <span class="highlight">“验证码”</span> 获取
21+
</p>
22+
</div>
23+
<div class="input-wrapper">
24+
<input
25+
v-model="inputCode"
26+
type="text"
27+
placeholder="输入验证码"
28+
class="unlock-input"
29+
maxlength="4"
30+
@keyup.enter="handleUnlock"
31+
/>
32+
<button class="unlock-btn" @click="handleUnlock">立即解锁</button>
33+
</div>
34+
<transition name="shake">
35+
<p v-if="showError" class="error-msg">验证码错误,请重试</p>
36+
</transition>
37+
<p class="lock-footer">感谢你的理解与支持</p>
38+
</div>
39+
</div>
40+
</Teleport>
41+
</div>
42+
</template>
43+
44+
<script setup lang="ts">
45+
import { computed, onMounted, ref, watch } from "vue";
46+
import { usePageData } from "vuepress/client";
47+
import {
48+
PREVIEW_HEIGHT,
49+
unlockConfig as config,
50+
} from "../../features/unlock/config";
51+
52+
const pageData = usePageData();
53+
const isUnlocked = ref(false);
54+
const inputCode = ref("");
55+
const showError = ref(false);
56+
const globalUnlockKey = `javaguide_site_unlocked_${config.unlockVersion ?? "v1"}`;
57+
58+
const normalizePath = (path: string) =>
59+
path.replace(/\/$/, "").replace(".html", "").toLowerCase();
60+
61+
const isLockedPage = computed(() => {
62+
const currentPath = normalizePath(pageData.value.path);
63+
return Object.keys(config.protectedPaths)
64+
.map((p) => normalizePath(p))
65+
.includes(currentPath);
66+
});
67+
68+
const visibleHeight = computed(() => {
69+
const currentPath = normalizePath(pageData.value.path);
70+
const matched = Object.keys(config.protectedPaths).find(
71+
(p) => normalizePath(p) === currentPath,
72+
);
73+
return matched ? config.protectedPaths[matched] : PREVIEW_HEIGHT.LONG;
74+
});
75+
76+
const readUnlockState = () => {
77+
if (typeof window === "undefined") return;
78+
isUnlocked.value = localStorage.getItem(globalUnlockKey) === "true";
79+
};
80+
81+
const findContentSelector = () => {
82+
const selectors = [
83+
".theme-hope-content",
84+
".vp-content",
85+
".content__default",
86+
".vp-page-content",
87+
"article",
88+
"main",
89+
];
90+
for (const selector of selectors) {
91+
if (document.querySelector(selector)) return selector;
92+
}
93+
return "main";
94+
};
95+
96+
const applyLockStyle = () => {
97+
if (typeof document === "undefined") return;
98+
99+
const styleId = "unlock-global-style";
100+
let styleEl = document.getElementById(styleId) as HTMLStyleElement | null;
101+
102+
if (isLockedPage.value && !isUnlocked.value) {
103+
const target = findContentSelector();
104+
const css = `
105+
${target} {
106+
max-height: ${visibleHeight.value} !important;
107+
overflow: hidden !important;
108+
position: relative !important;
109+
}
110+
${target}::after {
111+
content: "" !important;
112+
position: absolute !important;
113+
left: 0 !important;
114+
right: 0 !important;
115+
bottom: 0 !important;
116+
height: 160px !important;
117+
background: linear-gradient(to bottom, transparent, var(--bg-color, #fff)) !important;
118+
pointer-events: none !important;
119+
z-index: 90 !important;
120+
}
121+
`;
122+
123+
if (!styleEl) {
124+
styleEl = document.createElement("style");
125+
styleEl.id = styleId;
126+
document.head.appendChild(styleEl);
127+
}
128+
styleEl.innerHTML = css;
129+
} else if (styleEl) {
130+
styleEl.innerHTML = "";
131+
}
132+
};
133+
134+
const handleUnlock = () => {
135+
if (inputCode.value === config.code) {
136+
isUnlocked.value = true;
137+
localStorage.setItem(globalUnlockKey, "true");
138+
applyLockStyle();
139+
} else {
140+
showError.value = true;
141+
inputCode.value = "";
142+
setTimeout(() => {
143+
showError.value = false;
144+
}, 2000);
145+
}
146+
};
147+
148+
onMounted(() => {
149+
readUnlockState();
150+
setTimeout(applyLockStyle, 80);
151+
});
152+
153+
watch(
154+
() => pageData.value.path,
155+
() => {
156+
readUnlockState();
157+
setTimeout(applyLockStyle, 80);
158+
},
159+
);
160+
</script>
161+
162+
<style>
163+
.lock-overlay-container {
164+
position: fixed;
165+
left: 0;
166+
right: 0;
167+
bottom: 32px;
168+
z-index: 9999;
169+
display: flex;
170+
justify-content: center;
171+
pointer-events: none;
172+
}
173+
174+
.lock-card {
175+
width: min(92vw, 480px);
176+
padding: 1.25rem;
177+
border-radius: 14px;
178+
border: 1px solid var(--border-color, #e5e7eb);
179+
background: var(--bg-color, #fff);
180+
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.12);
181+
text-align: center;
182+
pointer-events: auto;
183+
}
184+
185+
.lock-icon {
186+
font-size: 1.9rem;
187+
}
188+
189+
.lock-title {
190+
margin: 0.35rem 0 0;
191+
font-size: 1.2rem;
192+
}
193+
194+
.lock-reason {
195+
margin: 0.75rem 0 1rem;
196+
color: #64748b;
197+
line-height: 1.6;
198+
}
199+
200+
.qr-container {
201+
margin: 0 auto 1rem;
202+
padding: 0.85rem;
203+
max-width: 300px;
204+
border: 1px dashed #3eaf7c;
205+
border-radius: 10px;
206+
background: #f8fafc;
207+
}
208+
209+
.qr-image {
210+
width: 140px;
211+
height: 140px;
212+
}
213+
214+
.qr-tip {
215+
margin: 0.45rem 0 0;
216+
font-size: 0.86rem;
217+
}
218+
219+
.highlight {
220+
color: #3eaf7c;
221+
font-weight: 700;
222+
}
223+
224+
.input-wrapper {
225+
display: flex;
226+
justify-content: center;
227+
gap: 0.55rem;
228+
}
229+
230+
.unlock-input {
231+
width: 125px;
232+
padding: 0.5rem 0.75rem;
233+
border-radius: 8px;
234+
border: 1px solid #d1d5db;
235+
font-size: 1rem;
236+
text-align: center;
237+
}
238+
239+
.unlock-btn {
240+
padding: 0.5rem 1rem;
241+
border: 0;
242+
border-radius: 8px;
243+
background: #3eaf7c;
244+
color: #fff;
245+
font-weight: 700;
246+
cursor: pointer;
247+
}
248+
249+
.error-msg {
250+
margin: 0.45rem 0 0;
251+
color: #dc2626;
252+
font-size: 0.85rem;
253+
}
254+
255+
.lock-footer {
256+
margin: 0.7rem 0 0;
257+
color: #94a3b8;
258+
font-size: 0.8rem;
259+
}
260+
261+
.shake-enter-active {
262+
animation: shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
263+
}
264+
265+
@keyframes shake {
266+
10%,
267+
90% {
268+
transform: translate3d(-1px, 0, 0);
269+
}
270+
20%,
271+
80% {
272+
transform: translate3d(2px, 0, 0);
273+
}
274+
30%,
275+
50%,
276+
70% {
277+
transform: translate3d(-4px, 0, 0);
278+
}
279+
40%,
280+
60% {
281+
transform: translate3d(4px, 0, 0);
282+
}
283+
}
284+
285+
@media (max-width: 576px) {
286+
.input-wrapper {
287+
flex-direction: column;
288+
align-items: center;
289+
}
290+
291+
.unlock-input,
292+
.unlock-btn {
293+
width: min(220px, 80vw);
294+
}
295+
}
296+
</style>

0 commit comments

Comments
 (0)